Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions internal/api/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -372,6 +372,15 @@ func (s *Server) setupRoutes() {
codexDirect.POST("/responses/compact", openaiResponsesHandlers.Compact)
}

// Codex CLI wire_api="responses" 根路径路由
// 当 Codex config.toml 配置 wire_api = "responses" 时,请求直接发到 /responses
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)
Comment on lines +377 to +379

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

您在这里添加的路由与 L361-L363 和 L370-L372 处的路由注册逻辑几乎完全相同。这造成了代码重复,未来如果需要修改这些路由(例如,添加新的中间件),将需要在三个地方同步修改,容易出错。

为了提高代码的可维护性,建议将这部分重复的路由注册逻辑提取到一个公共的辅助函数中。


// Codex CLI 兼容路由:/models(不带 /v1 前缀)
s.engine.GET("/models", AuthMiddleware(s.accessManager), s.unifiedModelsHandler(openaiHandlers, claudeCodeHandlers))

// Gemini compatible API routes
v1beta := s.engine.Group("/v1beta")
v1beta.Use(AuthMiddleware(s.accessManager))
Expand Down
35 changes: 35 additions & 0 deletions internal/registry/models/models.json
Original file line number Diff line number Diff line change
Expand Up @@ -1873,6 +1873,41 @@
}
}
],
"deepseek": [
{
"id": "deepseek-v4-pro",
"object": "model",
"created": 1778150400,
"owned_by": "deepseek",
"type": "deepseek",
"display_name": "DeepSeek V4 Pro",
"description": "DeepSeek V4 Pro - Advanced reasoning model with extended thinking capabilities",
"context_length": 128000,
"max_completion_tokens": 8192,
"thinking": {
"min": 1024,
"max": 32000,
"zero_allowed": true,
"dynamic_allowed": true,
"levels": [
"low",
"medium",
"high"
]
}
},
{
"id": "deepseek-v4-flash",
"object": "model",
"created": 1778150400,
"owned_by": "deepseek",
"type": "deepseek",
"display_name": "DeepSeek V4 Flash",
"description": "DeepSeek V4 Flash - Fast and efficient model for general tasks",
"context_length": 128000,
"max_completion_tokens": 8192
}
],
"antigravity": [
{
"id": "claude-opus-4-6-thinking",
Expand Down
1 change: 1 addition & 0 deletions internal/thinking/apply.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func ApplyThinking(body []byte, model string, fromFormat string, toFormat string
if providerKey == "" {
providerKey = providerFormat
}

fromFormat = strings.ToLower(strings.TrimSpace(fromFormat))
if fromFormat == "" {
fromFormat = providerFormat
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import (
"strconv"
"strings"

log "github.com/sirupsen/logrus"
"github.com/tidwall/gjson"
"github.com/tidwall/sjson"
)
Expand All @@ -28,6 +29,16 @@ import (
// - []byte: The transformed request data in OpenAI Responses API format
func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := inputRawJSON

// DEBUG: 打印原始请求
log.Debugf("codex chat-completions: raw input JSON (first 2000 chars): %s", func() string {
s := string(rawJSON)
if len(s) > 2000 {
return s[:2000] + "..."
}
return s
}())

// Start with empty JSON object
out := []byte(`{"instructions":""}`)

Expand Down Expand Up @@ -197,6 +208,19 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
}
}

// Handle reasoning_content for assistant messages (DeepSeek requires passing back in multi-turn)
// Convert reasoning_content string to reasoning type with summary
if role == "assistant" {
rc := m.Get("reasoning_content")
if rc.Exists() && rc.Type == gjson.String && rc.String() != "" {
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)
Comment on lines +216 to +220

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这里的 JSON 构建过程略显繁琐。为了使代码更简洁易读,可以考虑使用 fmt.Sprintf 配合 %q 格式化动词来直接生成 reasoningPart 的 JSON 字符串。%q 会为字符串正确地添加引号并处理转义,可以有效防止注入问题。

(请注意,这需要导入 fmt 包。)

Suggested change
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))

}
}

