返回博客

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

2026年8月4日阅读约 37 分钟
想用 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 兼容接入就能稳稳用起来。