| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854 |
- 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-6-astra": "gpt-6-astra",
- "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
- ToolNameReverse map[string]string
- Error error
- }
- type codexOAuthTransformOptions struct {
- IsCodexCLI bool
- IsCompact bool
- SkipDefaultInstructions bool
- PreserveToolCallIDs bool
- OmitPromotedSystemMessagesFromInput bool
- }
- const (
- codexCallIDMaxLength = 64
- codexCallIDPrefix = "fc_"
- )
- func normalizeCodexCallID(id string) string {
- return normalizeCodexCallIDForItemType("function_call", id)
- }
- func normalizeCodexCallIDForItemType(itemType, id string) string {
- prefix := openAIResponsesToolCallIDPrefix(itemType) + "_"
- candidate := id
- switch {
- case id == "":
- return ""
- case strings.HasPrefix(id, strings.TrimSuffix(prefix, "_")):
- case strings.HasPrefix(id, "call_"):
- candidate = prefix + strings.TrimPrefix(id, "call_")
- default:
- candidate = prefix + trimOpenAIResponsesKnownCallIDPrefix(id)
- }
- if len(candidate) <= codexCallIDMaxLength {
- return candidate
- }
- return compactCodexCallIDForItemType(itemType, candidate)
- }
- func compactCodexCallIDForItemType(itemType, id string) string {
- prefix := openAIResponsesToolCallIDPrefix(itemType) + "_"
- digest := sha256.Sum256([]byte("sub2api:codex-call-id:v1:" + id))
- encoded := hex.EncodeToString(digest[:])
- return prefix + encoded[:codexCallIDMaxLength-len(prefix)]
- }
- func trimOpenAIResponsesKnownCallIDPrefix(id string) string {
- for _, prefix := range []string{"fc_", "ctc_", "tsc_"} {
- if strings.HasPrefix(id, prefix) {
- return strings.TrimPrefix(id, prefix)
- }
- }
- return id
- }
- 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{
- "chat_template_kwargs",
- "user",
- "metadata",
- "prompt_cache_retention",
- "safety_identifier",
- "stream_options",
- "truncation",
- "stop_sequences",
- }
- 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{}
- if normalizeOpenAIOAuthResponsesCompatibilityFields(reqBody) {
- result.Modified = true
- }
- // 工具续链需求会影响存储策略与 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
- }
- // Collect aliases only after prompt/functions/function_call compatibility
- // has produced the final Responses protocol nodes. Otherwise references
- // introduced by those migrations can retain the reserved name.
- toolNameReverse, toolNamesChanged, err := aliasOpenAIOAuthReservedToolNames(reqBody)
- if err != nil {
- result.Error = err
- return result
- }
- result.ToolNameReverse = toolNameReverse
- if toolNamesChanged {
- 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
- }
- if choiceType == "allowed_tools" {
- // This is a selection policy, not a declared tool type. Preserve it for
- // upstream validation (including references to input.additional_tools);
- // falling back to auto would silently discard the caller's restriction.
- 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
- }
- imageModel := strings.ToLower(strings.TrimSpace(firstNonEmptyString(toolMap["model"])))
- if strings.HasPrefix(imageModel, "gpt-image-2") {
- if _, ok := toolMap["input_fidelity"]; ok {
- delete(toolMap, "input_fidelity")
- modified = true
- }
- }
- }
- return modified
- }
- func normalizeOpenAIResponseFormatSchemas(reqBody map[string]any) bool {
- if reqBody == nil {
- return false
- }
- modified := false
- normalizeFormat := func(format map[string]any) {
- if format == nil || strings.TrimSpace(firstNonEmptyString(format["type"])) != "json_schema" {
- return
- }
- if schema, ok := format["schema"].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(schema) {
- modified = true
- }
- if jsonSchema, ok := format["json_schema"].(map[string]any); ok {
- if schema, ok := jsonSchema["schema"].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(schema) {
- modified = true
- }
- }
- }
- if text, ok := reqBody["text"].(map[string]any); ok {
- if format, ok := text["format"].(map[string]any); ok {
- normalizeFormat(format)
- }
- }
- if responseFormat, ok := reqBody["response_format"].(map[string]any); ok {
- normalizeFormat(responseFormat)
- }
- return modified
- }
- func normalizeOpenAIResponseJSONSchema(schema map[string]any) bool {
- if schema == nil {
- return false
- }
- modified := false
- for _, key := range []string{"uniqueItems", "minProperties"} {
- if _, exists := schema[key]; exists {
- delete(schema, key)
- modified = true
- }
- }
- if rawType, exists := schema["type"]; !exists || rawType == nil {
- switch {
- case schema["properties"] != nil:
- schema["type"] = "object"
- modified = true
- case schema["items"] != nil:
- schema["type"] = "array"
- modified = true
- }
- }
- if properties, ok := schema["properties"].(map[string]any); ok {
- for _, raw := range properties {
- if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
- modified = true
- }
- }
- }
- switch items := schema["items"].(type) {
- case map[string]any:
- if normalizeOpenAIResponseJSONSchema(items) {
- modified = true
- }
- case []any:
- for _, raw := range items {
- if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
- modified = true
- }
- }
- }
- for _, key := range []string{
- "additionalProperties",
- "additionalItems",
- "contains",
- "not",
- "if",
- "then",
- "else",
- "propertyNames",
- "unevaluatedProperties",
- "unevaluatedItems",
- } {
- if child, ok := schema[key].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
- modified = true
- }
- }
- for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} {
- children, _ := schema[key].([]any)
- for _, raw := range children {
- if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
- modified = true
- }
- }
- }
- for _, key := range []string{"$defs", "definitions", "patternProperties", "dependentSchemas"} {
- children, _ := schema[key].(map[string]any)
- for _, raw := range children {
- if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
- modified = true
- }
- }
- }
- if dependencies, ok := schema["dependencies"].(map[string]any); ok {
- for _, raw := range dependencies {
- if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
- 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
- }
- mainModel := openAIImagesResponsesMainModelValue()
- if imageModel != mainModel {
- modified = true
- }
- reqBody["model"] = mainModel
- return modified
- }
- func normalizeOpenAIModelForUpstream(account *Account, model string) string {
- if account == nil || account.UsesOpenAICodexProtocol() {
- 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 行为;
- // 若内嵌 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 normalizeCodexFilterCallID(itemType, id string, preserve bool) string {
- if preserve && len(id) <= codexCallIDMaxLength {
- return id
- }
- return normalizeCodexCallIDForItemType(itemType, id)
- }
- func codexItemReferenceIDMappings(input []any, preserveCallIDs bool) map[string]string {
- mappings := make(map[string]string)
- ambiguous := make(map[string]struct{})
- for _, rawItem := range input {
- item, ok := rawItem.(map[string]any)
- if !ok {
- continue
- }
- itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
- if !isCodexToolCallItemType(itemType) {
- continue
- }
- rawCallID := strings.TrimSpace(firstNonEmptyString(item["call_id"]))
- if rawCallID == "" {
- continue
- }
- normalized := normalizeCodexFilterCallID(itemType, rawCallID, preserveCallIDs)
- if existing, exists := mappings[rawCallID]; exists && existing != normalized {
- delete(mappings, rawCallID)
- ambiguous[rawCallID] = struct{}{}
- continue
- }
- if _, conflict := ambiguous[rawCallID]; !conflict {
- mappings[rawCallID] = normalized
- }
- }
- return mappings
- }
- func codexInputItemIDs(input []any) map[string]struct{} {
- itemIDs := make(map[string]struct{})
- for _, rawItem := range input {
- item, ok := rawItem.(map[string]any)
- if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) == "item_reference" {
- continue
- }
- itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
- id := strings.TrimSpace(firstNonEmptyString(item["id"]))
- if id != "" && !shouldStripOpenAIResponsesInputItemID(itemType, id) {
- itemIDs[id] = struct{}{}
- }
- }
- return itemIDs
- }
- func codexInputCallIDs(input []any) map[string]struct{} {
- callIDs := make(map[string]struct{})
- for _, rawItem := range input {
- item, ok := rawItem.(map[string]any)
- if !ok || !isCodexToolCallItemType(strings.TrimSpace(firstNonEmptyString(item["type"]))) {
- continue
- }
- if callID := strings.TrimSpace(firstNonEmptyString(item["call_id"])); callID != "" {
- callIDs[callID] = struct{}{}
- }
- }
- return callIDs
- }
- func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []any {
- filtered := make([]any, 0, len(input))
- referenceIDMappings := codexItemReferenceIDMappings(input, opts.PreserveCallIDs)
- inputItemIDs := codexInputItemIDs(input)
- inputCallIDs := codexInputCallIDs(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" || key == "call_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 {
- return normalizeCodexFilterCallID(typ, id, opts.PreserveCallIDs)
- }
- 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(strings.TrimSpace(id), "call_") {
- trimmedID := strings.TrimSpace(id)
- _, referencesExistingItem := inputItemIDs[trimmedID]
- if !referencesExistingItem {
- if normalizedID, mapped := referenceIDMappings[trimmedID]; mapped {
- newItem["id"] = normalizedID
- } else if _, hasSameTurnCall := inputCallIDs[trimmedID]; !hasSameTurnCall {
- // A bare call_* reference is a legacy function-call identifier.
- // Normalize it even when its call item lives in an earlier turn.
- newItem["id"] = normalizeCodexCallID(trimmedID)
- }
- }
- }
- 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
- }
|