乐于分享
好东西不私藏

OpenCode、OpenClaw、Hermes对外APIs接口

OpenCode、OpenClaw、Hermes对外APIs接口

目前流行的AI Agent,无非就是Claude Code、Codex、OpenCode、OpenClaw、Hermes,以及国内的WorkBuddy、QwenPaw、QoderWork、MimoCode等。

对于如下的一个场景:你在一台机器上,部署了一个Agent,并配置了相应的Skills、mcp等,但是想在另一台机器上,调用这个Agent,就有些难了。

  • 有些只是单纯的本地工具,无法通过远程调用,没有对外的APIs接口,例如Claude Code、WorkBuddy等。

  • 有些因为高风险,导致公司要求只能部署在单独的服务器,例如OpenClaw、Hermes等,更需要对外的APIs接口。

本文只是简单描述了OpenCode、OpenClaw、Hermes的对外APIs,以及使用方法,不涉及A2A相关。

01

OpenCode

OpenCode是一个GitHub开源的项目,自带对外的APIs接口,可通过opencode serve启动,最好是先进入到你的项目,再启动,这样就会自动加载项目中的配置、skill等。

详细文档,可参考:

https://opencode.ai/docs/zh-cn/server

可设置用户名、密码、端口等,增强链接的安全性。

对于基于OpenCode开发的mimo code、mobile code等,是否支持APIs,需要自行尝试。

1、简单用法就两步:

  1. 发送POST /session接口,创建一个新的会话。获取session id。

  2. 发送POST /session/:id/message接口,发送消息并等待响应。

  • 请求体:{ messageID?, model?, agent?, noReply?, system?, tools?, parts }

  • 返回:{ info: Message, parts: Part[]}

这里需要注意的是parts部分,他不是常规的openai的/v1/chat/completions或者/v1/responses格式。

最简单的就是{“parts”: [{“type”: “text”, “text”: “hello”}]},这里的type,可以换成多种方式reasoning、file、tool等等。

要想知道parts里具体都有啥,以及返回信息,这边有个简单的脚本,可以让你看看自己的会话中触发了哪些:

def jiexi(response):    print(response.text)    data = response.json() if isinstance(response.json(), listelse [response.json()]    for r in data:        role = r['info']['role']        if role == 'user':            print(f"{role}{r['parts'][0]['text']}")        elif role == 'assistant':            for p in r['parts']:                if p["type"] == "reasoning":#思考                    pass                elif p["type"] == "text":                    print(f"{role}{p['text']}")res1 = requests.get(    url='http://127.0.0.1:4096/session/ses_idxxxxxx/message',)#{"name":"NotFoundError","data":{"message":"Session not found: ses_116935062ffejURD7xVWvGDax"}}if "Session not found" in res1.text:    pass    # 需要重新创建会话else:    jiexi(res1)

2、加个中间层fastapi

为啥要加这个,因为我希望,他对同一用户,使用同一session。在这一层中,加了用户和session的映射关系。OpenCode对于同一session,会自动发送上下文给大模型,免去我们手动保存添加的步骤。

1、用户发来相关问题{phone:xxxxxx, query:xxxxxx}。

2、中间层根据phone,获取对应的session id,若无,则创建新session,同时添加系统提示词。

3、中间层转发query给OpenCode。

4、OpenCode根据session里的上下文、skill、mcp等信息,回答用户。

下面是一段示例代码,实现了基础的功能,但有一些问题:

1、相关配置,没有单独提取出来;

2、用的是字典的方式保存映射关系,可以让AI修改成redis等来保存。

import asynciofrom contextlib import asynccontextmanagerfrom typing import Optionalimport httpxfrom fastapi import FastAPI, HTTPExceptionfrom loguru import loggerfrom pydantic import BaseModel# ==================== 日志配置 ====================# 移除默认 handler,避免重复打印logger.remove()# 1. 控制台输出:带颜色,仅显示 INFO 及以上logger.add(    lambda msg: print(msg, end=""),  # 兼容 Windows 控制台颜色    level="INFO",    format="<green>{time:HH:mm:ss}</green> | <level>{level:<7}</level> | {message}",    colorize=True)# 2. 文件输出:按天切割,保留30天,记录完整内容logger.add(    "logs/opencode_{time:YYYY-MM-DD}.log",    rotation="00:00",       # 每天零点切割    retention="30 days",    # 保留30天    encoding="utf-8",    level="DEBUG",    format="{time:YYYY-MM-DD HH:mm:ss.SSS} | {level:<7} | {message}",    enqueue=True            # 🔑 异步写入,不阻塞 FastAPI 事件循环)# ==================== 数据模型 ====================class ChatRequest(BaseModel):    phone: str    query: strclass ChatResponse(BaseModel):    phone: str    session_id: str    reply: str# ==================== OpenCode 客户端 ====================class OpenCodeClient:    # 🔧 在此处定义系统提示词,方便后续维护    SYSTEM_PROMPT = (        "你是一个专业的智能助手。请始终使用中文回答,"        "保持语气友好、专业,并在回答前仔细分析用户的问题。"    )    def __init__(self, base_url: str = "http://127.0.0.1:4096"):        self.base_url = base_url.rstrip("/")        self.phone_session_map: dict[strstr] = {}        self._lock = asyncio.Lock()        self._client: Optional[httpx.AsyncClient] = None    async def start(self):        self._client = httpx.AsyncClient(timeout=60*10)        logger.info("OpenCode HTTP 客户端已初始化")    async def stop(self):        if self._client:            await self._client.aclose()        logger.info("OpenCode HTTP 客户端已关闭")    async def _create_session(self, phone: str) -> str:        # 1. 创建新会话        res = await self._client.post(f"{self.base_url}/session")        res.raise_for_status()        session_id = res.json()["id"]        logger.info(f"[新建会话] phone={phone} | session_id={session_id}")        # 🔑 2. 首次创建时,立即注入系统提示词        sys_payload = {            "parts": [{"type""text""text"self.SYSTEM_PROMPT}]        }        sys_res = await self._client.post(            f"{self.base_url}/session/{session_id}/message",            json=sys_payload,            headers={'Content-Type''application/json'}        )        if sys_res.status_code == 200:            logger.debug(f"[系统提示词注入成功] phone={phone} | session={session_id}")        else:            logger.warning(                f"[系统提示词注入失败] phone={phone} | session={session_id} | "                f"status={sys_res.status_code} | body={sys_res.text}"            )        return session_id    async def _session_exists(self, session_id: str) -> bool:        res = await self._client.get(f"{self.base_url}/session/{session_id}/message")        return "Session not found" not in res.text    async def get_or_create_session(self, phone: str) -> str:        async with self._lock:            session_id = self.phone_session_map.get(phone)            if session_id:                if await self._session_exists(session_id):                    logger.debug(f"[复用会话] phone={phone} | session_id={session_id}")                    return session_id                else:                    logger.warning(f"[会话失效] phone={phone} | old_session={session_id}")                    del self.phone_session_map[phone]            new_session_id = await self._create_session(phone)            self.phone_session_map[phone] = new_session_id            return new_session_id    @staticmethod    def _extract_reply(data) -> str:        items = data if isinstance(data, listelse [data]        texts = []        for r in items:            if r.get("info", {}).get("role") == "assistant":                for p in r.get("parts", []):                    if p.get("type") == "text":                        texts.append(p["text"])        return "\n".join(texts)    async def chat(self, phone: str, query: str) -> dict:        # 📝 记录用户发送内容        logger.info(f"[用户提问] phone={phone} | query={query!r}")        session_id = await self.get_or_create_session(phone)        payload = {"parts": [{"type""text""text": query}]}        res = await self._client.post(            f"{self.base_url}/session/{session_id}/message",            json=payload,        )        # 极端情况重试        if "Session not found" in res.text:            logger.warning(f"[会话异常重试] phone={phone} | session={session_id}")            async with self._lock:                self.phone_session_map.pop(phone, None)            session_id = await self.get_or_create_session(phone)            res = await self._client.post(                f"{self.base_url}/session/{session_id}/message",                json=payload,            )        reply = self._extract_reply(res.json())        # 📝 记录 AI 回复内容        logger.info(f"[AI回复] phone={phone} | session={session_id} | reply={reply!r}")        return {"session_id": session_id, "reply": reply}# ==================== FastAPI 应用 ====================opencode = OpenCodeClient()@asynccontextmanagerasync def lifespan(app: FastAPI):    await opencode.start()    yield    await opencode.stop()app = FastAPI(title="OpenCode Proxy", lifespan=lifespan)@app.post("/chat", response_model=ChatResponse)async def chat(req: ChatRequest):    try:        result = await opencode.chat(phone=req.phone, query=req.query)    except httpx.HTTPStatusError as e:        logger.error(f"[OpenCode错误] phone={req.phone} | status={e.response.status_code} | detail={e.response.text}")        raise HTTPException(status_code=e.response.status_code, detail=f"OpenCode 请求失败: {e.response.text}")    except Exception as e:        logger.exception(f"[未知异常] phone={req.phone} | error={e}")        raise HTTPException(status_code=500, detail=str(e))    return ChatResponse(        phone=req.phone,        session_id=result["session_id"],        reply=result["reply"],    )

02

OpenClaw

OpenClaw有自带的对外APIs接口。遵循OpenAI的两种规范。当Gateway启动后,自动就有。

  • /v1/chat/completions:

https://docs.openclaw.ai/gateway/openai-http-api

  • /v1/responses:

https://docs.openclaw.ai/gateway/openresponses-http-api

至于是从openclaw哪个版本开始支持的,这个就不清楚了。

根据Session behavior这块的文档,接口有3种行为:

  1. 默认情况下,每个请求都是无状态的,每次都会生成一个新的session key。

  2. 如果请求里包含user字段,Gateway会根据这个user派生出一个稳定的session key,因此相同的user会命中同一个agent session。

  3. 如果显式指定x-openclaw-session-key,则直接使用你指定的session 进行路由(explicit session routing)。

OpenClaw官网的右下角,有个Molty机器人,可以和他对话,他会告知你整个文档中的所有内容。

1、默认情况

Gateway为这次请求创建一个新的session。下一次发送同样的请求,也会进入另一个新的session。两次请求之间不会共享聊天历史。

curl http://localhost:8080/v1/responses \  -H "Authorization: Bearer YOUR_TOKEN" \  -H "Content-Type: application/json" \  -d '{    "model""openclaw",    "input""你好"  }'

Authorization:看配置文件里的gateway.auth.mode配置的是啥。

  • “token” or “password”:token是默认且常见的,Bearer <token-or-password>

  • “trusted-proxy”:一般不常用这个。

  • “none”:不需要该字段。

model:”openclaw”,路由到配置好的默认agent。

2、指定user

对于同一个user,每次使用同一个会话。对于不同的user,则会命中另一个对应的session。两个人的上下文互不影响。

curl http://localhost:8080/v1/responses \  -H "Authorization: Bearer YOUR_TOKEN" \  -H "Content-Type: application/json" \  -d '{    "model""openclaw",    "user""alice",    "input""你好"  }'

3、指定x-openclaw-session-key

这种方式完全由调用方决定要进入哪个session。这种方式适合你的系统已经有自己的会话ID,希望OpenClaw直接使用该标识进行路由。

curl http://localhost:8080/v1/responses \  -H "Authorization: Bearer YOUR_TOKEN" \  -H "x-openclaw-session-key: customer-9527" \  -H "Content-Type: application/json" \  -d '{    "model""openclaw",    "input""你好"  }'

session-key怎么来?还记得web版的页面吗,在聊天框的上方,有会话id。

需要注意的是,不得使用保留的内部会话命名空间,如subagent:xxx、cron:xxx、acp:xxx,这些请求被拒绝,400 invalid_request_error。

和第一部分的OpenCode比起来,OpenCLaw的这种APIs接口,算是很简单的了,也不需要中间层作为转发。而且可自由选定,每个用户是独立的session,还是共用同一个。

03

Hermes

Hermes有自带的对外APIs接口,兼容OpenAI的HTTP端点。和OpenClaw一样,都支持/v1/chat/completions和/v1/responses。

https://hermes-agent.nousresearch.com/docs/user-guide/features/api-server

1、启用API服务器

修改~/.hermes/.env文件

API_SERVER_ENABLED=trueAPI_SERVER_KEY=change-me-local-dev# Optional: only if a browser must call Hermes directly# API_SERVER_CORS_ORIGINS=http://localhost:3000

 2、启动网关

hermes gateway

 你将看到:

[API Server] API server listening on http://127.0.0.1:8642

3、连接

指向任何兼容 OpenAI 的客户端http://localhost:8642/v1:

# Test with curlcurl http://localhost:8642/v1/chat/completions \  -H "Authorization: Bearer change-me-local-dev" \  -H "Content-Type: application/json" \  -d '{"model""hermes-agent""messages": [{"role": "user", "content": "Hello!"}]}'
  • /v1/chat/completions
每次连接都会创建一个新的会话,所以需要手动添加上下文。
  • /v1/responses
支持通过previous_response_id实现服务器端对话状态——服务器存储完整的对话历史(包括工具调用和结果),因此多回合上下文得以保留,无需客户端管理。
  • /v1/runs

流媒体友好型替代方案,服务器为长形式会话提供了run API,客户端希望订阅进度事件,而不是自己管理流媒体。

======END=======