Blog posts and course content are currently in Chinese. Translation is in progress.
Back to Blog

想用 OpenAI 协议直接调模型?怡途Claw 模型供应商目录与对外 API 接入实战

2026年8月4日37 min read
想用 OpenAI 协议直接调模型?怡途Claw 模型供应商目录与对外 API 接入实战

部署完 Agent 想用 OpenAI 协议直接调底层模型?这篇讲透 /app/llm 供应商目录、api-info 自动签发 oc_ key、/v1 纯代理面与三种 key 鉴权形态的坑位。

部署完 AI Agent、也把实例跑起来之后,总会撞上一种场景:你不想走怡途Claw 自己的 /api/app/chat 那套业务封装,而是想拿现成的 OpenAI SDK、或者已有的那段调 chat/completions 的老代码,直接怼到平台接的底层大模型上——做压测、做 A/B、接进旧系统。问题就来了:平台到底接了哪些模型供应商?默认用的是谁?我上哪去拿可用的 base_urlapi_key?这一篇就把 /api/app/llm 这组「模型供应商目录 + 对外接入信息」接口,连同它背后那个 OpenAI 兼容的 /v1 代理面,一次讲透。

一、先看目录:GET /api/app/llm/providers

这是最基础的「平台接了谁」查询,不需要传任何参数,JWT 鉴权后直接拉全量活跃供应商:

GET /api/app/llm/providers
Authorization: Bearer <你的 JWT>

返回的每个供应商对象,字段是这样的(走 sanitizeProvider 脱敏后的结构):

  • id / providerId / name:内部主键、业务标识(如 deepseekvolcengine)、展示名。
  • providerType:供应商类型,区分 OpenAI 兼容、Anthropic 兼容等不同协议形态——这点很关键,它直接决定你的请求该打到 /v1/chat/completions 还是 /v1/messages
  • baseUrl / anthropicBaseUrl:供应商上游地址,对外代理时会用到。
  • isDefault / isActive / priority:是否默认、是否启用、优先级数字。findAll(false) 只返回 isActive=true 的,priority 在多供应商分流时决定先后。
  • supportedModels:该供应商支持的模型 ID 数组,来自 supportedModelsArray
  • modelPricing:按模型 ID 索引的价格表,结构是 { [modelId]: { inputPrice, outputPrice, enabled } }enabled !== false 才算对外可用。
  • capability:能力快照,是这组接口里最该读懂的字段,下一段单独展开。
  • description / createdAt / updatedAt:描述与时间戳。

二、capability 能力快照:别只看模型名,看它能干什么

sanitizeProvider 里挂的 capability 不是供应商自己声明的,而是 ModelProviderService.getCapabilitySnapshot() 算出来的,一共 9 个字段:

  • supportsChat / supportsImageGeneration / supportsVideoGeneration:能不能对话、生图、生视频。一个供应商可能三样都支持,也可能只支持对话。
  • defaultForChat / defaultForMedia:它是不是当前平台的默认对话供应商、默认媒体供应商。这两条是分开的——和你账号下「对话走一个供应商、生图生视频走另一个」的计费分轨是对应的(媒体那条轨的计费细节见 310 媒体生成用量那篇)。
  • defaultModel / imageGenerationModel / videoGenerationModel:各场景下的默认模型 ID。
  • timeoutMs:上游超时阈值,调长任务时心里有数。

读目录的时候,别只盯着 supportedModels 有几个模型名,重点看 capability——它告诉你这个供应商在你这套部署里到底被当成什么角色用。

三、默认是谁:GET /api/app/llm/providers/default

如果你不关心全量目录,只想知道「现在默认对话和默认媒体各是谁」,调这个:

GET /api/app/llm/providers/default

返回 { chat, media } 两个供应商对象,结构跟上面 providers 里的一样。实现上就是 getDefaultChatProvider()getDefaultMediaProvider() 各自从活跃供应商里挑 isDefault=true 的那个。新接入一个供应商、或者运营侧切了默认模型之后,这里会立刻反映出来,适合做客户端的「当前推荐模型」展示。

四、对外接入信息:GET /api/app/llm/api-info

这是整组接口里最有料的一个——它把「OpenAI 兼容协议怎么接」一次性打包给你:

GET /api/app/llm/api-info