// Don't emit empty assistant messages when only tool_calls
// are present — Responses API needs function_call items
// directly, otherwise call_id matching fails (#2132).
Expand Down Expand Up @@ -356,6 +380,16 @@ func ConvertOpenAIRequestToCodex(modelName string, inputRawJSON []byte, stream b
}

out, _ = sjson.SetBytes(out, "store", false)

// DEBUG: 打印翻译后的请求
log.Debugf("codex chat-completions: translated output JSON (first 2000 chars): %s", func() string {
s := string(out)
if len(s) > 2000 {
return s[:2000] + "..."
}
return s
}())

return out
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,15 @@ import (
func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, _ bool) []byte {
rawJSON := inputRawJSON

// DEBUG: 打印原始请求
log.Debugf("codex responses: raw input JSON (first 2000 chars): %s", func() string {
s := string(rawJSON)
if len(s) > 2000 {
return s[:2000] + "..."
}
return s
}())

inputResult := gjson.GetBytes(rawJSON, "input")
if inputResult.Type == gjson.String {
input, _ := sjson.SetBytes([]byte(`[{"type":"message","role":"user","content":[{"type":"input_text","text":""}]}]`), "0.content.0.text", inputResult.String())
Expand Down Expand Up @@ -40,8 +49,19 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte,

// Convert role "system" to "developer" in input array to comply with Codex API requirements.
rawJSON = convertSystemRoleToDeveloper(rawJSON)
// Convert reasoning_content to reasoning type for DeepSeek multi-turn compatibility.
rawJSON = convertReasoningContentToReasoning(rawJSON)
rawJSON = normalizeCodexBuiltinTools(rawJSON)

// DEBUG: 打印翻译后的请求
log.Debugf("codex responses: translated output JSON (first 2000 chars): %s", func() string {
s := string(rawJSON)
if len(s) > 2000 {
return s[:2000] + "..."
}
return s
}())

return rawJSON
}

Expand Down Expand Up @@ -85,6 +105,61 @@ func convertSystemRoleToDeveloper(rawJSON []byte) []byte {
return result
}

// convertReasoningContentToReasoning traverses the input array and converts
// reasoning_content in assistant messages to reasoning type with summary.
// DeepSeek requires passing reasoning_content back in multi-turn conversations.
func convertReasoningContentToReasoning(rawJSON []byte) []byte {
inputResult := gjson.GetBytes(rawJSON, "input")
if !inputResult.IsArray() {
return rawJSON
}

inputArray := inputResult.Array()
result := rawJSON

for i := 0; i < len(inputArray); i++ {
rolePath := fmt.Sprintf("input.%d.role", i)
role := gjson.GetBytes(result, rolePath).String()

// Only process assistant messages
if role != "assistant" {
continue
}

// Check if reasoning_content exists
rcPath := fmt.Sprintf("input.%d.reasoning_content", i)
rc := gjson.GetBytes(result, rcPath)
if !rc.Exists() || rc.Type != gjson.String || rc.String() == "" {
continue
}

// Get content array
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)
Comment on lines +137 to +154

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

这部分用于向 content 数组添加 reasoning 对象的逻辑有些复杂。您手动检查了数组是否存在,如果不存在则创建,然后获取数组长度来计算新元素的索引。

sjson 库支持使用 .-1 路径来向数组末尾追加元素,并且会自动创建不存在的父级对象或数组。利用这个特性可以大大简化代码。

Suggested change
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)


// Delete the original reasoning_content field
result, _ = sjson.DeleteBytes(result, rcPath)
}

return result
}

