fix: DeepSeek reasoning_content 处理和多轮对话兼容性 - #3278
Conversation
修复 Codex CLI + DeepSeek 多轮对话失败问题: - reasoning item 空 text 转换为 "[reasoning unavailable]" placeholder - reasoning.effort 正确映射到 reasoning_effort 字段 - 添加 reasoning_content 透传和转换逻辑 其他改进: - 添加 /responses 根路径路由支持 wire_api="responses" - 注册 DeepSeek V4 Pro/Flash 模型到 registry - 处理嵌套和扁平格式的 tool 定义 - 跳过空名称的 tool 和空 role 的 message
|
This pull request targeted The base branch has been automatically changed to |
There was a problem hiding this comment.
Code Review
This pull request introduces support for DeepSeek V4 models, including reasoning/thinking capabilities, and adds compatibility routes for the Codex CLI. Key changes include updating the model registry, implementing reasoning_content handling for multi-turn conversations in both chat completions and responses translators, and refining tool conversion logic. Feedback focuses on reducing code duplication in route registration and tool processing, as well as simplifying JSON manipulation using sjson features and string formatting.
| s.engine.GET("/responses", AuthMiddleware(s.accessManager), openaiResponsesHandlers.ResponsesWebsocket) | ||
| s.engine.POST("/responses", AuthMiddleware(s.accessManager), openaiResponsesHandlers.Responses) | ||
| s.engine.POST("/responses/compact", AuthMiddleware(s.accessManager), openaiResponsesHandlers.Compact) |
| reasoningPart := []byte(`{}`) | ||
| reasoningPart, _ = sjson.SetBytes(reasoningPart, "type", "reasoning") | ||
| reasoningPart, _ = sjson.SetRawBytes(reasoningPart, "summary", []byte(`[{"type":"summary_text","text":""}]`)) | ||
| reasoningPart, _ = sjson.SetBytes(reasoningPart, "summary.0.text", rc.String()) | ||
| msg, _ = sjson.SetRawBytes(msg, "content.-1", reasoningPart) |
There was a problem hiding this comment.
这里的 JSON 构建过程略显繁琐。为了使代码更简洁易读,可以考虑使用 fmt.Sprintf 配合 %q 格式化动词来直接生成 reasoningPart 的 JSON 字符串。%q 会为字符串正确地添加引号并处理转义,可以有效防止注入问题。
(请注意,这需要导入 fmt 包。)
| reasoningPart := []byte(`{}`) | |
| reasoningPart, _ = sjson.SetBytes(reasoningPart, "type", "reasoning") | |
| reasoningPart, _ = sjson.SetRawBytes(reasoningPart, "summary", []byte(`[{"type":"summary_text","text":""}]`)) | |
| reasoningPart, _ = sjson.SetBytes(reasoningPart, "summary.0.text", rc.String()) | |
| msg, _ = sjson.SetRawBytes(msg, "content.-1", reasoningPart) | |
| reasoningPartJSON := fmt.Sprintf(`{"type":"reasoning","summary":[{"type":"summary_text","text":%q}]}`, rc.String()) | |
| msg, _ = sjson.SetRawBytes(msg, "content.-1", []byte(reasoningPartJSON)) |
| contentPath := fmt.Sprintf("input.%d.content", i) | ||
| contentResult := gjson.GetBytes(result, contentPath) | ||
| if !contentResult.IsArray() { | ||
| // Create content array if it doesn't exist | ||
| result, _ = sjson.SetRawBytes(result, contentPath, []byte(`[]`)) | ||
| } | ||
|
|
||
| // Add reasoning item to content | ||
| // Find the last index of content array | ||
| contentArray := gjson.GetBytes(result, contentPath).Array() | ||
| lastIdx := len(contentArray) | ||
|
|
||
| // Create reasoning summary item | ||
| reasoningItemPath := fmt.Sprintf("input.%d.content.%d", i, lastIdx) | ||
| reasoningItem := []byte(`{"type":"reasoning","summary":[{"type":"summary_text","text":""}]}`) | ||
| reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.text", rc.String()) | ||
|
|
||
| result, _ = sjson.SetRawBytes(result, reasoningItemPath, reasoningItem) |
There was a problem hiding this comment.
这部分用于向 content 数组添加 reasoning 对象的逻辑有些复杂。您手动检查了数组是否存在,如果不存在则创建,然后获取数组长度来计算新元素的索引。
sjson 库支持使用 .-1 路径来向数组末尾追加元素,并且会自动创建不存在的父级对象或数组。利用这个特性可以大大简化代码。
| contentPath := fmt.Sprintf("input.%d.content", i) | |
| contentResult := gjson.GetBytes(result, contentPath) | |
| if !contentResult.IsArray() { | |
| // Create content array if it doesn't exist | |
| result, _ = sjson.SetRawBytes(result, contentPath, []byte(`[]`)) | |
| } | |
| // Add reasoning item to content | |
| // Find the last index of content array | |
| contentArray := gjson.GetBytes(result, contentPath).Array() | |
| lastIdx := len(contentArray) | |
| // Create reasoning summary item | |
| reasoningItemPath := fmt.Sprintf("input.%d.content.%d", i, lastIdx) | |
| reasoningItem := []byte(`{"type":"reasoning","summary":[{"type":"summary_text","text":""}]}`) | |
| reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.text", rc.String()) | |
| result, _ = sjson.SetRawBytes(result, reasoningItemPath, reasoningItem) | |
| // Add reasoning item to content | |
| reasoningItem := []byte(`{"type":"reasoning","summary":[{"type":"summary_text","text":""}]}`) | |
| reasoningItem, _ = sjson.SetBytes(reasoningItem, "summary.0.text", rc.String()) | |
| contentPath := fmt.Sprintf("input.%d.content.-1", i) | |
| result, _ = sjson.SetRawBytes(result, contentPath, reasoningItem) |
| // Try nested format first (Codex CLI sends {"type": "function", "function": {"name": "xxx"}}) | ||
| nestedFunction := tool.Get("function") | ||
| if nestedFunction.Exists() && nestedFunction.IsObject() { | ||
| if name := nestedFunction.Get("name"); name.Exists() { | ||
| function, _ = sjson.SetBytes(function, "name", name.String()) | ||
| } | ||
| if description := nestedFunction.Get("description"); description.Exists() { | ||
| function, _ = sjson.SetBytes(function, "description", description.String()) | ||
| } | ||
| if parameters := nestedFunction.Get("parameters"); parameters.Exists() { | ||
| function, _ = sjson.SetRawBytes(function, "parameters", []byte(parameters.Raw)) | ||
| } | ||
| // Ensure parameters has type: object (required by most providers) | ||
| if !gjson.GetBytes(function, "parameters.type").Exists() { | ||
| function, _ = sjson.SetBytes(function, "parameters.type", "object") | ||
| } | ||
| } else { | ||
| // Fall back to flat format {"type": "function", "name": "xxx"} | ||
| if name := tool.Get("name"); name.Exists() { | ||
| function, _ = sjson.SetBytes(function, "name", name.String()) | ||
| } | ||
| if description := tool.Get("description"); description.Exists() { | ||
| function, _ = sjson.SetBytes(function, "description", description.String()) | ||
| } | ||
| if parameters := tool.Get("parameters"); parameters.Exists() { | ||
| function, _ = sjson.SetRawBytes(function, "parameters", []byte(parameters.Raw)) | ||
| } | ||
| // Ensure parameters has type: object (required by most providers) | ||
| if !gjson.GetBytes(function, "parameters.type").Exists() { | ||
| function, _ = sjson.SetBytes(function, "parameters.type", "object") | ||
| } | ||
| } |
There was a problem hiding this comment.
if/else 块中的代码存在大量重复。两个分支都在处理从 tool 或 nestedFunction 中提取 name, description, parameters 的逻辑,并且都包含了确保 parameters.type 为 object 的检查。
为了消除重复并提高代码的可读性,建议重构此部分。可以先确定属性的来源(nestedFunction 或 tool 本身),然后对该来源执行一次提取和检查逻辑。
var functionSource gjson.Result
nestedFunction := tool.Get("function")
if nestedFunction.Exists() && nestedFunction.IsObject() {
functionSource = nestedFunction
} else {
functionSource = tool
}
if name := functionSource.Get("name"); name.Exists() {
function, _ = sjson.SetBytes(function, "name", name.String())
}
if description := functionSource.Get("description"); description.Exists() {
function, _ = sjson.SetBytes(function, "description", description.String())
}
if parameters := functionSource.Get("parameters"); parameters.Exists() {
function, _ = sjson.SetRawBytes(function, "parameters", []byte(parameters.Raw))
}
// Ensure parameters has type: object (required by most providers)
if !gjson.GetBytes(function, "parameters.type").Exists() {
function, _ = sjson.SetBytes(function, "parameters.type", "object")
}|
你提交了过多与修复无关的代码,并且项目主体代码中不允许出现中文注释。 请清理PR的代码后重新PR。 |
Summary
修复 Codex CLI + DeepSeek 多轮对话失败问题。
核心修复
[reasoning unavailable]placeholder,满足 DeepSeek V4 要求reasoning_effort字段其他改进
/responses根路径路由支持wire_api="responses"Test Plan
Files Changed
internal/translator/openai/openai/responses/openai_openai-responses_request.gointernal/translator/codex/openai/responses/codex_openai-responses_request.gointernal/translator/codex/openai/chat-completions/codex_openai_request.gointernal/registry/models/models.jsoninternal/api/server.go/responses路路由