feat: 提升 Codex 行为模拟保真度(仅加法式改进)

- 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) 属最新版特征,会与旧稳定版
头指纹混搭冲突,本次未加。
This commit is contained in:
eyre
2026-06-06 00:44:49 +08:00
parent 1cecd2716c
commit baf078fdb7
5 changed files with 74 additions and 11 deletions
@@ -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)
}
@@ -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
}
@@ -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)
@@ -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)
}
@@ -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) {