// normalizeCodexBuiltinTools rewrites legacy/preview built-in tool variants to the
// stable names expected by the current Codex upstream.
func normalizeCodexBuiltinTools(rawJSON []byte) []byte {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import (
// - []byte: The transformed request data in OpenAI chat completions format
func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inputRawJSON []byte, stream bool) []byte {
rawJSON := inputRawJSON

// Base OpenAI chat completions template with default values
out := []byte(`{"model":"","messages":[],"stream":false}`)

Expand Down Expand Up @@ -67,6 +68,10 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
case "message", "":
// Handle regular message conversion
role := item.Get("role").String()
// Skip items with empty role - these are invalid messages
if role == "" {
return true
}
if role == "developer" {
role = "user"
}
Expand Down Expand Up @@ -109,6 +114,14 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
message, _ = sjson.SetBytes(message, "content", content.String())
}

// Handle reasoning_content in message (for DeepSeek thinking mode)
// When assistant message has reasoning_content, pass it through
if role == "assistant" {
if rc := item.Get("reasoning_content"); rc.Exists() && rc.String() != "" {
message, _ = sjson.SetBytes(message, "reasoning_content", rc.String())
}
}

out, _ = sjson.SetRawBytes(out, "messages.-1", message)

case "function_call":
Expand Down Expand Up @@ -145,6 +158,38 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
}

out, _ = sjson.SetRawBytes(out, "messages.-1", toolMessage)

case "reasoning":
// Handle reasoning item conversion for DeepSeek thinking mode
// DeepSeek requires reasoning_content to be passed back in subsequent requests
// The reasoning item has a summary array with text content
summary := item.Get("summary")
if summary.Exists() && summary.IsArray() {
// Concatenate all summary text parts
var reasoningText strings.Builder
summary.ForEach(func(_, summaryItem gjson.Result) bool {
if summaryItem.Get("type").String() == "summary_text" {
text := summaryItem.Get("text").String()
if text != "" {
reasoningText.WriteString(text)
}
}
return true
})

// DeepSeek V4 requires reasoning_content even if empty
// When Codex CLI sends empty reasoning text, fill with placeholder
reasoningStr := reasoningText.String()
if reasoningStr == "" {
reasoningStr = "[reasoning unavailable]"
}

// Create assistant message with reasoning_content for DeepSeek
// DeepSeek expects: {"role":"assistant","content":"","reasoning_content":"..."}
reasoningMessage := []byte(`{"role":"assistant","content":"","reasoning_content":""}`)
reasoningMessage, _ = sjson.SetBytes(reasoningMessage, "reasoning_content", reasoningStr)
out, _ = sjson.SetRawBytes(out, "messages.-1", reasoningMessage)
}
}

return true
Expand Down Expand Up @@ -173,18 +218,45 @@ func ConvertOpenAIResponsesRequestToOpenAIChatCompletions(modelName string, inpu
chatTool := []byte(`{"type":"function","function":{}}`)

// Convert tool structure from responses format to chat completions format
// Handle both flat format {"name": "xxx"} and nested format {"function": {"name": "xxx"}}
function := []byte(`{"name":"","description":"","parameters":{}}`)

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())
// 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")
}
}
Comment on lines +224 to 255

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

if/else 块中的代码存在大量重复。两个分支都在处理从 toolnestedFunction 中提取 name, description, parameters 的逻辑,并且都包含了确保 parameters.typeobject 的检查。

为了消除重复并提高代码的可读性,建议重构此部分。可以先确定属性的来源(nestedFunctiontool 本身),然后对该来源执行一次提取和检查逻辑。

			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")
			}


if parameters := tool.Get("parameters"); parameters.Exists() {
function, _ = sjson.SetRawBytes(function, "parameters", []byte(parameters.Raw))
// Skip tools with empty names (invalid for most providers)
if gjson.GetBytes(function, "name").String() == "" {
return true
}

chatTool, _ = sjson.SetRawBytes(chatTool, "function", function)
Expand Down
5 changes: 5 additions & 0 deletions sdk/cliproxy/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -983,8 +983,13 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
modelID = m.Name
}
thinking := m.Thinking

// DEBUG: 打印 config 中的 thinking 值
log.Debugf("registerModelsForAuth: model=%s, config thinking=%v, type=%T", modelID, thinking, thinking)

if thinking == nil {
thinking = &registry.ThinkingSupport{Levels: []string{"low", "medium", "high"}}
log.Debugf("registerModelsForAuth: model=%s, using default thinking", modelID)
}
ms = append(ms, &ModelInfo{
ID: modelID,
Expand Down
Loading