From baf078fdb7155eac6449e1a9219e880125bf84f7 Mon Sep 17 00:00:00 2001 From: eyre Date: Sat, 6 Jun 2026 00:44:49 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E6=8F=90=E5=8D=87=20Codex=20=E8=A1=8C?= =?UTF-8?q?=E4=B8=BA=E6=A8=A1=E6=8B=9F=E4=BF=9D=E7=9C=9F=E5=BA=A6=EF=BC=88?= =?UTF-8?q?=E4=BB=85=E5=8A=A0=E6=B3=95=E5=BC=8F=E6=94=B9=E8=BF=9B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - ForceCodexCLI 兜底 User-Agent 补全为完整结构 {originator}/{ver} ({OS} {ver}; {arch}) {terminal}, 与真实 codex_cli_rs UA 对齐,避免被上游指纹识别为非官方客户端 - 合成路径默认 instructions 改用内嵌的真实 Codex base prompt(openai.DefaultInstructions, "You are Codex, based on GPT-5..."),替换通用占位符;transform 与 hotpath 两处统一走 defaultCodexSynthInstructions() - /responses→chat 转换补全 SSE 事件 reasoning_text.delta 与 custom_tool_call_input.delta (流式 ResponsesEventToChatChunks 与缓冲 BufferedResponseAccumulator 两条路径), 并将 custom_tool_call item 一并注册以正确映射工具索引 - OAuth /responses 请求带 reasoning 时补齐 include:["reasoning.encrypted_content"], 幂等且加法式,不改动已有 include 保持旧稳定版兼容:不改动现有可工作的请求头(session_id/conversation_id/originator/ OpenAI-Beta 维持原样)。client_metadata(installation-id) 属最新版特征,会与旧稳定版 头指纹混搭冲突,本次未加。 --- .../apicompat/responses_to_chatcompletions.go | 20 +++++--- .../service/openai_codex_transform.go | 50 ++++++++++++++++++- .../service/openai_gateway_service.go | 7 ++- .../openai_gateway_service_hotpath_test.go | 6 ++- .../service/openai_oauth_passthrough_test.go | 2 +- 5 files changed, 74 insertions(+), 11 deletions(-) diff --git a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go index 8809b4fc0e..13a89ab04c 100644 --- a/backend/internal/pkg/apicompat/responses_to_chatcompletions.go +++ b/backend/internal/pkg/apicompat/responses_to_chatcompletions.go @@ -142,9 +142,15 @@ func ResponsesEventToChatChunks(evt *ResponsesStreamEvent, state *ResponsesEvent return resToChatHandleTextDelta(evt, state) case "response.output_item.added": return resToChatHandleOutputItemAdded(evt, state) - case "response.function_call_arguments.delta": + case "response.function_call_arguments.delta", + // custom/freeform 工具(如新版 apply_patch)的输入增量与 function_call 参数增量同形, + // 均按 OutputIndex 累加到对应工具调用。 + "response.custom_tool_call_input.delta": return resToChatHandleFuncArgsDelta(evt, state) - case "response.reasoning_summary_text.delta": + case "response.reasoning_summary_text.delta", + // 原始推理文本增量(真实 Codex 客户端消费的 reasoning_text.delta), + // 与 reasoning summary 一样映射为 reasoning_content。 + "response.reasoning_text.delta": return resToChatHandleReasoningDelta(evt, state) case "response.reasoning_summary_text.done": return nil @@ -228,7 +234,9 @@ func resToChatHandleTextDelta(evt *ResponsesStreamEvent, state *ResponsesEventTo } func resToChatHandleOutputItemAdded(evt *ResponsesStreamEvent, state *ResponsesEventToChatState) []ChatCompletionsChunk { - if evt.Item == nil || evt.Item.Type != "function_call" { + // function_call 与 custom_tool_call(custom/freeform 工具)均按工具调用注册, + // 以便后续 *_input.delta / *_arguments.delta 能映射到正确的工具索引。 + if evt.Item == nil || (evt.Item.Type != "function_call" && evt.Item.Type != "custom_tool_call") { return nil } @@ -445,7 +453,7 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) _, _ = a.text.WriteString(event.Delta) } case "response.output_item.added": - if event.Item != nil && event.Item.Type == "function_call" { + if event.Item != nil && (event.Item.Type == "function_call" || event.Item.Type == "custom_tool_call") { idx := len(a.funcCalls) a.outputIndexToFuncIdx[event.OutputIndex] = idx a.funcCalls = append(a.funcCalls, bufferedFuncCall{ @@ -453,13 +461,13 @@ func (a *BufferedResponseAccumulator) ProcessEvent(event *ResponsesStreamEvent) Name: event.Item.Name, }) } - case "response.function_call_arguments.delta": + case "response.function_call_arguments.delta", "response.custom_tool_call_input.delta": if event.Delta != "" { if idx, ok := a.outputIndexToFuncIdx[event.OutputIndex]; ok { _, _ = a.funcCalls[idx].Args.WriteString(event.Delta) } } - case "response.reasoning_summary_text.delta": + case "response.reasoning_summary_text.delta", "response.reasoning_text.delta": if event.Delta != "" { _, _ = a.reasoning.WriteString(event.Delta) } diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 1c2e3cb34c..49abb52a62 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -4,6 +4,8 @@ import ( "encoding/json" "fmt" "strings" + + "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) var codexModelMap = map[string]string{ @@ -156,6 +158,12 @@ func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuth } } + // 请求带 reasoning 时补齐 include:["reasoning.encrypted_content"],与真实 Codex 对齐 + // (compact 端点形态不同,单独处理,此处跳过)。 + if !opts.IsCompact && ensureCodexReasoningInclude(reqBody) { + result.Modified = true + } + // 兼容遗留的 functions 和 function_call,转换为 tools 和 tool_choice if functionsRaw, ok := reqBody["functions"]; ok { if functions, k := functionsRaw.([]any); k { @@ -964,12 +972,52 @@ func extractPromptLikeInstructionsFromInput(reqBody map[string]any) string { return strings.Join(texts, "\n\n") } +// defaultCodexSynthInstructions 返回合成路径在 instructions 为空时应填入的默认提示词。 +// +// 返回真实 Codex CLI 的 base instructions(openai.DefaultInstructions,内嵌自 +// instructions.txt,开头为 "You are Codex, based on GPT-5..."),使合成请求在提示词 +// 层面贴近真实 Codex 行为;若内嵌 prompt 意外为空,回退到最小占位符以保证字段非空。 +func defaultCodexSynthInstructions() string { + if instructions := strings.TrimSpace(openai.DefaultInstructions); instructions != "" { + return instructions + } + return "You are a helpful coding assistant." +} + +// ensureCodexReasoningInclude 在请求带 reasoning 时补齐 include:["reasoning.encrypted_content"]。 +// +// 真实 Codex 在 reasoning 存在时总会请求加密推理内容(ChatGPT/store=false 场景下用于上下文回放)。 +// 该函数为加法式、幂等:仅在 include 缺失或未包含该项时追加;对非数组的异常 include 不做破坏性改写。 +func ensureCodexReasoningInclude(reqBody map[string]any) bool { + reasoning, ok := reqBody["reasoning"].(map[string]any) + if !ok || len(reasoning) == 0 { + return false + } + const encrypted = "reasoning.encrypted_content" + switch existing := reqBody["include"].(type) { + case nil: + reqBody["include"] = []any{encrypted} + return true + case []any: + for _, v := range existing { + if s, ok := v.(string); ok && s == encrypted { + return false + } + } + reqBody["include"] = append(existing, encrypted) + return true + default: + // include 为非预期类型时保持原样,避免破坏调用方意图。 + return false + } +} + // applyInstructions 处理 instructions 字段:仅在 instructions 为空时填充默认值。 func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool { if !isInstructionsEmpty(reqBody) { return false } - reqBody["instructions"] = "You are a helpful coding assistant." + reqBody["instructions"] = defaultCodexSynthInstructions() return true } diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index 01db7080a4..4f94759b5c 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -42,7 +42,10 @@ const ( // OpenAI Platform API for API Key accounts (fallback) openaiPlatformAPIURL = "https://api.openai.com/v1/responses" openaiStickySessionTTL = time.Hour // 粘性会话TTL - codexCLIUserAgent = "codex_cli_rs/0.125.0" + // 与真实 Codex CLI 的 User-Agent 结构对齐: + // {originator}/{version} ({OS} {OS_version}; {arch}) {terminal} + // 旧值 "codex_cli_rs/0.125.0" 缺少 OS/架构/终端后缀,易被上游指纹识别为非官方客户端。 + codexCLIUserAgent = "codex_cli_rs/0.125.0 (Ubuntu 22.4.0; x86_64) xterm-256color" // codex_cli_only 拒绝时单个请求头日志长度上限(字符) codexCLIOnlyHeaderValueMaxBytes = 256 @@ -2499,7 +2502,7 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco instructions := gjson.GetBytes(body, "instructions") instructionsEmpty := !instructions.Exists() || instructions.Type != gjson.String || strings.TrimSpace(instructions.String()) == "" if instructionsEmpty && !compatMessagesBridge { - markPatchSet("instructions", "You are a helpful coding assistant.") + markPatchSet("instructions", defaultCodexSynthInstructions()) } billingModel := account.GetMappedModel(reqModel) diff --git a/backend/internal/service/openai_gateway_service_hotpath_test.go b/backend/internal/service/openai_gateway_service_hotpath_test.go index 92a0d1ac03..ce67414dfd 100644 --- a/backend/internal/service/openai_gateway_service_hotpath_test.go +++ b/backend/internal/service/openai_gateway_service_hotpath_test.go @@ -3,6 +3,7 @@ package service import ( "context" "encoding/json" + "fmt" "io" "net/http" "net/http/httptest" @@ -111,7 +112,10 @@ func TestOpenAIGatewayService_Forward_HTTPPatchPathKeepsLargeInputRaw(t *testing require.NoError(t, err) require.NotNil(t, result) require.NotNil(t, upstream.lastReq) - require.JSONEq(t, `{"model":"gpt-5","stream":false,"reasoning":{"effort":"none"},"instructions":"You are a helpful coding assistant.","input":[{"type":"message","content":[{"type":"input_text","text":"hi","nonce":9007199254740993}]}]}`, string(upstream.lastBody)) + // 合成路径默认 instructions 现填入真实 Codex base prompt(openai.DefaultInstructions)。 + encodedInstr, _ := json.Marshal(defaultCodexSynthInstructions()) + expectedBody := fmt.Sprintf(`{"model":"gpt-5","stream":false,"reasoning":{"effort":"none"},"instructions":%s,"input":[{"type":"message","content":[{"type":"input_text","text":"hi","nonce":9007199254740993}]}]}`, string(encodedInstr)) + require.JSONEq(t, expectedBody, string(upstream.lastBody)) require.Equal(t, "9007199254740993", gjson.GetBytes(upstream.lastBody, "input.0.content.0.nonce").Raw) } diff --git a/backend/internal/service/openai_oauth_passthrough_test.go b/backend/internal/service/openai_oauth_passthrough_test.go index 2710c696e6..b371808066 100644 --- a/backend/internal/service/openai_oauth_passthrough_test.go +++ b/backend/internal/service/openai_oauth_passthrough_test.go @@ -975,7 +975,7 @@ func TestOpenAIGatewayService_OAuthPassthrough_NonCodexUAFallbackToCodexUA(t *te require.NoError(t, err) require.Equal(t, false, gjson.GetBytes(upstream.lastBody, "store").Bool()) require.Equal(t, true, gjson.GetBytes(upstream.lastBody, "stream").Bool()) - require.Equal(t, "codex_cli_rs/0.125.0", upstream.lastReq.Header.Get("User-Agent")) + require.Equal(t, codexCLIUserAgent, upstream.lastReq.Header.Get("User-Agent")) } func TestOpenAIGatewayService_CodexCLIOnly_RejectsNonCodexClient(t *testing.T) {