Files
sub2api/backend/internal/service/openai_codex_transform.go
李建琦 6d655c9903
Release / update-version (push) Has been cancelled
Release / build-frontend (push) Has been cancelled
Release / release (push) Has been cancelled
Release / sync-version-file (push) Has been cancelled
CI / shell (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / frontend (push) Canceled after 0s
CI / golangci-lint (push) Canceled after 0s
Security Scan / backend-security (push) Canceled after 0s
Security Scan / frontend-security (push) Canceled after 0s
Sub2API v1.0 - AI API 网关(二开初始版本,基于上游 Wei-Shaw/sub2api)
2026-08-21 18:30:13 +08:00

1627 lines
45 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 = "<sub2api-codex-image-generation>"
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</sub2api-codex-image-generation>"
codexSparkImageUnsupportedMarker = "<sub2api-codex-spark-image-unsupported>"
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</sub2api-codex-spark-image-unsupported>"
)
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 instructionscodex 系→GPT-5-Codex
// gpt-5.2→GPT-5.2gpt-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
}