mirror of
https://github.com/Wei-Shaw/sub2api.git
synced 2026-09-24 16:05:44 +08:00
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:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user