package service import ( "crypto/sha256" "encoding/hex" "encoding/json" "fmt" "strings" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" ) var codexModelMap = map[string]string{ "gpt-5.6-sol": "gpt-5.6-sol", "gpt-5.6-terra": "gpt-5.6-terra", "gpt-5.6-luna": "gpt-5.6-luna", "gpt-5.5": "gpt-5.5", "gpt-5.5-pro": "gpt-5.5-pro", "codex-auto-review": "codex-auto-review", "gpt-5.4": "gpt-5.4", "gpt-5.4-mini": "gpt-5.4-mini", "gpt-5.4-none": "gpt-5.4", "gpt-5.4-low": "gpt-5.4", "gpt-5.4-medium": "gpt-5.4", "gpt-5.4-high": "gpt-5.4", "gpt-5.4-xhigh": "gpt-5.4", "gpt-5.4-chat-latest": "gpt-5.4", "gpt-5.3": "gpt-5.3-codex", "gpt-5.3-none": "gpt-5.3-codex", "gpt-5.3-low": "gpt-5.3-codex", "gpt-5.3-medium": "gpt-5.3-codex", "gpt-5.3-high": "gpt-5.3-codex", "gpt-5.3-xhigh": "gpt-5.3-codex", "gpt-5.3-codex": "gpt-5.3-codex", "gpt-5.3-codex-spark": "gpt-5.3-codex-spark", "gpt-5.3-codex-low": "gpt-5.3-codex", "gpt-5.3-codex-medium": "gpt-5.3-codex", "gpt-5.3-codex-high": "gpt-5.3-codex", "gpt-5.3-codex-xhigh": "gpt-5.3-codex", "gpt-5.2": "gpt-5.2", "gpt-5.2-none": "gpt-5.2", "gpt-5.2-low": "gpt-5.2", "gpt-5.2-medium": "gpt-5.2", "gpt-5.2-high": "gpt-5.2", "gpt-5.2-xhigh": "gpt-5.2", "gpt-5": "gpt-5.4", "gpt-5-mini": "gpt-5.4", "gpt-5-nano": "gpt-5.4", "gpt-5.1": "gpt-5.4", "gpt-5.1-codex": "gpt-5.3-codex", "gpt-5.1-codex-max": "gpt-5.3-codex", "gpt-5.1-codex-mini": "gpt-5.3-codex", "gpt-5.2-codex": "gpt-5.2", "codex-mini-latest": "gpt-5.3-codex", "gpt-5-codex": "gpt-5.3-codex", } var codexVersionModelPrefixes = []struct { prefix string target string }{ {prefix: "gpt-5.6-sol", target: "gpt-5.6-sol"}, {prefix: "gpt-5.6-terra", target: "gpt-5.6-terra"}, {prefix: "gpt-5.6-luna", target: "gpt-5.6-luna"}, {prefix: "gpt-5.3-codex-spark", target: "gpt-5.3-codex-spark"}, {prefix: "gpt-5.3-codex", target: "gpt-5.3-codex"}, {prefix: "gpt-5.4-mini", target: "gpt-5.4-mini"}, {prefix: "gpt-5.4-nano", target: "gpt-5.4-nano"}, {prefix: "gpt-5.5-pro", target: "gpt-5.5-pro"}, {prefix: "gpt-5.5", target: "gpt-5.5"}, {prefix: "gpt-5.4", target: "gpt-5.4"}, {prefix: "gpt-5.2", target: "gpt-5.2"}, } type codexTransformResult struct { Modified bool NormalizedModel string PromptCacheKey string } type codexOAuthTransformOptions struct { IsCodexCLI bool IsCompact bool SkipDefaultInstructions bool PreserveToolCallIDs bool OmitPromotedSystemMessagesFromInput bool } const ( codexCallIDMaxLength = 64 codexCallIDPrefix = "fc_" ) func normalizeCodexCallID(id string) string { candidate := id switch { case id == "": return "" case strings.HasPrefix(id, "fc"): case strings.HasPrefix(id, "call_"): candidate = codexCallIDPrefix + strings.TrimPrefix(id, "call_") default: candidate = codexCallIDPrefix + id } if len(candidate) <= codexCallIDMaxLength { return candidate } return compactCodexCallID(candidate) } func compactCodexCallID(id string) string { digest := sha256.Sum256([]byte("sub2api:codex-call-id:v1:" + id)) encoded := hex.EncodeToString(digest[:]) return codexCallIDPrefix + encoded[:codexCallIDMaxLength-len(codexCallIDPrefix)] } const codexImageGenerationFunctionToolName = "image_gen.imagegen" const ( codexImageGenerationBridgeMarker = "" codexImageGenerationBridgeText = codexImageGenerationBridgeMarker + "\nWhen the user asks for raster image generation or editing, use the OpenAI Responses native `image_generation` tool attached to this request. The local Codex client may not expose an `image_gen` namespace, but that does not mean image generation is unavailable. Do not ask the user to switch to CLI fallback solely because `image_gen` is absent.\n" codexSparkImageUnsupportedMarker = "" codexSparkImageUnsupportedText = codexSparkImageUnsupportedMarker + "\nThe current model is gpt-5.3-codex-spark, which does not support image generation, image editing, image input, the `image_generation` tool, or Codex `image_gen`/`$imagegen` workflows. If the user asks for image generation or image editing, clearly explain this model limitation and ask them to switch to a non-Spark Codex model such as gpt-5.3-codex or gpt-5.4. Do not claim that the local environment merely lacks image_gen tooling, and do not suggest CLI fallback as the primary fix while the model remains Spark.\n" ) var openAIChatGPTInternalUnsupportedFields = []string{ "user", "metadata", "prompt_cache_retention", "safety_identifier", "stream_options", } var openAICodexOAuthUnsupportedFields = append([]string{ "max_output_tokens", "max_completion_tokens", "temperature", "top_p", "frequency_penalty", "presence_penalty", }, openAIChatGPTInternalUnsupportedFields...) func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact bool) codexTransformResult { return applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{ IsCodexCLI: isCodexCLI, IsCompact: isCompact, }) } func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuthTransformOptions) codexTransformResult { result := codexTransformResult{} // 工具续链需求会影响存储策略与 input 过滤逻辑。 needsToolContinuation := NeedsToolContinuation(reqBody) model := "" if v, ok := reqBody["model"].(string); ok { model = v } normalizedModel := strings.TrimSpace(model) if normalizedModel != "" { if model != normalizedModel { reqBody["model"] = normalizedModel result.Modified = true } result.NormalizedModel = normalizedModel } if opts.IsCompact { if _, ok := reqBody["store"]; ok { delete(reqBody, "store") result.Modified = true } if _, ok := reqBody["stream"]; ok { delete(reqBody, "stream") result.Modified = true } } else { // OAuth 走 ChatGPT internal API 时,store 必须为 false;显式 true 也会强制覆盖。 // 避免上游返回 "Store must be set to false"。 if v, ok := reqBody["store"].(bool); !ok || v { reqBody["store"] = false result.Modified = true } if v, ok := reqBody["stream"].(bool); !ok || !v { reqBody["stream"] = true result.Modified = true } } // Strip parameters unsupported by ChatGPT internal Codex endpoint. for _, key := range openAICodexOAuthUnsupportedFields { if _, ok := reqBody[key]; ok { delete(reqBody, key) result.Modified = true } } // 请求带 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 { tools := make([]any, 0, len(functions)) for _, f := range functions { tools = append(tools, map[string]any{ "type": "function", "function": f, }) } reqBody["tools"] = tools } delete(reqBody, "functions") result.Modified = true } if fcRaw, ok := reqBody["function_call"]; ok { if fcStr, ok := fcRaw.(string); ok { // e.g. "auto", "none" reqBody["tool_choice"] = fcStr } else if fcObj, ok := fcRaw.(map[string]any); ok { // e.g. {"name": "my_func"} if name, ok := fcObj["name"].(string); ok && strings.TrimSpace(name) != "" { reqBody["tool_choice"] = map[string]any{ "type": "function", "name": name, } } } delete(reqBody, "function_call") result.Modified = true } if normalizeCodexTools(reqBody) { result.Modified = true } if normalizeCodexToolChoice(reqBody) { result.Modified = true } if v, ok := reqBody["prompt_cache_key"].(string); ok { result.PromptCacheKey = strings.TrimSpace(v) if isOpenAICompatMessagesBridgeRequestBody(reqBody) { delete(reqBody, "prompt_cache_key") result.Modified = true } } // ChatGPT internal Codex endpoint does not accept role:"system". // Mirror its text into instructions because Codex OAuth requires it. Some // callers must also keep the guidance in input as developer (notably // Responses JSON object mode), while Chat Completions compatibility can // omit text-only messages after promoting them losslessly. if extractSystemMessagesFromInput(reqBody, opts.OmitPromotedSystemMessagesFromInput) { result.Modified = true } // instructions 处理逻辑:根据是否是 Codex CLI 分别调用不同方法 if !opts.SkipDefaultInstructions && applyInstructions(reqBody, opts.IsCodexCLI) { result.Modified = true } if isCodexSparkModel(normalizedModel) && applyCodexSparkImageUnsupportedInstructions(reqBody) { result.Modified = true } // gpt-5.3-codex-spark rejects the image_generation tool upstream (HTTP 400, // param=tools); Codex CLI advertises it by default, so strip it for spark. if isCodexSparkModel(normalizedModel) && stripCodexSparkImageGenerationTools(reqBody) { result.Modified = true } // 续链场景保留 item_reference 与 id,避免 call_id 上下文丢失。 if input, ok := reqBody["input"].([]any); ok { if normalizedInput, modified := normalizeCodexToolRoleMessages(input); modified { input = normalizedInput result.Modified = true } if normalizedInput, modified := normalizeCodexMessageContentText(input); modified { input = normalizedInput result.Modified = true } input = filterCodexInputWithOptions(input, codexInputFilterOptions{ PreserveReferences: needsToolContinuation, PreserveCallIDs: opts.PreserveToolCallIDs, }) reqBody["input"] = input result.Modified = true } else if inputStr, ok := reqBody["input"].(string); ok { // ChatGPT codex endpoint requires input to be a list, not a string. // Convert string input to the expected message array format. trimmed := strings.TrimSpace(inputStr) if trimmed != "" { reqBody["input"] = []any{ map[string]any{ "type": "message", "role": "user", "content": inputStr, }, } } else { reqBody["input"] = []any{} } result.Modified = true } return result } func normalizeCodexToolChoice(reqBody map[string]any) bool { choice, ok := reqBody["tool_choice"] if !ok || choice == nil { return false } choiceMap, ok := choice.(map[string]any) if !ok { return false } choiceType := strings.TrimSpace(firstNonEmptyString(choiceMap["type"])) if choiceType == "" { return false } modified := false if choiceType == "function" { name := strings.TrimSpace(firstNonEmptyString(choiceMap["name"])) if name == "" { if function, ok := choiceMap["function"].(map[string]any); ok { name = strings.TrimSpace(firstNonEmptyString(function["name"])) } } if name == "" { reqBody["tool_choice"] = "auto" return true } if strings.TrimSpace(firstNonEmptyString(choiceMap["name"])) != name { choiceMap["name"] = name modified = true } if _, ok := choiceMap["function"]; ok { delete(choiceMap, "function") modified = true } if !codexToolsContainFunctionName(reqBody["tools"], name) { reqBody["tool_choice"] = "auto" return true } return modified } if codexToolsContainType(reqBody["tools"], choiceType) || codexInputAdditionalToolsContainType(reqBody["input"], choiceType) { return modified } reqBody["tool_choice"] = "auto" return true } func codexInputAdditionalToolsContainType(rawInput any, toolType string) bool { input, ok := rawInput.([]any) if !ok || strings.TrimSpace(toolType) == "" { return false } for _, rawItem := range input { item, ok := rawItem.(map[string]any) if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" { continue } if codexToolsContainType(item["tools"], toolType) { return true } } return false } func codexToolsContainType(rawTools any, toolType string) bool { tools, ok := rawTools.([]any) if !ok || strings.TrimSpace(toolType) == "" { return false } for _, rawTool := range tools { tool, ok := rawTool.(map[string]any) if !ok { continue } if strings.TrimSpace(firstNonEmptyString(tool["type"])) == toolType { return true } } return false } func codexToolsContainFunctionName(rawTools any, name string) bool { tools, ok := rawTools.([]any) if !ok || strings.TrimSpace(name) == "" { return false } normalizedName := strings.TrimSpace(name) for _, rawTool := range tools { tool, ok := rawTool.(map[string]any) if !ok { continue } if strings.TrimSpace(firstNonEmptyString(tool["type"])) != "function" { continue } toolName := strings.TrimSpace(firstNonEmptyString(tool["name"])) if toolName == "" { if function, ok := tool["function"].(map[string]any); ok { toolName = strings.TrimSpace(firstNonEmptyString(function["name"])) } } if toolName == normalizedName { return true } } return false } func normalizeCodexToolRoleMessages(input []any) ([]any, bool) { if len(input) == 0 { return input, false } modified := false normalized := make([]any, 0, len(input)) for _, item := range input { m, ok := item.(map[string]any) if !ok { normalized = append(normalized, item) continue } role, _ := m["role"].(string) if strings.TrimSpace(role) != "tool" { normalized = append(normalized, item) continue } callID := firstNonEmptyString(m["call_id"], m["tool_call_id"], m["id"]) callID = strings.TrimSpace(callID) if callID == "" { // Responses does not accept role:"tool". If no call id is available, // preserve the text as a user message instead of sending invalid input. fallback := make(map[string]any, len(m)) for key, value := range m { fallback[key] = value } fallback["role"] = "user" delete(fallback, "tool_call_id") normalized = append(normalized, fallback) modified = true continue } output := extractTextFromContent(m["content"]) if output == "" { if value, ok := m["output"].(string); ok { output = value } } if output == "" && m["content"] != nil { if b, err := json.Marshal(m["content"]); err == nil { output = string(b) } } normalized = append(normalized, map[string]any{ "type": "function_call_output", "call_id": callID, "output": output, }) modified = true } if !modified { return input, false } return normalized, true } func normalizeCodexMessageContentText(input []any) ([]any, bool) { if len(input) == 0 { return input, false } modified := false normalized := make([]any, 0, len(input)) for _, item := range input { m, ok := item.(map[string]any) if !ok || strings.TrimSpace(firstNonEmptyString(m["type"])) != "message" { normalized = append(normalized, item) continue } parts, ok := m["content"].([]any) if !ok { normalized = append(normalized, item) continue } var newItem map[string]any var newParts []any ensureItemCopy := func() { if newItem != nil { return } newItem = make(map[string]any, len(m)) for key, value := range m { newItem[key] = value } newParts = make([]any, len(parts)) copy(newParts, parts) } for i, rawPart := range parts { part, ok := rawPart.(map[string]any) if !ok { continue } text, hasText := part["text"] if !hasText { continue } if _, ok := text.(string); ok { continue } ensureItemCopy() newPart := make(map[string]any, len(part)) for key, value := range part { newPart[key] = value } newPart["text"] = stringifyCodexContentText(text) newParts[i] = newPart modified = true } if newItem != nil { newItem["content"] = newParts normalized = append(normalized, newItem) continue } normalized = append(normalized, item) } if !modified { return input, false } return normalized, true } func stringifyCodexContentText(value any) string { switch v := value.(type) { case string: return v case nil: return "" default: if b, err := json.Marshal(v); err == nil { return string(b) } return fmt.Sprint(v) } } func normalizeCodexModel(model string) string { model = strings.TrimSpace(model) if model == "" { return "gpt-5.4" } if mapped, ok := normalizeKnownCodexModel(model); ok { return mapped } return model } func normalizeKnownCodexModel(model string) (string, bool) { model = strings.TrimSpace(model) if model == "" { return "", false } if isOpenAIImageGenerationModel(model) { return model, true } modelID := lastOpenAIModelSegment(model) if normalized := canonicalizeOpenAIModelAliasSpelling(modelID); normalized != "" { modelID = normalized } if mapped := normalizeKnownOpenAICodexModel(modelID); mapped != "" { return mapped, true } key := codexModelLookupKey(modelID) if key == "" { return "", false } if mapped := getNormalizedCodexModel(key); mapped != "" { return mapped, true } for _, item := range codexVersionModelPrefixes { if key == item.prefix { return item.target, true } suffix, ok := strings.CutPrefix(key, item.prefix+"-") if ok && isKnownCodexModelSuffix(suffix) { return item.target, true } } return "", false } func codexModelLookupKey(modelID string) string { modelID = strings.TrimSpace(modelID) if modelID == "" { return "" } if strings.Contains(modelID, "/") { parts := strings.Split(modelID, "/") modelID = parts[len(parts)-1] } return strings.ToLower(strings.Join(strings.Fields(modelID), "-")) } func isKnownCodexModelSuffix(suffix string) bool { switch suffix { case "none", "minimal", "low", "medium", "high", "xhigh": return true } return isCodexDateSuffix(suffix) } func isCodexDateSuffix(suffix string) bool { parts := strings.Split(suffix, "-") if len(parts) != 3 || len(parts[0]) != 4 || len(parts[1]) != 2 || len(parts[2]) != 2 { return false } for _, part := range parts { for _, r := range part { if r < '0' || r > '9' { return false } } } return true } func isCodexSparkModel(model string) bool { return normalizeCodexModel(model) == "gpt-5.3-codex-spark" } func hasOpenAIImageGenerationTool(reqBody map[string]any) bool { if toolsContainImageGeneration(reqBody["tools"]) { return true } return inputContainsImageGenerationTool(reqBody["input"]) } func hasCodexImageGenerationFunctionTool(reqBody map[string]any) bool { return len(reqBody) > 0 && codexToolsContainFunctionName(reqBody["tools"], codexImageGenerationFunctionToolName) } func toolsContainImageGeneration(rawTools any) bool { if rawTools == nil { return false } tools, ok := rawTools.([]any) if !ok { return false } for _, rawTool := range tools { toolMap, ok := rawTool.(map[string]any) if !ok { continue } if isOpenAIImageGenerationToolMap(toolMap) { return true } } return false } func isOpenAIImageGenerationToolMap(tool map[string]any) bool { return isOpenAIImageGenerationType(firstNonEmptyString(tool["type"])) || isImageGenNamespaceToolMap(tool) } func isImageGenNamespaceToolMap(tool map[string]any) bool { return strings.TrimSpace(firstNonEmptyString(tool["type"])) == "namespace" && isOpenAIImageGenNamespaceName(firstNonEmptyString(tool["name"])) } func inputContainsImageGenerationTool(rawInput any) bool { input, ok := rawInput.([]any) if !ok { return false } for _, rawItem := range input { item, ok := rawItem.(map[string]any) if !ok { continue } if strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" { continue } if toolsContainImageGeneration(item["tools"]) { return true } } return false } // stripOpenAIImageGenerationTools keeps account-level strip policy symmetric // across standard Responses tools, Responses Lite additional_tools, and tool_choice. func stripOpenAIImageGenerationTools(reqBody map[string]any) bool { if reqBody == nil { return false } modified := stripOpenAIImageGenerationToolList(reqBody, "tools") if stripOpenAIImageGenerationToolsFromInput(reqBody) { modified = true } if openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) { delete(reqBody, "tool_choice") modified = true } return modified } func stripOpenAIImageGenerationToolList(container map[string]any, key string) bool { rawTools, ok := container[key] if !ok || rawTools == nil { return false } tools, ok := rawTools.([]any) if !ok { return false } filtered := make([]any, 0, len(tools)) removed := false for _, rawTool := range tools { if toolMap, ok := rawTool.(map[string]any); ok && isOpenAIImageGenerationToolMap(toolMap) { removed = true continue } filtered = append(filtered, rawTool) } if !removed { return false } if len(filtered) == 0 { delete(container, key) } else { container[key] = filtered } return true } func stripOpenAIImageGenerationToolsFromInput(reqBody map[string]any) bool { input, ok := reqBody["input"].([]any) if !ok { return false } filteredInput := make([]any, 0, len(input)) modified := false for _, rawItem := range input { item, ok := rawItem.(map[string]any) if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" { filteredInput = append(filteredInput, rawItem) continue } if !stripOpenAIImageGenerationToolList(item, "tools") { filteredInput = append(filteredInput, rawItem) continue } modified = true if _, hasTools := item["tools"]; hasTools { filteredInput = append(filteredInput, rawItem) } // An empty additional_tools carrier is not useful upstream; drop the item // after its only declared capability has been removed. } if modified { reqBody["input"] = filteredInput } return modified } // stripOpenAIImageGenerationToolsFromRawPayload is the shared adapter for paths // that forward raw HTTP or WebSocket payloads without the normal request map. func stripOpenAIImageGenerationToolsFromRawPayload(payload []byte) ([]byte, bool, error) { if !openAIRequestBodyHasImageGenerationDeclaration(payload) { if json.Valid(payload) { return payload, false, nil } var invalidPayload map[string]any return payload, false, json.Unmarshal(payload, &invalidPayload) } payloadMap := make(map[string]any) if err := json.Unmarshal(payload, &payloadMap); err != nil { return payload, false, err } if !stripOpenAIImageGenerationTools(payloadMap) { return payload, false, nil } rebuilt, err := json.Marshal(payloadMap) if err != nil { return payload, false, err } return rebuilt, true, nil } // stripCodexSparkImageGenerationTools removes image tool declarations and choices. // gpt-5.3-codex-spark rejects those capabilities upstream, while Codex clients may // advertise them by default. func stripCodexSparkImageGenerationTools(reqBody map[string]any) bool { return stripOpenAIImageGenerationTools(reqBody) } func hasOpenAIInputImage(reqBody map[string]any) bool { if reqBody == nil { return false } return hasOpenAIInputImageValue(reqBody["input"]) || hasOpenAIInputImageValue(reqBody["messages"]) } func hasOpenAIInputImageValue(value any) bool { switch v := value.(type) { case []any: for _, item := range v { if hasOpenAIInputImageValue(item) { return true } } case map[string]any: if strings.TrimSpace(firstNonEmptyString(v["type"])) == "input_image" { return true } if _, ok := v["image_url"]; ok { return true } return hasOpenAIInputImageValue(v["content"]) } return false } func validateCodexSparkInput(reqBody map[string]any, model string) error { if !isCodexSparkModel(model) || !hasOpenAIInputImage(reqBody) { return nil } return fmt.Errorf("model %q does not support image input", strings.TrimSpace(model)) } func normalizeOpenAIResponsesImageGenerationTools(reqBody map[string]any) bool { rawTools, ok := reqBody["tools"] if !ok || rawTools == nil { return false } tools, ok := rawTools.([]any) if !ok { return false } modified := false for _, rawTool := range tools { toolMap, ok := rawTool.(map[string]any) if !ok || strings.TrimSpace(firstNonEmptyString(toolMap["type"])) != "image_generation" { continue } if _, ok := toolMap["output_format"]; !ok { if value := strings.TrimSpace(firstNonEmptyString(toolMap["format"])); value != "" { toolMap["output_format"] = value modified = true } } if _, ok := toolMap["output_compression"]; !ok { if value, exists := toolMap["compression"]; exists && value != nil { toolMap["output_compression"] = value modified = true } } if _, ok := toolMap["format"]; ok { delete(toolMap, "format") modified = true } if _, ok := toolMap["compression"]; ok { delete(toolMap, "compression") modified = true } } return modified } func ensureOpenAIResponsesImageGenerationTool(reqBody map[string]any) bool { if len(reqBody) == 0 { return false } if isCodexSparkModel(firstNonEmptyString(reqBody["model"])) { return false } if hasCodexImageGenerationFunctionTool(reqBody) { return false } if hasOpenAIImageGenerationTool(reqBody) { return false } tool := map[string]any{ "type": "image_generation", "output_format": "png", } rawTools, ok := reqBody["tools"] if !ok || rawTools == nil { reqBody["tools"] = []any{tool} return true } tools, ok := rawTools.([]any) if !ok { reqBody["tools"] = []any{tool} return true } reqBody["tools"] = append(tools, tool) return true } func ensureOpenAIResponsesImageGenerationToolChoiceAuto(reqBody map[string]any) bool { if len(reqBody) == 0 || hasCodexImageGenerationFunctionTool(reqBody) || !hasOpenAIImageGenerationTool(reqBody) { return false } if isCodexSparkModel(firstNonEmptyString(reqBody["model"])) { return false } if _, ok := reqBody["tool_choice"]; ok { return false } reqBody["tool_choice"] = "auto" return true } func applyCodexImageGenerationBridgeInstructions(reqBody map[string]any) bool { if len(reqBody) == 0 || hasCodexImageGenerationFunctionTool(reqBody) || !hasOpenAIImageGenerationTool(reqBody) { return false } if isCodexSparkModel(firstNonEmptyString(reqBody["model"])) { return false } existing, _ := reqBody["instructions"].(string) if strings.Contains(existing, codexImageGenerationBridgeMarker) { return false } existing = strings.TrimRight(existing, " \t\r\n") if strings.TrimSpace(existing) == "" { reqBody["instructions"] = codexImageGenerationBridgeText return true } reqBody["instructions"] = existing + "\n\n" + codexImageGenerationBridgeText return true } func applyCodexSparkImageUnsupportedInstructions(reqBody map[string]any) bool { if len(reqBody) == 0 { return false } existing, _ := reqBody["instructions"].(string) if strings.Contains(existing, codexSparkImageUnsupportedMarker) { return false } existing = strings.TrimRight(existing, " \t\r\n") if strings.TrimSpace(existing) == "" { reqBody["instructions"] = codexSparkImageUnsupportedText return true } reqBody["instructions"] = existing + "\n\n" + codexSparkImageUnsupportedText return true } func validateOpenAIResponsesImageModel(reqBody map[string]any, model string) error { if !hasOpenAIImageGenerationTool(reqBody) { return nil } model = strings.TrimSpace(model) if !isOpenAIImageGenerationModel(model) { return nil } return fmt.Errorf("/v1/responses image_generation requests require a Responses-capable text model; image-only model %q is not allowed", model) } func normalizeOpenAIResponsesImageOnlyModel(reqBody map[string]any) bool { if len(reqBody) == 0 { return false } imageModel := strings.TrimSpace(firstNonEmptyString(reqBody["model"])) if !isOpenAIImageGenerationModel(imageModel) { return false } modified := false tools, _ := reqBody["tools"].([]any) imageToolIndex := -1 for i, rawTool := range tools { toolMap, ok := rawTool.(map[string]any) if !ok { continue } if strings.TrimSpace(firstNonEmptyString(toolMap["type"])) == "image_generation" { imageToolIndex = i break } } if imageToolIndex < 0 { tools = append(tools, map[string]any{ "type": "image_generation", "model": imageModel, }) imageToolIndex = len(tools) - 1 reqBody["tools"] = tools modified = true } if toolMap, ok := tools[imageToolIndex].(map[string]any); ok { if strings.TrimSpace(firstNonEmptyString(toolMap["model"])) == "" { toolMap["model"] = imageModel modified = true } for _, key := range []string{ "size", "quality", "background", "output_format", "output_compression", "moderation", "style", "partial_images", } { if value, exists := reqBody[key]; exists && value != nil { if _, toolHas := toolMap[key]; !toolHas { toolMap[key] = value } delete(reqBody, key) modified = true } } } if prompt := strings.TrimSpace(firstNonEmptyString(reqBody["prompt"])); prompt != "" { if _, hasInput := reqBody["input"]; !hasInput { reqBody["input"] = prompt } delete(reqBody, "prompt") modified = true } if _, ok := reqBody["tool_choice"]; !ok { reqBody["tool_choice"] = map[string]any{"type": "image_generation"} modified = true } if imageModel != openAIImagesResponsesMainModel { modified = true } reqBody["model"] = openAIImagesResponsesMainModel return modified } func normalizeOpenAIModelForUpstream(account *Account, model string) string { if account == nil || account.Type == AccountTypeOAuth { return normalizeCodexModel(model) } return strings.TrimSpace(model) } func SupportsVerbosity(model string) bool { if !strings.HasPrefix(model, "gpt-") { return true } var major, minor int n, _ := fmt.Sscanf(model, "gpt-%d.%d", &major, &minor) if major > 5 { return true } if major < 5 { return false } // gpt-5 if n == 1 { return true } return minor >= 3 } func getNormalizedCodexModel(modelID string) string { key := codexModelLookupKey(modelID) if key == "" { return "" } if mapped, ok := codexModelMap[key]; ok { return mapped } return "" } // extractTextFromContent extracts plain text from a content value that is either // a Go string or a []any of text-like content-part maps. func extractTextFromContent(content any) string { switch v := content.(type) { case string: return v case []any: var parts []string for _, part := range v { m, ok := part.(map[string]any) if !ok { continue } switch t, _ := m["type"].(string); t { case "text", "input_text", "output_text": if text, ok := m["text"].(string); ok { parts = append(parts, text) } } } return strings.Join(parts, "") default: return "" } } // extractSystemMessagesFromInput scans input for role=="system" and mirrors // their text into reqBody["instructions"]. By default it maps those items to // developer so Responses JSON mode can still see JSON instructions in input. // When omitPromoted is true, text-only items are removed after their content is // losslessly promoted; mixed or malformed content is retained as developer. func extractSystemMessagesFromInput(reqBody map[string]any, omitPromoted bool) bool { input, ok := reqBody["input"].([]any) if !ok || len(input) == 0 { return false } var systemTexts []string filteredInput := make([]any, 0, len(input)) modified := false for _, item := range input { m, ok := item.(map[string]any) if !ok || m["role"] != "system" { filteredInput = append(filteredInput, item) continue } if omitPromoted { if losslessText, lossless := extractLosslessTextFromContent(m["content"]); lossless { if losslessText != "" { systemTexts = append(systemTexts, losslessText) } modified = true continue } } if text := extractTextFromContent(m["content"]); text != "" { systemTexts = append(systemTexts, text) } m["role"] = "developer" filteredInput = append(filteredInput, item) modified = true } if omitPromoted && len(filteredInput) != len(input) { reqBody["input"] = filteredInput } if len(systemTexts) == 0 { return modified } extracted := strings.Join(systemTexts, "\n\n") if existing, ok := reqBody["instructions"].(string); ok && strings.TrimSpace(existing) != "" { reqBody["instructions"] = extracted + "\n\n" + existing } else { reqBody["instructions"] = extracted } return true } // extractLosslessTextFromContent returns text only when the entire content can // be represented by an instructions string without dropping non-text parts. func extractLosslessTextFromContent(content any) (string, bool) { switch v := content.(type) { case string: return v, true case []any: var b strings.Builder for _, part := range v { m, ok := part.(map[string]any) if !ok { return "", false } typeName, ok := m["type"].(string) if !ok || (typeName != "text" && typeName != "input_text" && typeName != "output_text") { return "", false } text, ok := m["text"].(string) if !ok { return "", false } _, _ = b.WriteString(text) } return b.String(), true default: return "", false } } func extractPromptLikeInstructionsFromInput(reqBody map[string]any) string { input, ok := reqBody["input"].([]any) if !ok || len(input) == 0 { return "" } var texts []string for _, item := range input { m, ok := item.(map[string]any) if !ok { continue } role, _ := m["role"].(string) switch role { case "developer", "system": if text := strings.TrimSpace(extractTextFromContent(m["content"])); text != "" { texts = append(texts, text) } } } return strings.Join(texts, "\n\n") } // defaultCodexSynthInstructions 返回合成路径在 instructions 为空时应填入的默认提示词。 // // 按 model 选择真实 Codex CLI 的 base instructions(codex 系→GPT-5-Codex, // gpt-5.2→GPT-5.2,gpt-5.1/gpt-5→GPT-5.1),使合成请求在提示词层面贴近真实 Codex 行为; // 若内嵌 prompt 意外为空,回退到最小占位符以保证字段非空。 func defaultCodexSynthInstructions(model string) string { if instructions := strings.TrimSpace(openai.CodexBaseInstructionsForModel(model)); 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 } } // applyCodexClientMetadata 在请求体补齐 client_metadata["x-codex-installation-id"], // 取值为账号真实的 openai_device_id(最新 Codex 在请求体携带的安装标识)。 // // 加法式、幂等:仅在账号存在 device_id 且该键缺失时注入,绝不覆盖既有 client_metadata // (如 turn metadata),也不伪造——无 device_id 时不写入。 func applyCodexClientMetadata(reqBody map[string]any, account *Account) bool { if account == nil { return false } deviceID := strings.TrimSpace(account.GetOpenAIDeviceID()) if deviceID == "" { return false } const key = "x-codex-installation-id" switch existing := reqBody["client_metadata"].(type) { case map[string]any: if v, ok := existing[key].(string); ok && strings.TrimSpace(v) != "" { return false } existing[key] = deviceID reqBody["client_metadata"] = existing return true case map[string]string: if strings.TrimSpace(existing[key]) != "" { return false } next := make(map[string]any, len(existing)+1) for k, v := range existing { next[k] = v } next[key] = deviceID reqBody["client_metadata"] = next return true case nil: reqBody["client_metadata"] = map[string]any{key: deviceID} return true default: return false } } // applyInstructions 处理 instructions 字段:仅在 instructions 为空时填充默认值。 func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool { if !isInstructionsEmpty(reqBody) { return false } model, _ := reqBody["model"].(string) reqBody["instructions"] = defaultCodexSynthInstructions(model) return true } // isInstructionsEmpty 检查 instructions 字段是否为空 // 处理以下情况:字段不存在、nil、空字符串、纯空白字符串 func isInstructionsEmpty(reqBody map[string]any) bool { val, exists := reqBody["instructions"] if !exists { return true } if val == nil { return true } str, ok := val.(string) if !ok { return true } return strings.TrimSpace(str) == "" } type codexInputFilterOptions struct { PreserveReferences bool PreserveCallIDs bool } // filterCodexInput 按需过滤 item_reference 与 id。 // preserveReferences 为 true 时保持引用与 id,以满足续链请求对上下文的依赖。 func filterCodexInput(input []any, preserveReferences bool) []any { return filterCodexInputWithOptions(input, codexInputFilterOptions{ PreserveReferences: preserveReferences, }) } func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []any { filtered := make([]any, 0, len(input)) for _, item := range input { m, ok := item.(map[string]any) if !ok { filtered = append(filtered, item) continue } typ, _ := m["type"].(string) // chatgpt.com codex (OAuth path) runs with store=false (forced by // applyCodexOAuthTransform). Replaying a reasoning item with its rs_* // id but no encrypted_content 404s upstream ("Item with id 'rs_...' // not found") — the 404 is triggered by the id lookup, not by the // reasoning item itself. So strip the id (always, independent of // PreserveReferences) yet keep the item: under store=false // encrypted_content is the official channel for carrying reasoning // context across turns, and dropping the whole item silently degrades // multi-turn agent reasoning. Preserve encrypted_content/content/ // summary and every other field verbatim. Upstream additionally // requires a summary field — a missing one is rejected with 400 // "Missing required parameter 'input[N].summary'" — so backfill an // empty array when it is absent. Contracts verified end-to-end against // chatgpt.com codex (gpt-5.5); see issue #1957. // compaction_summary items (cmp_*) are the other encrypted_content // carrier. Verified against the live backend: they require // encrypted_content (a missing one is rejected with 400), and with it // present the cmp_* id does not 404 whether kept or stripped. Being // neither reasoning nor tool calls, they flow through the generic path // below (id stripped when !PreserveReferences, encrypted_content // preserved either way), which is safe and needs no special-casing. if typ == "reasoning" { newItem := make(map[string]any, len(m)) for key, value := range m { if key == "id" { // rs_* id replayed under store=false 404s; strip it. continue } newItem[key] = value } if summary, ok := newItem["summary"]; !ok || summary == nil { // Upstream requires a summary field; an empty array satisfies it. newItem["summary"] = []any{} } filtered = append(filtered, newItem) continue } // 仅修正真正的 tool/function call 标识,避免误改普通 message/reasoning id; // 若 item_reference 指向 legacy call_* 标识,则仅修正该引用本身。 fixCallIDPrefix := func(id string) string { if opts.PreserveCallIDs { // preserve 模式尽量原样透传客户端 id 以维持 tool_use/tool_result // 配对,但上游对 call_id 有 64 字符硬上限,超长原样透传必然被 // 400 拒绝("Invalid 'input[N].call_id': string too long")。 // 超长时退回确定性压缩:同一逻辑 id 在 function_call 与 // function_call_output 两侧结果一致,配对不受影响。 if len(id) <= codexCallIDMaxLength { return id } return compactCodexCallID(id) } return normalizeCodexCallID(id) } if typ == "item_reference" { if !opts.PreserveReferences { continue } newItem := make(map[string]any, len(m)) for key, value := range m { newItem[key] = value } if id, ok := newItem["id"].(string); ok && strings.HasPrefix(id, "call_") { newItem["id"] = fixCallIDPrefix(id) } filtered = append(filtered, newItem) continue } newItem := m copied := false // 仅在需要修改字段时创建副本,避免直接改写原始输入。 ensureCopy := func() { if copied { return } newItem = make(map[string]any, len(m)) for key, value := range m { newItem[key] = value } copied = true } if isCodexToolCallItemType(typ) { callID, ok := m["call_id"].(string) if !ok || strings.TrimSpace(callID) == "" { if id, ok := m["id"].(string); ok && strings.TrimSpace(id) != "" { callID = id ensureCopy() newItem["call_id"] = callID } } if callID != "" { fixedCallID := fixCallIDPrefix(callID) if fixedCallID != callID { ensureCopy() newItem["call_id"] = fixedCallID } } } if !isCodexToolCallItemType(typ) { ensureCopy() delete(newItem, "call_id") } if codexInputItemRequiresName(typ) { if strings.TrimSpace(firstNonEmptyString(m["name"])) == "" { name := firstNonEmptyString(m["tool_name"]) if name == "" { if function, ok := m["function"].(map[string]any); ok { name = firstNonEmptyString(function["name"]) } } if name == "" { name = "tool" } ensureCopy() newItem["name"] = name } } if !opts.PreserveReferences { ensureCopy() delete(newItem, "id") } else if id, ok := m["id"].(string); ok && shouldStripOpenAIResponsesInputItemID(typ, id) { ensureCopy() delete(newItem, "id") } filtered = append(filtered, newItem) } return filtered } func isCodexToolCallItemType(typ string) bool { switch typ { case "function_call", "tool_call", "local_shell_call", "tool_search_call", "custom_tool_call", "mcp_tool_call", "function_call_output", "mcp_tool_call_output", "custom_tool_call_output", "tool_search_output": return true default: return false } } // isCodexToolCallInputType 仅匹配 call-input 类型(不含 output),这些类型的 // id 必须以 "fc" 开头,上游会校验 "Expected an ID that begins with 'fc'."。 func isCodexToolCallInputType(typ string) bool { switch typ { case "function_call", "tool_call", "local_shell_call", "tool_search_call", "custom_tool_call", "mcp_tool_call": return true default: return false } } func codexInputItemRequiresName(typ string) bool { switch strings.TrimSpace(typ) { case "function_call", "custom_tool_call", "mcp_tool_call": return true default: return false } } func normalizeCodexTools(reqBody map[string]any) bool { rawTools, ok := reqBody["tools"] if !ok || rawTools == nil { return false } tools, ok := rawTools.([]any) if !ok { return false } modified := false validTools := make([]any, 0, len(tools)) for _, tool := range tools { toolMap, ok := tool.(map[string]any) if !ok { // Keep unknown structure as-is to avoid breaking upstream behavior. validTools = append(validTools, tool) continue } toolType, _ := toolMap["type"].(string) toolType = strings.TrimSpace(toolType) if toolType != "function" { validTools = append(validTools, toolMap) continue } // OpenAI Responses-style tools use top-level name/parameters. if name, ok := toolMap["name"].(string); ok && strings.TrimSpace(name) != "" { validTools = append(validTools, toolMap) continue } // ChatCompletions-style tools use {type:"function", function:{...}}. functionValue, hasFunction := toolMap["function"] function, ok := functionValue.(map[string]any) if !hasFunction || functionValue == nil || !ok || function == nil { // Drop invalid function tools. modified = true continue } if _, ok := toolMap["name"]; !ok { if name, ok := function["name"].(string); ok && strings.TrimSpace(name) != "" { toolMap["name"] = name modified = true } } if _, ok := toolMap["description"]; !ok { if desc, ok := function["description"].(string); ok && strings.TrimSpace(desc) != "" { toolMap["description"] = desc modified = true } } if _, ok := toolMap["parameters"]; !ok { if params, ok := function["parameters"]; ok { toolMap["parameters"] = params modified = true } } if _, ok := toolMap["strict"]; !ok { if strict, ok := function["strict"]; ok { toolMap["strict"] = strict modified = true } } validTools = append(validTools, toolMap) } if modified { reqBody["tools"] = validTools } return modified }