@clawhub-17329971-2d3db7615a
排查 CLI Proxy API(codex-api-proxy)的配置、认证、模型注册和请求问题。适用场景包括:(1) AI 请求报错 unknown provider for model, (2) 模型列表中缺少预期模型, (3) codex-api-key/auth-dir 配置不生效, (4) CLI P...
---
name: cli-proxy-troubleshooting
description: "排查 CLI Proxy API(codex-api-proxy)的配置、认证、模型注册和请求问题。适用场景包括:(1) AI 请求报错 unknown provider for model, (2) 模型列表中缺少预期模型, (3) codex-api-key/auth-dir 配置不生效, (4) CLI Proxy 启动后 AI 无法调用, (5) 认证成功但请求失败或超时。包含源码级排查方法:模型注册表架构、认证加载链路、 SanitizeCodexKeys 规则、常见错误的真实根因。"
metadata: {"openclaw":{"homepage":"https://github.com/stainless-codex/cli-proxy-api"}}
---
# CLI Proxy (Codex API Proxy) Troubleshooting Guide
排查基于 [CLI Proxy API](https://github.com/stainless-codex/cli-proxy-api) 的 Codex OAuth / OpenAI-compatible 代理问题。
## 使用方式
当遇到以下情况时,先按本文的“快速诊断流程”执行,再按需阅读 `references/source-architecture.md`:
- API 报 `unknown provider for model`
- 配置已写但模型列表不对
- 认证文件或 API key 看起来存在,但请求仍失败
- 代理启动正常,但上层客户端无法完成实际调用
## 架构概述
CLI Proxy 的核心架构:
```
config.yaml / auth-dir → reloadClients → snapshotCoreAuths
→ refreshAuthState → dispatchAuthUpdates → applyCoreAuthAddOrUpdate
→ registerModelsForAuth → 模型注册表(全局单例)
```
**请求处理链路:**
```
HTTP → ChatCompletions handler → getRequestDetails(modelName)
→ GetProviderName(baseModel) → GetModelProviders(modelName)
→ AuthManager.Execute(providers, req) → Codex executor → ChatGPT
```
- 模型注册表是全局单例(`sync.Once`),运行中可热加载
- 认证信息变更会触发模型重新注册
- 配置热重载有 debounce + SHA256 hash 对比
## 模型注册机制
### 认证 → 模型映射
不同认证类型注册不同的模型集:
| 认证类型 | 注册的模型 | 来源 |
|---|---|---|
| Codex Free(auth-dir 的 JSON 文件带 `-free`) | gpt-5.4, gpt-5.4-mini, gpt-5.3-codex, gpt-5.2 | `models.json` 中的 `CodexFreeModels` |
| Codex Pro(auth-dir 的 JSON 文件无 `-free`) | 同上 + gpt-5.3-codex-spark | `GetCodexProModels()` |
| codex-api-key(config.yaml 中配置) | Pro 模型集 | `synthesizeCodexKeys`→`GetCodexProModels()` |
| OpenAI API Key | gpt-4o, gpt-4o-mini | 标准 OpenAI 模型 |
### 模型列表来源
内嵌模型定义在 `internal/registry/models/models.json`,编译时打包进二进制。
## 常见问题与根因
### 1. "unknown provider for model" 报错
**错误消息的细节决定排查方向:**
- `"unknown provider for model gpt-5.4"` → 模型名被正确解析,但 provider(认证)未注册 → 检查认证文件和 API key
- `"unknown provider for model"`(没有模型名) → 请求体被破坏,模型字段缺失 → **检查请求编码**
**💡 核心发现:** 错误消息中的模型名是否出现,直接指向完全不同的根因。
### 2. PowerShell + curl 请求体编码问题
PowerShell 会对 `-d` 参数中的 JSON 做转义处理,导致:
- 引号被转义(`"` → `\"` 或丢失)
- 请求体结构被破坏
- model 字段可能丢失
**修复方法:**
```bash
# 用文件方式(推荐)
echo '{"model":"gpt-5.4","messages":[{"role":"user","content":"hi"}]}' > body.json
curl -X POST <proxy-base-url>/v1/chat/completions -d @body.json
# 或用 Python 发请求
python -c "
import requests
r = requests.post('<proxy-base-url>/v1/chat/completions',
json={'model':'gpt-5.4','messages':[{'role':'user','content':'hi'}]})
print(r.text)
"
```
### 3. codex-api-key 不生效 (SanitizeCodexKeys)
CLI Proxy 启动时会调用 `SanitizeCodexKeys()` 清理配置中的 codex-api-key 条目。
**清理规则:** 移除**没有 `base-url`** 的条目。
```yaml
# ❌ 会被移除
codex_api_keys:
my-key:
key: "sk-xxx"
# ✅ 保留
codex_api_keys:
my-key:
key: "sk-xxx"
base-url: "https://chatgpt.com/backend-api/codex"
```
`base-url` 必须是 `/backend-api/codex` 路径,不是纯域名。
### 4. 认证文件正确加载但模型不出现
**管理 API 返回 `None` 不代表配置没加载。** `auth-dir` 字段是 `json:"-"` 标记的,管理 API 故意不暴露。
**排查方法:** 直接检查:
1. `<auth-dir>/` 目录 — 认证文件是否存在
2. 日志中是否有 `applied core auth` / `registerModelsForAuth` 输出
3. 测试 API 调用是否正常返回
### 5. 请求超时 / 502
CLI Proxy 需要访问 `chatgpt.com` 后端。如果 ChatGPT 被墙:
- 必须在 config.yaml 中配置 `proxy-url: "http://127.0.0.1:PORT"`
- 或通过环境变量设置代理
- 代理关闭时请求会直接超时
### 6. 图片生成报错
图片生成通过 Responses API 转发,使用 `tool_choice: {type: "image_generation"}` 调用。
**常见失败场景:**
- Codex Free 账号不支持 → 报 `Tool choice 'image_generation' not found`
- 需要 Codex Pro 账号
## 快速诊断流程
当用户报告模型调用异常时:
1. **确认错误消息** — 看是否包含模型名
2. **检查请求体** — 用 Python 或 `@body.json` 重发验证
3. **检查认证** — 确认 codex-api-key 有 base-url,auth-dir 文件正确
4. **检查网络** — 确认代理配置正确、目标可达
5. **查看日志** — 搜索 `registerModelsForAuth`、`applied core auth`、`provider_not_found`
## 参考
- 先看本文件:适合快速定位常见根因
- 需要源码级确认时,再看 `references/source-architecture.md`
该 reference 文件包含关键源码文件、函数链路和模型注册逻辑的完整说明。
FILE:references/source-architecture.md
# CLI Proxy 源码架构详解
## 关键源码文件
| 文件 | 作用 |
|---|---|
| `internal/config/config.go` | `SanitizeCodexKeys()` 清理没有 base-url 的 codex-api-key |
| `internal/watcher/clients.go` | `reloadClients` 加载认证文件和 API key |
| `internal/watcher/dispatcher.go` | `refreshAuthState` / `dispatchAuthUpdates` / `dispatchLoop` auth 分发 |
| `internal/watcher/synthesizer/file.go` | `synthesizeFileAuths` 从认证文件生成 auth(JWT id_token→plan_type) |
| `internal/watcher/synthesizer/config.go` | `synthesizeCodexKeys` 从配置生成 codex-api-key auth |
| `internal/access/reconcile.go` | API 认证 provider 的 reconcile |
| `internal/registry/model_registry.go` | `GetModelProviders` / `RegisterClient` / `addModelRegistration` 模型注册表 |
| `internal/registry/models/models.json` | 内嵌模型定义 |
| `sdk/cliproxy/service.go` | `registerModelsForAuth` / `registerResolvedModelsForAuth` 模型注册入口 |
| `sdk/cliproxy/auth/conductor.go` | `Manager.Execute` 请求执行(provider_not_found 来源) |
| `sdk/api/handlers/handlers.go` | `getRequestDetails` / `ExecuteWithAuthManager` |
| `sdk/api/handlers/openai/openai_handlers.go` | `ChatCompletions` / `handleNonStreamingResponse` |
| `sdk/api/handlers/openai/openai_images_handlers.go` | 图片生成 Responses API 转发 |
| `internal/util/provider.go` | `GetProviderName` / `ResolveAutoModel` |
| `internal/thinking/suffix.go` | `ParseSuffix` thinking 后缀解析 |
| `internal/watcher/config_reload.go` | 配置热重载(debounced,SHA256 hash 比对) |
## 认证加载链路(完整)
```
config.yaml + auth-dir
│
▼
reloadClients() ← 启动时 + 配置热重载触发
│
▼
snapshotCoreAuths()
├── synthesizeApiKeyAuths() — openai_api_keys from config
├── synthesizeCodexKeys() — codex_api_keys from config
└── synthesizeFileAuths() — JSON files from auth-dir
│
▼
refreshAuthState()
│
▼
prepareAuthUpdatesLocked()
├── diff old vs new auths
└── generate add/update/remove ops
│
▼
dispatchAuthUpdates()
│
▼
consumeAuthUpdates() ← dispatchLoop goroutine
│
▼
handleAuthUpdate()
├── applyCoreAuthAddOrUpdate()
│ └── registerModelsForAuth()
└── applyCoreAuthRemove()
```
## 模型注册逻辑
`registerModelsForAuth(auth)` 根据认证类型决定模型集:
```go
func registerModelsForAuth(auth *core.CoreAuth) {
switch {
case auth.CodexAuth != nil:
if auth.PlanType == "free" {
models = GetCodexFreeModels()
} else {
models = GetCodexProModels()
}
case auth.OpenAIAuth != nil:
models = defaultModels // gpt-4o, gpt-4o-mini
}
registerResolvedModelsForAuth(auth, models)
}
```
**关键点:** `synthesizeCodexKeys`(从 config.yaml 的 codex_api_keys 生成)不设 `plan_type`,因此走 default 分支 → `GetCodexProModels()`(比 Free 多 spark 模型)。
`synthesizeFileAuths`(从 auth-dir 的 JSON 文件生成)会从 JWT `id_token` 的 `plan_type` 字段提取:
- `"plan_type": "codex"` → Codex Pro
- 其他或无 → Codex Free
## 常见误解澄清
### "auth providers unchanged" 不是模型注册问题
日志中的 `auth providers unchanged` 来自 `dispatchAuthUpdates` 中的 reconcile 过程——它将新的认证列表与当前状态对比,无变化时不触发更新。
**这不代表模型注册失败。** 模型注册只在 `applyCoreAuthAddOrUpdate` 中触发,它是 auth 更新链路的一部分,不是独立的 reconcile。
### 管理 API 不显示 auth-dir 不意味着配置无效
```go
// internal/watcher/dispatcher.go
type AuthState struct {
Auths []*core.CoreAuth `json:"-"`
// ...
}
```
`json:"-"` 标签意味着管理 API 的 JSON 序列化会排除这些字段。管理 API 返回的信息是安全裁剪过的。
解决飞书 IM 语音气泡问题——通过 ffmpeg 将 TTS 输出的 mp3 转为飞书支持的 ogg-opus 格式。适用场景:(1) 在飞书机器人的 TTS 回复中需要显示语音气泡而非文件附件, (2) Edge TTS 或其他只支持 mp3/webm 输出的 TTS 引擎需要适配飞书, (3) 自定义 TT...
---
name: feishu-voice-note-ffmpeg
description: "解决飞书 IM 语音气泡问题——通过 ffmpeg 将 TTS 输出的 mp3 转为飞书支持的 ogg-opus 格式。适用场景:(1) 在飞书机器人的 TTS 回复中需要显示语音气泡而非文件附件, (2) Edge TTS 或其他只支持 mp3/webm 输出的 TTS 引擎需要适配飞书, (3) 自定义 TTS provider 的飞书集成。包含核心原理、ffmpeg 命令、OpenClaw pipeline 集成方案。"
metadata: {"openclaw":{"homepage":"https://open.feishu.cn/document/ukTMukTMukTM/uAjLw4CM/ukTMukTMukTM/reference/im-v1/message/create_json"}}
---
# 飞书语音气泡 ffmpeg 方案
在飞书机器人中,语音消息只有以 `ogg-opus` 格式发送才会显示为可播放的语音气泡。
纯文本附件或其他格式会显示为文件附件,无法内联播放。
## 使用方式
适合在以下场景直接套用:
- TTS 已经能正常生成音频,但飞书里只显示为附件
- 希望把现有 mp3 / webm-opus 输出适配成飞书语音气泡
- 正在做 OpenClaw / 自定义机器人 / 自定义消息管线的飞书语音集成
## 核心原理
```
TTS 引擎(Edge TTS)
→ 输出 mp3(Edge TTS 原生仅支持 mp3 和 webm-opus)
→ ffmpeg 转码为 ogg-opus
→ 飞书 API 接收 ogg → 显示语音气泡 ✅
```
**为什么需要转码:**
- Edge TTS 仅支持 `audio-24khz-48kbitrate-mono-mp3`(mp3)和 `webm-opus` 格式
- 飞书官方只将 `ogg-opus` 识别为语音消息(`msg_type: audio`)
- webm 容器的 opus 文件飞书不识别,可能被当作视频或未知格式
- mp3 文件在飞书中只能作为文件附件发送
## 飞书官方推荐命令
```bash
ffmpeg -i input.mp3 -acodec libopus -ac 1 -ar 16000 output.opus
```
参数说明:
- `-acodec libopus` — 使用 Opus 编码器
- `-ac 1` — 单声道(语音消息标准)
- `-ar 16000` — 16kHz 采样率(语音质量与文件大小的平衡点)
## 在 OpenClaw 中的集成方案
### 方案概述
在 TTS provider 的 `synthesize` 函数中,检测当前通道是否要求语音气泡(通过 `target` 参数判断),如果是则:
1. 调用目标 TTS 引擎生成 mp3
2. 自动调用 ffmpeg 转成 ogg-opus
3. 返回 `.opus` 文件路径给消息发送管线
4. 飞书通道检测到 `fileType: "opus"` 后以 `msg_type: "audio"` 发送 → 语音气泡
### 关键集成点
```
TTS provider synthesize()
→ 生成 mp3 临时文件
→ 若 target === "voice-note"(飞书通道自动触发):
→ ffmpeg -i temp.mp3 ... temp.opus
→ 返回 temp.opus 路径
→ 否则直接返回 mp3 路径
```
### 出错处理
- 如果 ffmpeg 转码失败(未安装、参数错误等),降级为返回原始 mp3 文件
- 降级后 mp3 会作为文件附件发送,不会导致崩溃
## 格式验证
转码后的 opus 文件可通过以下方式验证:
```bash
# 查看文件格式
ffprobe output.opus
# 确认编码器
ffprobe -show_streams output.opus | findstr codec
# 确认飞书兼容性
# 文件扩展名必须为 .opus
# MIME 类型应为 audio/opus 或 audio/ogg
```
## 实施建议
- 优先在 **TTS provider 输出后、消息发送前** 做转码
- 不建议把转码逻辑塞进上层业务逻辑;媒体格式适配应尽量留在音频管线内部
- 先保证失败可降级,再追求“始终发语音气泡”
## 已知限制
- ffmpeg 必须安装在系统 PATH 中
- 转码增加约 50-200ms 延迟(取决于音频时长)
- 临时文件需要及时清理,避免磁盘占用
- 更新 TTS provider 或消息通道组件后,集成代码可能需要重新应用
- 飞书 API 要求 opus 文件大小不超过一定限制(通常语音消息几秒内的文件无问题)
排查 DeepSeek V4-Pro 在 tool-call 模式下因 reasoning_content 字段缺失导致的 API 400 错误。适用场景:(1) DeepSeek V4-Pro 使用 thinking/reasoning 模式时遇到 400 error, (2) 报错内容为 'The reaso...
---
name: deepseek-v4-reasoning-bug
description: "排查 DeepSeek V4-Pro 在 tool-call 模式下因 reasoning_content 字段缺失导致的 API 400 错误。适用场景:(1) DeepSeek V4-Pro 使用 thinking/reasoning 模式时遇到 400 error, (2) 报错内容为 'The reasoning_content in the thinking mode must be passed back to the API', (3) 与 OpenClaw/OpenAI-compatible 客户端集成时 multi-turn + tool call 场景下报错。包含触发条件、复现方法、临时 workaround、官方修复跟踪。"
metadata: {"openclaw":{"homepage":"https://github.com/openclaw/openclaw/pulls?q=reasoning_content+deepseek"}}
---
# DeepSeek V4-Pro reasoning_content Bug
## 使用方式
当报错文本中出现 `The reasoning_content in the thinking mode must be passed back to the API.`,优先把本技能当作 **协议兼容性 / 消息回放问题** 来排查,而不是先怀疑网络、余额或普通鉴权。
## 问题描述
V4-Pro 启用 thinking(推理)模式时,在**多轮对话 + tool call** 场景下,DeepSeek API 要求客户端的后续请求必须回传前一轮响应中的 `reasoning_content` 字段,否则返回 HTTP 400。
```
The reasoning_content in the thinking mode must be passed back to the API.
```
## 触发条件
| 场景 | 结果 |
|---|---|
| 单轮对话(无工具) | ✅ 正常 |
| 单轮对话(有工具定义) | ✅ 正常 |
| 多轮对话(无工具调用) | ✅ 正常 |
| **多轮对话 + 工具调用 (tool call)** | ❌ **400 报错** |
| 多轮对话 + 作为工具(tool)角色 | ❌ **400 报错** |
**核心触发条件:** Tool call 产生的 tool 角色消息之后,下一轮对话必须包含上一轮的 `reasoning_content`,否则报错。
## DeepSeek 的约束要求
- `reasoning_content` 字段必须存在(值可以是空字符串 `""`)
- `reasoning_content` 完全缺失 → 400
- `reasoning_content` 合并到 `content` 字段中 → 400(DeepSeek 只看字段名,不看内容位置)
- `reasoning_content: ""`(空字符串)→ ✅ 200 成功
**因此最小修复方案是:** 在回传给 DeepSeek 的 assistant 消息中,无条件添加 `reasoning_content: ""` 字段作为 fallback。
## 影响范围
- 任何使用 DeepSeek V4-Pro + thinking 模式的客户端都可能遇到
- OpenClaw 中:`convertMessages` 函数会过滤掉内容为空的 thinking block,导致 `reasoning_content` 字段完全缺失
- 不影响 V4-Flash(`reasoning: false`,不产生 `reasoning_content`)
- 不影响单轮对话场景
## 排查方法
### 1. 确认是否是此 bug
检查 API 响应 body 中是否包含:
```json
{
"error": {
"message": "The reasoning_content in the thinking mode must be passed back to the API."
}
}
```
### 2. 确认触发场景
查看请求历史,确认是否有 assistant + tool_calls 消息后跟了 tool 角色消息。
### 3. 临时 workaround
如果必须在当前版本使用 V4-Pro + thinking,可以:
- 每次请求中手动向 assistant 消息添加 `reasoning_content: ""`
- 或在消息处理管线中拦截 assistant 消息,无条件注入空 `reasoning_content`
### 4. 检查客户端版本
检查所使用的 DeepSeek SDK / 客户端版本是否已有修复。
## 临时决策建议
- 如果当前客户端未修复:不要把 V4-Pro thinking + tool-call 当作稳定主力链路
- 如果必须继续使用:优先采用“无条件补 `reasoning_content: ""`”的最小 fallback
- 如果是纯单轮或无工具场景:可继续验证,但不要把该结果外推到多轮 tool-call 场景
## 官方修复状态
| PR | 作者 | 描述 | 状态 |
|---|---|---|---|
| [#71105](https://github.com/openclaw/openclaw/pull/71105) | lsdsjy | DeepSeek 官方 provider 插件 + reasoning_content 回传修复 | Review 中 |
| [#71146](https://github.com/openclaw/openclaw/pull/71146) | snowzlm | replay DeepSeek reasoning_content on tool-turn history | Review 中 |
两个 PR 于 2026-04-24 提交。修复方案都涉及在 tool-turn 历史消息中回传 `reasoning_content` 字段。
## 复现方法
```python
import requests
# 1. 首次请求(带 thinking + tool calls)
response = requests.post("https://api.deepseek.com/v1/chat/completions", json={
"model": "deepseek-v4-pro",
"reasoning": {"effort": "low"},
"messages": [
{"role": "user", "content": "查询一下天气"}
],
"tools": [{"type": "function", "function": {"name": "get_weather", ...}}]
})
# 2. 模拟 tool call 结果
data = response.json()
assistant_msg = data["choices"][0]["message"]
# 3. 下次请求不传 reasoning_content → 会 400
bad_request = requests.post("https://api.deepseek.com/v1/chat/completions", json={
"model": "deepseek-v4-pro",
"reasoning": {"effort": "low"},
"messages": [
{"role": "user", "content": "查询一下天气"},
{
"role": "assistant",
"content": assistant_msg["content"],
"tool_calls": assistant_msg["tool_calls"],
# ❌ reasoning_content 缺失
},
{"role": "tool", "content": "晴,25°C", "tool_call_id": ...}
]
})
# → HTTP 400 ❌
```