部署完 Agent 想用 OpenAI 协议直接调底层模型?这篇讲透 /app/llm 供应商目录、api-info 自动签发 oc_ key、/v1 纯代理面与三种 key 鉴权形态的坑位。
部署完 AI Agent、也把实例跑起来之后,总会撞上一种场景:你不想走怡途Claw 自己的 /api/app/chat 那套业务封装,而是想拿现成的 OpenAI SDK、或者已有的那段调 chat/completions 的老代码,直接怼到平台接的底层大模型上——做压测、做 A/B、接进旧系统。问题就来了:平台到底接了哪些模型供应商?默认用的是谁?我上哪去拿可用的 base_url 和 api_key?这一篇就把 /api/app/llm 这组「模型供应商目录 + 对外接入信息」接口,连同它背后那个 OpenAI 兼容的 /v1 代理面,一次讲透。
一、先看目录:GET /api/app/llm/providers
这是最基础的「平台接了谁」查询,不需要传任何参数,JWT 鉴权后直接拉全量活跃供应商:
GET /api/app/llm/providers
Authorization: Bearer <你的 JWT>
返回的每个供应商对象,字段是这样的(走 sanitizeProvider 脱敏后的结构):
id/providerId/name:内部主键、业务标识(如deepseek、volcengine)、展示名。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 这个价格表怎么来的,值得展开,因为它不是简单读一个字段:
- 先遍历所有活跃供应商,解析每个供应商的
supportedModels,对每个模型读modelPricing[modelId],只要enabled !== false就收进 map,记下inputPrice/outputPrice。 - 再遍历你名下所有运行中的实例,把实例
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=true,usageCount=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 → 401Invalid API key and no running instances。
错误返回是 OpenAI 风格的 { error: { message, type } },type 为 api_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_url 和 oc_ 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,重点读 capability 和 providerType |
| 查默认对话/媒体供应商 | GET /app/llm/providers/default | chat / media 两条默认轨道分开 |
| 拿对外 base_url + key + 价格表 | GET /app/llm/api-info | 首次自动签 oc_ key,apiKeyFull 只此一次 |
| OpenAI 格式对话 | POST /v1/chat/completions | 纯代理,stream=true 走 SSE |
| Anthropic 格式 | POST /v1/messages | 走 anthropicBaseUrl |
| Responses API | POST /v1/responses | 走 baseUrl,火山地址原生支持 |
| 列可用模型 | GET /v1/models | 只返回运行中实例的模型,实例停了就空 |
三个最容易翻车的点,再强调一遍:第一,apiKeyFull 只在 api-info 首次签发时返回一次,没存住就得重签;第二,oc_ key 调 /v1 时落到「第一个 running 实例」,多实例场景落点不可控,要精确就改用 sk_<instanceId>_ 格式的容器 key;第三,raw provider key 的兜底会借全局最早 running 实例的身份,生产环境务必确认 /v1 的暴露面和网关策略。把这三点盯住,这套 OpenAI 兼容接入就能稳稳用起来。