返回结构:

  • baseUrl:对外基地址, ${API_BASE_URL || 'https://claw.yitukeji.cn'}/v1` 。生产默认就是 https://claw.yitukeji.cn/v1`。
  • chatEndpoint ${baseUrl}/chat/completions,OpenAI 格式对话端点。
  • modelsEndpoint ${baseUrl}/models,列模型端点。
  • apiKey:脱敏后的 key,只显示前 12 个字符加 ...,给你确认是哪把 key 用的。
  • apiKeyFull完整 key 明文——注意,这是少数会回完整 key 的地方,因为 key 只在创建时返回一次完整值,后续列表里都脱敏。
  • supportedModels:可用模型 + 价格表,结构是 [{ id, inputPrice, outputPrice }]

supportedModels 这个价格表怎么来的,值得展开,因为它不是简单读一个字段:

  1. 先遍历所有活跃供应商,解析每个供应商的 supportedModels,对每个模型读 modelPricing[modelId],只要 enabled !== false 就收进 map,记下 inputPrice / outputPrice
  2. 再遍历你名下所有运行中的实例,把实例 customLLMConfig.chatModelId(或 modelId)补进去——如果这个模型在第一步没出现过,就按 inputPrice:0, outputPrice:0 兜底占位。

也就是说,这张表 = 「平台所有活跃供应商的模型」∪「你自己跑着的实例挂的模型」。压测前先看这张表,能避免调一个根本没接的模型 ID。

自动签发 oc_ key

api-info 还干了一件「贴心但有坑」的事:如果你名下还没有 active 的 API key,它会自动createApiKey({ name: '默认 API Key' }) 给你签一把。新 key 的特征:

  • 前缀固定 oc_,后接随机串,形如 oc_xxxxxxxx...
  • permissions 默认 ['read']isActive=trueusageCount=0
  • 完整 key 只在创建这一刻返回,之后 getUserApiKeys 列表里就只能看到脱敏的。

所以第一次调 api-info 时务必把 apiKeyFull 存好——丢了就得重新签发,旧 key 不会再现。

五、/v1 代理面:OpenAI 兼容协议到底能调什么

api-info 给的 baseUrl 指向 /v1,这层由 V1OpenAiController 实现,注释写得很直白:纯代理,不做格式转换。一共四个端点:

  • GET /v1:根探活,返回 { object: 'root', version: '1.0' },啥都不用鉴权,用来确认服务在不在。
  • GET /v1/models:列模型。注意它不是返回平台全量模型,而是只返回你名下运行中实例能用的模型——遍历 running 实例的 customLLMConfig,按 chatProviderId/providerId/provider 反查供应商的 supportedModels,再并上 chatModelId/modelId。每条返回 { id, object: 'model', owned_by: 'openclaw' }。这意味着:实例没跑起来,/v1/models 就是空的。
  • POST /v1/chat/completions:OpenAI 格式对话,body.stream === true 走 SSE 流式(text/event-stream),否则走 JSON。请求体原样透传给上游,平台不做字段翻译。
  • POST /v1/messages:Anthropic 格式,走供应商的 anthropicBaseUrl
  • POST /v1/responses:OpenAI Responses API,走 baseUrl(火山引擎的 OpenAI 兼容地址原生支持这个端点)。

三种协议形态对应三种 providerType,所以你拿 providers 目录里的 providerType 判断该用哪个端点,是靠谱的。

六、key 鉴权三形态:这是最容易踩坑的地方

/v1 这层鉴权吃 x-api-key 头或 Authorization: Bearer <key>,取值时 x-api-key 优先、否则剥 Bearer 前缀。但拿到 token 之后怎么解析,分三种情况,行为差别很大:

形态一:sk_<instanceId>_<suffix>——容器级精确 key。格式是 sk_ 开头、中间一段是 UUID(带 dash 的 instanceId)、末尾随机后缀。extractInstanceIdFromKey 会用正则校验中间那段是不是合法 UUID,是的话直接拿这个 instanceId 精确定位容器,绕过 oc_ 校验。这种 key 由 InstancesService.buildProxyApiKey 生成,专门给容器里的 Claude Code / Codex CLI 用,保证请求落到指定实例。如果这个 instanceId 在库里查不到,直接 401 Instance not found

形态二:oc_ key——用户级 key。走 validateApiKey,先校验前缀必须是 oc_,再查库确认 isActive,拿到 userId。然后 findAll(userId) 拉这个用户的所有实例,筛出 running 的,取第一个作为代理目标。注意是「第一个」,不是「你想要的那个」——如果你名下同时跑着多个实例,用 oc_ key 调 /v1,落点不保证可控。

形态三:raw provider key——兜底逻辑,也是最容易出安全问题的点。如果 token 不是 oc_ 开头、validateApiKey 失败,代码不会直接拒绝,而是去全局找一个 status='running' 的实例(按 createdAt 升序取最早创建的那个),把它的 userId 当成当前用户。日志会打一句 V1 auth: raw key xxx... -> 关联用户 <userId>。这相当于:任意一个非 oc_ 格式的字符串 key,都能借到全局最早那个运行中实例的用户身份。在内网调试时这是便利,对外暴露时这是个要盯紧的面——生产上 /v1 是否对公网开放、是否套了网关,得单独评估。

不管走哪种形态,最后都要落到一个 running 实例上。如果没有运行中实例:

  • sk_ key 路径:instanceId 查不到 → 401。
  • oc_ / raw key 路径:findAll 后没有 running → 503 没有运行中的实例resolveUser 全局兜底也没找到 running → 401 Invalid API key and no running instances

错误返回是 OpenAI 风格的 { error: { message, type } }typeapi_error(HttpException)或 server_error(其他异常),方便用 OpenAI SDK 的错误处理逻辑直接接。

七、和 303 实例切模型是什么关系

这里要和 303 那篇划清边界,免得混:

  • 303(PUT /api/app/instances/:id/llm-config):改的是单个实例挂哪个供应商、哪个模型,是实例级的运行配置。
  • 本文(/api/app/llm/*):读的是平台级的供应商目录、默认值、对外接入信息,不改任何实例配置。

典型配合流程是:先 GET /app/llm/providers 看平台接了什么 → 用 303 给某个实例切到想要的模型 → 再 GET /app/llm/api-info 拿对外 base_urloc_ key → 用 OpenAI SDK 打 /v1/chat/completions。两套接口一个管「实例内部怎么跑」,一个管「外部怎么接进来」,别当成一回事。

八、一条能跑通的 curl

把上面串起来,最小可用链路:

# 1. 拿对外接入信息(首次会自动签发 oc_ key)
curl https://www.yitukeji.cn/api/app/llm/api-info \
  -H "Authorization: Bearer <JWT>"
# → 记下 data.apiKeyFull 和 data.baseUrl

# 2. 用拿到的 oc_ key 调 OpenAI 兼容端点(流式)
curl https://claw.yitukeji.cn/v1/chat/completions \
  -H "x-api-key: oc_xxxxxxxx..." \
  -H "Content-Type: application/json" \
  -d '{
    "model": "<supportedModels 里的某个 id>",
    "messages": [{"role":"user","content":"用一句话介绍你自己"}],
    "stream": true
  }'

前提是你名下至少有一个 status=running 的实例——否则第 2 步直接 503。这也是为什么 /v1/models 会随实例启停变化:实例停了,它挂的模型就从列表里掉出去。

九、小结与坑位表

场景接口关键点
看平台接了哪些供应商GET /app/llm/providers只返回 isActive,重点读 capabilityproviderType
查默认对话/媒体供应商GET /app/llm/providers/defaultchat / media 两条默认轨道分开
拿对外 base_url + key + 价格表GET /app/llm/api-info首次自动签 oc_ key,apiKeyFull 只此一次
OpenAI 格式对话POST /v1/chat/completions纯代理,stream=true 走 SSE
Anthropic 格式POST /v1/messagesanthropicBaseUrl
Responses APIPOST /v1/responsesbaseUrl,火山地址原生支持
列可用模型GET /v1/models只返回运行中实例的模型,实例停了就空

三个最容易翻车的点,再强调一遍:第一,apiKeyFull 只在 api-info 首次签发时返回一次,没存住就得重签;第二,oc_ key 调 /v1 时落到「第一个 running 实例」,多实例场景落点不可控,要精确就改用 sk_<instanceId>_ 格式的容器 key;第三,raw provider key 的兜底会借全局最早 running 实例的身份,生产环境务必确认 /v1 的暴露面和网关策略。把这三点盯住,这套 OpenAI 兼容接入就能稳稳用起来。