openai_codex_transform.go 51 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059106010611062106310641065106610671068106910701071107210731074107510761077107810791080108110821083108410851086108710881089109010911092109310941095109610971098109911001101110211031104110511061107110811091110111111121113111411151116111711181119112011211122112311241125112611271128112911301131113211331134113511361137113811391140114111421143114411451146114711481149115011511152115311541155115611571158115911601161116211631164116511661167116811691170117111721173117411751176117711781179118011811182118311841185118611871188118911901191119211931194119511961197119811991200120112021203120412051206120712081209121012111212121312141215121612171218121912201221122212231224122512261227122812291230123112321233123412351236123712381239124012411242124312441245124612471248124912501251125212531254125512561257125812591260126112621263126412651266126712681269127012711272127312741275127612771278127912801281128212831284128512861287128812891290129112921293129412951296129712981299130013011302130313041305130613071308130913101311131213131314131513161317131813191320132113221323132413251326132713281329133013311332133313341335133613371338133913401341134213431344134513461347134813491350135113521353135413551356135713581359136013611362136313641365136613671368136913701371137213731374137513761377137813791380138113821383138413851386138713881389139013911392139313941395139613971398139914001401140214031404140514061407140814091410141114121413141414151416141714181419142014211422142314241425142614271428142914301431143214331434143514361437143814391440144114421443144414451446144714481449145014511452145314541455145614571458145914601461146214631464146514661467146814691470147114721473147414751476147714781479148014811482148314841485148614871488148914901491149214931494149514961497149814991500150115021503150415051506150715081509151015111512151315141515151615171518151915201521152215231524152515261527152815291530153115321533153415351536153715381539154015411542154315441545154615471548154915501551155215531554155515561557155815591560156115621563156415651566156715681569157015711572157315741575157615771578157915801581158215831584158515861587158815891590159115921593159415951596159715981599160016011602160316041605160616071608160916101611161216131614161516161617161816191620162116221623162416251626162716281629163016311632163316341635163616371638163916401641164216431644164516461647164816491650165116521653165416551656165716581659166016611662166316641665166616671668166916701671167216731674167516761677167816791680168116821683168416851686168716881689169016911692169316941695169616971698169917001701170217031704170517061707170817091710171117121713171417151716171717181719172017211722172317241725172617271728172917301731173217331734173517361737173817391740174117421743174417451746174717481749175017511752175317541755175617571758175917601761176217631764176517661767176817691770177117721773177417751776177717781779178017811782178317841785178617871788178917901791179217931794179517961797179817991800180118021803180418051806180718081809181018111812181318141815181618171818181918201821182218231824182518261827182818291830183118321833183418351836183718381839184018411842184318441845184618471848184918501851185218531854
  1. package service
  2. import (
  3. "crypto/sha256"
  4. "encoding/hex"
  5. "encoding/json"
  6. "fmt"
  7. "strings"
  8. "github.com/Wei-Shaw/sub2api/internal/pkg/openai"
  9. )
  10. var codexModelMap = map[string]string{
  11. "gpt-6-astra": "gpt-6-astra",
  12. "gpt-5.6-sol": "gpt-5.6-sol",
  13. "gpt-5.6-terra": "gpt-5.6-terra",
  14. "gpt-5.6-luna": "gpt-5.6-luna",
  15. "gpt-5.5": "gpt-5.5",
  16. "gpt-5.5-pro": "gpt-5.5-pro",
  17. "codex-auto-review": "codex-auto-review",
  18. "gpt-5.4": "gpt-5.4",
  19. "gpt-5.4-mini": "gpt-5.4-mini",
  20. "gpt-5.4-none": "gpt-5.4",
  21. "gpt-5.4-low": "gpt-5.4",
  22. "gpt-5.4-medium": "gpt-5.4",
  23. "gpt-5.4-high": "gpt-5.4",
  24. "gpt-5.4-xhigh": "gpt-5.4",
  25. "gpt-5.4-chat-latest": "gpt-5.4",
  26. "gpt-5.3": "gpt-5.3-codex",
  27. "gpt-5.3-none": "gpt-5.3-codex",
  28. "gpt-5.3-low": "gpt-5.3-codex",
  29. "gpt-5.3-medium": "gpt-5.3-codex",
  30. "gpt-5.3-high": "gpt-5.3-codex",
  31. "gpt-5.3-xhigh": "gpt-5.3-codex",
  32. "gpt-5.3-codex": "gpt-5.3-codex",
  33. "gpt-5.3-codex-spark": "gpt-5.3-codex-spark",
  34. "gpt-5.3-codex-low": "gpt-5.3-codex",
  35. "gpt-5.3-codex-medium": "gpt-5.3-codex",
  36. "gpt-5.3-codex-high": "gpt-5.3-codex",
  37. "gpt-5.3-codex-xhigh": "gpt-5.3-codex",
  38. "gpt-5.2": "gpt-5.2",
  39. "gpt-5.2-none": "gpt-5.2",
  40. "gpt-5.2-low": "gpt-5.2",
  41. "gpt-5.2-medium": "gpt-5.2",
  42. "gpt-5.2-high": "gpt-5.2",
  43. "gpt-5.2-xhigh": "gpt-5.2",
  44. "gpt-5": "gpt-5.4",
  45. "gpt-5-mini": "gpt-5.4",
  46. "gpt-5-nano": "gpt-5.4",
  47. "gpt-5.1": "gpt-5.4",
  48. "gpt-5.1-codex": "gpt-5.3-codex",
  49. "gpt-5.1-codex-max": "gpt-5.3-codex",
  50. "gpt-5.1-codex-mini": "gpt-5.3-codex",
  51. "gpt-5.2-codex": "gpt-5.2",
  52. "codex-mini-latest": "gpt-5.3-codex",
  53. "gpt-5-codex": "gpt-5.3-codex",
  54. }
  55. var codexVersionModelPrefixes = []struct {
  56. prefix string
  57. target string
  58. }{
  59. {prefix: "gpt-5.6-sol", target: "gpt-5.6-sol"},
  60. {prefix: "gpt-5.6-terra", target: "gpt-5.6-terra"},
  61. {prefix: "gpt-5.6-luna", target: "gpt-5.6-luna"},
  62. {prefix: "gpt-5.3-codex-spark", target: "gpt-5.3-codex-spark"},
  63. {prefix: "gpt-5.3-codex", target: "gpt-5.3-codex"},
  64. {prefix: "gpt-5.4-mini", target: "gpt-5.4-mini"},
  65. {prefix: "gpt-5.4-nano", target: "gpt-5.4-nano"},
  66. {prefix: "gpt-5.5-pro", target: "gpt-5.5-pro"},
  67. {prefix: "gpt-5.5", target: "gpt-5.5"},
  68. {prefix: "gpt-5.4", target: "gpt-5.4"},
  69. {prefix: "gpt-5.2", target: "gpt-5.2"},
  70. }
  71. type codexTransformResult struct {
  72. Modified bool
  73. NormalizedModel string
  74. PromptCacheKey string
  75. ToolNameReverse map[string]string
  76. Error error
  77. }
  78. type codexOAuthTransformOptions struct {
  79. IsCodexCLI bool
  80. IsCompact bool
  81. SkipDefaultInstructions bool
  82. PreserveToolCallIDs bool
  83. OmitPromotedSystemMessagesFromInput bool
  84. }
  85. const (
  86. codexCallIDMaxLength = 64
  87. codexCallIDPrefix = "fc_"
  88. )
  89. func normalizeCodexCallID(id string) string {
  90. return normalizeCodexCallIDForItemType("function_call", id)
  91. }
  92. func normalizeCodexCallIDForItemType(itemType, id string) string {
  93. prefix := openAIResponsesToolCallIDPrefix(itemType) + "_"
  94. candidate := id
  95. switch {
  96. case id == "":
  97. return ""
  98. case strings.HasPrefix(id, strings.TrimSuffix(prefix, "_")):
  99. case strings.HasPrefix(id, "call_"):
  100. candidate = prefix + strings.TrimPrefix(id, "call_")
  101. default:
  102. candidate = prefix + trimOpenAIResponsesKnownCallIDPrefix(id)
  103. }
  104. if len(candidate) <= codexCallIDMaxLength {
  105. return candidate
  106. }
  107. return compactCodexCallIDForItemType(itemType, candidate)
  108. }
  109. func compactCodexCallIDForItemType(itemType, id string) string {
  110. prefix := openAIResponsesToolCallIDPrefix(itemType) + "_"
  111. digest := sha256.Sum256([]byte("sub2api:codex-call-id:v1:" + id))
  112. encoded := hex.EncodeToString(digest[:])
  113. return prefix + encoded[:codexCallIDMaxLength-len(prefix)]
  114. }
  115. func trimOpenAIResponsesKnownCallIDPrefix(id string) string {
  116. for _, prefix := range []string{"fc_", "ctc_", "tsc_"} {
  117. if strings.HasPrefix(id, prefix) {
  118. return strings.TrimPrefix(id, prefix)
  119. }
  120. }
  121. return id
  122. }
  123. const codexImageGenerationFunctionToolName = "image_gen.imagegen"
  124. const (
  125. codexImageGenerationBridgeMarker = "<sub2api-codex-image-generation>"
  126. 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>"
  127. codexSparkImageUnsupportedMarker = "<sub2api-codex-spark-image-unsupported>"
  128. 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>"
  129. )
  130. var openAIChatGPTInternalUnsupportedFields = []string{
  131. "chat_template_kwargs",
  132. "user",
  133. "metadata",
  134. "prompt_cache_retention",
  135. "safety_identifier",
  136. "stream_options",
  137. "truncation",
  138. "stop_sequences",
  139. }
  140. var openAICodexOAuthUnsupportedFields = append([]string{
  141. "max_output_tokens",
  142. "max_completion_tokens",
  143. "temperature",
  144. "top_p",
  145. "frequency_penalty",
  146. "presence_penalty",
  147. }, openAIChatGPTInternalUnsupportedFields...)
  148. func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact bool) codexTransformResult {
  149. return applyCodexOAuthTransformWithOptions(reqBody, codexOAuthTransformOptions{
  150. IsCodexCLI: isCodexCLI,
  151. IsCompact: isCompact,
  152. })
  153. }
  154. func applyCodexOAuthTransformWithOptions(reqBody map[string]any, opts codexOAuthTransformOptions) codexTransformResult {
  155. result := codexTransformResult{}
  156. if normalizeOpenAIOAuthResponsesCompatibilityFields(reqBody) {
  157. result.Modified = true
  158. }
  159. // 工具续链需求会影响存储策略与 input 过滤逻辑。
  160. needsToolContinuation := NeedsToolContinuation(reqBody)
  161. model := ""
  162. if v, ok := reqBody["model"].(string); ok {
  163. model = v
  164. }
  165. normalizedModel := strings.TrimSpace(model)
  166. if normalizedModel != "" {
  167. if model != normalizedModel {
  168. reqBody["model"] = normalizedModel
  169. result.Modified = true
  170. }
  171. result.NormalizedModel = normalizedModel
  172. }
  173. if opts.IsCompact {
  174. if _, ok := reqBody["store"]; ok {
  175. delete(reqBody, "store")
  176. result.Modified = true
  177. }
  178. if _, ok := reqBody["stream"]; ok {
  179. delete(reqBody, "stream")
  180. result.Modified = true
  181. }
  182. } else {
  183. // OAuth 走 ChatGPT internal API 时,store 必须为 false;显式 true 也会强制覆盖。
  184. // 避免上游返回 "Store must be set to false"。
  185. if v, ok := reqBody["store"].(bool); !ok || v {
  186. reqBody["store"] = false
  187. result.Modified = true
  188. }
  189. if v, ok := reqBody["stream"].(bool); !ok || !v {
  190. reqBody["stream"] = true
  191. result.Modified = true
  192. }
  193. }
  194. // Strip parameters unsupported by ChatGPT internal Codex endpoint.
  195. for _, key := range openAICodexOAuthUnsupportedFields {
  196. if _, ok := reqBody[key]; ok {
  197. delete(reqBody, key)
  198. result.Modified = true
  199. }
  200. }
  201. // 请求带 reasoning 时补齐 include:["reasoning.encrypted_content"],与真实 Codex 对齐
  202. // (compact 端点形态不同,单独处理,此处跳过)。
  203. if !opts.IsCompact && ensureCodexReasoningInclude(reqBody) {
  204. result.Modified = true
  205. }
  206. // 兼容遗留的 functions 和 function_call,转换为 tools 和 tool_choice
  207. if functionsRaw, ok := reqBody["functions"]; ok {
  208. if functions, k := functionsRaw.([]any); k {
  209. tools := make([]any, 0, len(functions))
  210. for _, f := range functions {
  211. tools = append(tools, map[string]any{
  212. "type": "function",
  213. "function": f,
  214. })
  215. }
  216. reqBody["tools"] = tools
  217. }
  218. delete(reqBody, "functions")
  219. result.Modified = true
  220. }
  221. if fcRaw, ok := reqBody["function_call"]; ok {
  222. if fcStr, ok := fcRaw.(string); ok {
  223. // e.g. "auto", "none"
  224. reqBody["tool_choice"] = fcStr
  225. } else if fcObj, ok := fcRaw.(map[string]any); ok {
  226. // e.g. {"name": "my_func"}
  227. if name, ok := fcObj["name"].(string); ok && strings.TrimSpace(name) != "" {
  228. reqBody["tool_choice"] = map[string]any{
  229. "type": "function",
  230. "name": name,
  231. }
  232. }
  233. }
  234. delete(reqBody, "function_call")
  235. result.Modified = true
  236. }
  237. if normalizeCodexTools(reqBody) {
  238. result.Modified = true
  239. }
  240. // Collect aliases only after prompt/functions/function_call compatibility
  241. // has produced the final Responses protocol nodes. Otherwise references
  242. // introduced by those migrations can retain the reserved name.
  243. toolNameReverse, toolNamesChanged, err := aliasOpenAIOAuthReservedToolNames(reqBody)
  244. if err != nil {
  245. result.Error = err
  246. return result
  247. }
  248. result.ToolNameReverse = toolNameReverse
  249. if toolNamesChanged {
  250. result.Modified = true
  251. }
  252. if normalizeCodexToolChoice(reqBody) {
  253. result.Modified = true
  254. }
  255. if v, ok := reqBody["prompt_cache_key"].(string); ok {
  256. result.PromptCacheKey = strings.TrimSpace(v)
  257. if isOpenAICompatMessagesBridgeRequestBody(reqBody) {
  258. delete(reqBody, "prompt_cache_key")
  259. result.Modified = true
  260. }
  261. }
  262. // ChatGPT internal Codex endpoint does not accept role:"system".
  263. // Mirror its text into instructions because Codex OAuth requires it. Some
  264. // callers must also keep the guidance in input as developer (notably
  265. // Responses JSON object mode), while Chat Completions compatibility can
  266. // omit text-only messages after promoting them losslessly.
  267. if extractSystemMessagesFromInput(reqBody, opts.OmitPromotedSystemMessagesFromInput) {
  268. result.Modified = true
  269. }
  270. // instructions 处理逻辑:根据是否是 Codex CLI 分别调用不同方法
  271. if !opts.SkipDefaultInstructions && applyInstructions(reqBody, opts.IsCodexCLI) {
  272. result.Modified = true
  273. }
  274. if isCodexSparkModel(normalizedModel) && applyCodexSparkImageUnsupportedInstructions(reqBody) {
  275. result.Modified = true
  276. }
  277. // gpt-5.3-codex-spark rejects the image_generation tool upstream (HTTP 400,
  278. // param=tools); Codex CLI advertises it by default, so strip it for spark.
  279. if isCodexSparkModel(normalizedModel) && stripCodexSparkImageGenerationTools(reqBody) {
  280. result.Modified = true
  281. }
  282. // 续链场景保留 item_reference 与 id,避免 call_id 上下文丢失。
  283. if input, ok := reqBody["input"].([]any); ok {
  284. if normalizedInput, modified := normalizeCodexToolRoleMessages(input); modified {
  285. input = normalizedInput
  286. result.Modified = true
  287. }
  288. if normalizedInput, modified := normalizeCodexMessageContentText(input); modified {
  289. input = normalizedInput
  290. result.Modified = true
  291. }
  292. input = filterCodexInputWithOptions(input, codexInputFilterOptions{
  293. PreserveReferences: needsToolContinuation,
  294. PreserveCallIDs: opts.PreserveToolCallIDs,
  295. })
  296. reqBody["input"] = input
  297. result.Modified = true
  298. } else if inputStr, ok := reqBody["input"].(string); ok {
  299. // ChatGPT codex endpoint requires input to be a list, not a string.
  300. // Convert string input to the expected message array format.
  301. trimmed := strings.TrimSpace(inputStr)
  302. if trimmed != "" {
  303. reqBody["input"] = []any{
  304. map[string]any{
  305. "type": "message",
  306. "role": "user",
  307. "content": inputStr,
  308. },
  309. }
  310. } else {
  311. reqBody["input"] = []any{}
  312. }
  313. result.Modified = true
  314. }
  315. return result
  316. }
  317. func normalizeCodexToolChoice(reqBody map[string]any) bool {
  318. choice, ok := reqBody["tool_choice"]
  319. if !ok || choice == nil {
  320. return false
  321. }
  322. choiceMap, ok := choice.(map[string]any)
  323. if !ok {
  324. return false
  325. }
  326. choiceType := strings.TrimSpace(firstNonEmptyString(choiceMap["type"]))
  327. if choiceType == "" {
  328. return false
  329. }
  330. if choiceType == "allowed_tools" {
  331. // This is a selection policy, not a declared tool type. Preserve it for
  332. // upstream validation (including references to input.additional_tools);
  333. // falling back to auto would silently discard the caller's restriction.
  334. return false
  335. }
  336. modified := false
  337. if choiceType == "function" {
  338. name := strings.TrimSpace(firstNonEmptyString(choiceMap["name"]))
  339. if name == "" {
  340. if function, ok := choiceMap["function"].(map[string]any); ok {
  341. name = strings.TrimSpace(firstNonEmptyString(function["name"]))
  342. }
  343. }
  344. if name == "" {
  345. reqBody["tool_choice"] = "auto"
  346. return true
  347. }
  348. if strings.TrimSpace(firstNonEmptyString(choiceMap["name"])) != name {
  349. choiceMap["name"] = name
  350. modified = true
  351. }
  352. if _, ok := choiceMap["function"]; ok {
  353. delete(choiceMap, "function")
  354. modified = true
  355. }
  356. if !codexToolsContainFunctionName(reqBody["tools"], name) {
  357. reqBody["tool_choice"] = "auto"
  358. return true
  359. }
  360. return modified
  361. }
  362. if codexToolsContainType(reqBody["tools"], choiceType) || codexInputAdditionalToolsContainType(reqBody["input"], choiceType) {
  363. return modified
  364. }
  365. reqBody["tool_choice"] = "auto"
  366. return true
  367. }
  368. func codexInputAdditionalToolsContainType(rawInput any, toolType string) bool {
  369. input, ok := rawInput.([]any)
  370. if !ok || strings.TrimSpace(toolType) == "" {
  371. return false
  372. }
  373. for _, rawItem := range input {
  374. item, ok := rawItem.(map[string]any)
  375. if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" {
  376. continue
  377. }
  378. if codexToolsContainType(item["tools"], toolType) {
  379. return true
  380. }
  381. }
  382. return false
  383. }
  384. func codexToolsContainType(rawTools any, toolType string) bool {
  385. tools, ok := rawTools.([]any)
  386. if !ok || strings.TrimSpace(toolType) == "" {
  387. return false
  388. }
  389. for _, rawTool := range tools {
  390. tool, ok := rawTool.(map[string]any)
  391. if !ok {
  392. continue
  393. }
  394. if strings.TrimSpace(firstNonEmptyString(tool["type"])) == toolType {
  395. return true
  396. }
  397. }
  398. return false
  399. }
  400. func codexToolsContainFunctionName(rawTools any, name string) bool {
  401. tools, ok := rawTools.([]any)
  402. if !ok || strings.TrimSpace(name) == "" {
  403. return false
  404. }
  405. normalizedName := strings.TrimSpace(name)
  406. for _, rawTool := range tools {
  407. tool, ok := rawTool.(map[string]any)
  408. if !ok {
  409. continue
  410. }
  411. if strings.TrimSpace(firstNonEmptyString(tool["type"])) != "function" {
  412. continue
  413. }
  414. toolName := strings.TrimSpace(firstNonEmptyString(tool["name"]))
  415. if toolName == "" {
  416. if function, ok := tool["function"].(map[string]any); ok {
  417. toolName = strings.TrimSpace(firstNonEmptyString(function["name"]))
  418. }
  419. }
  420. if toolName == normalizedName {
  421. return true
  422. }
  423. }
  424. return false
  425. }
  426. func normalizeCodexToolRoleMessages(input []any) ([]any, bool) {
  427. if len(input) == 0 {
  428. return input, false
  429. }
  430. modified := false
  431. normalized := make([]any, 0, len(input))
  432. for _, item := range input {
  433. m, ok := item.(map[string]any)
  434. if !ok {
  435. normalized = append(normalized, item)
  436. continue
  437. }
  438. role, _ := m["role"].(string)
  439. if strings.TrimSpace(role) != "tool" {
  440. normalized = append(normalized, item)
  441. continue
  442. }
  443. callID := firstNonEmptyString(m["call_id"], m["tool_call_id"], m["id"])
  444. callID = strings.TrimSpace(callID)
  445. if callID == "" {
  446. // Responses does not accept role:"tool". If no call id is available,
  447. // preserve the text as a user message instead of sending invalid input.
  448. fallback := make(map[string]any, len(m))
  449. for key, value := range m {
  450. fallback[key] = value
  451. }
  452. fallback["role"] = "user"
  453. delete(fallback, "tool_call_id")
  454. normalized = append(normalized, fallback)
  455. modified = true
  456. continue
  457. }
  458. output := extractTextFromContent(m["content"])
  459. if output == "" {
  460. if value, ok := m["output"].(string); ok {
  461. output = value
  462. }
  463. }
  464. if output == "" && m["content"] != nil {
  465. if b, err := json.Marshal(m["content"]); err == nil {
  466. output = string(b)
  467. }
  468. }
  469. normalized = append(normalized, map[string]any{
  470. "type": "function_call_output",
  471. "call_id": callID,
  472. "output": output,
  473. })
  474. modified = true
  475. }
  476. if !modified {
  477. return input, false
  478. }
  479. return normalized, true
  480. }
  481. func normalizeCodexMessageContentText(input []any) ([]any, bool) {
  482. if len(input) == 0 {
  483. return input, false
  484. }
  485. modified := false
  486. normalized := make([]any, 0, len(input))
  487. for _, item := range input {
  488. m, ok := item.(map[string]any)
  489. if !ok || strings.TrimSpace(firstNonEmptyString(m["type"])) != "message" {
  490. normalized = append(normalized, item)
  491. continue
  492. }
  493. parts, ok := m["content"].([]any)
  494. if !ok {
  495. normalized = append(normalized, item)
  496. continue
  497. }
  498. var newItem map[string]any
  499. var newParts []any
  500. ensureItemCopy := func() {
  501. if newItem != nil {
  502. return
  503. }
  504. newItem = make(map[string]any, len(m))
  505. for key, value := range m {
  506. newItem[key] = value
  507. }
  508. newParts = make([]any, len(parts))
  509. copy(newParts, parts)
  510. }
  511. for i, rawPart := range parts {
  512. part, ok := rawPart.(map[string]any)
  513. if !ok {
  514. continue
  515. }
  516. text, hasText := part["text"]
  517. if !hasText {
  518. continue
  519. }
  520. if _, ok := text.(string); ok {
  521. continue
  522. }
  523. ensureItemCopy()
  524. newPart := make(map[string]any, len(part))
  525. for key, value := range part {
  526. newPart[key] = value
  527. }
  528. newPart["text"] = stringifyCodexContentText(text)
  529. newParts[i] = newPart
  530. modified = true
  531. }
  532. if newItem != nil {
  533. newItem["content"] = newParts
  534. normalized = append(normalized, newItem)
  535. continue
  536. }
  537. normalized = append(normalized, item)
  538. }
  539. if !modified {
  540. return input, false
  541. }
  542. return normalized, true
  543. }
  544. func stringifyCodexContentText(value any) string {
  545. switch v := value.(type) {
  546. case string:
  547. return v
  548. case nil:
  549. return ""
  550. default:
  551. if b, err := json.Marshal(v); err == nil {
  552. return string(b)
  553. }
  554. return fmt.Sprint(v)
  555. }
  556. }
  557. func normalizeCodexModel(model string) string {
  558. model = strings.TrimSpace(model)
  559. if model == "" {
  560. return "gpt-5.4"
  561. }
  562. if mapped, ok := normalizeKnownCodexModel(model); ok {
  563. return mapped
  564. }
  565. return model
  566. }
  567. func normalizeKnownCodexModel(model string) (string, bool) {
  568. model = strings.TrimSpace(model)
  569. if model == "" {
  570. return "", false
  571. }
  572. if isOpenAIImageGenerationModel(model) {
  573. return model, true
  574. }
  575. modelID := lastOpenAIModelSegment(model)
  576. if normalized := canonicalizeOpenAIModelAliasSpelling(modelID); normalized != "" {
  577. modelID = normalized
  578. }
  579. if mapped := normalizeKnownOpenAICodexModel(modelID); mapped != "" {
  580. return mapped, true
  581. }
  582. key := codexModelLookupKey(modelID)
  583. if key == "" {
  584. return "", false
  585. }
  586. if mapped := getNormalizedCodexModel(key); mapped != "" {
  587. return mapped, true
  588. }
  589. for _, item := range codexVersionModelPrefixes {
  590. if key == item.prefix {
  591. return item.target, true
  592. }
  593. suffix, ok := strings.CutPrefix(key, item.prefix+"-")
  594. if ok && isKnownCodexModelSuffix(suffix) {
  595. return item.target, true
  596. }
  597. }
  598. return "", false
  599. }
  600. func codexModelLookupKey(modelID string) string {
  601. modelID = strings.TrimSpace(modelID)
  602. if modelID == "" {
  603. return ""
  604. }
  605. if strings.Contains(modelID, "/") {
  606. parts := strings.Split(modelID, "/")
  607. modelID = parts[len(parts)-1]
  608. }
  609. return strings.ToLower(strings.Join(strings.Fields(modelID), "-"))
  610. }
  611. func isKnownCodexModelSuffix(suffix string) bool {
  612. switch suffix {
  613. case "none", "minimal", "low", "medium", "high", "xhigh":
  614. return true
  615. }
  616. return isCodexDateSuffix(suffix)
  617. }
  618. func isCodexDateSuffix(suffix string) bool {
  619. parts := strings.Split(suffix, "-")
  620. if len(parts) != 3 || len(parts[0]) != 4 || len(parts[1]) != 2 || len(parts[2]) != 2 {
  621. return false
  622. }
  623. for _, part := range parts {
  624. for _, r := range part {
  625. if r < '0' || r > '9' {
  626. return false
  627. }
  628. }
  629. }
  630. return true
  631. }
  632. func isCodexSparkModel(model string) bool {
  633. return normalizeCodexModel(model) == "gpt-5.3-codex-spark"
  634. }
  635. func hasOpenAIImageGenerationTool(reqBody map[string]any) bool {
  636. if toolsContainImageGeneration(reqBody["tools"]) {
  637. return true
  638. }
  639. return inputContainsImageGenerationTool(reqBody["input"])
  640. }
  641. func hasCodexImageGenerationFunctionTool(reqBody map[string]any) bool {
  642. return len(reqBody) > 0 &&
  643. codexToolsContainFunctionName(reqBody["tools"], codexImageGenerationFunctionToolName)
  644. }
  645. func toolsContainImageGeneration(rawTools any) bool {
  646. if rawTools == nil {
  647. return false
  648. }
  649. tools, ok := rawTools.([]any)
  650. if !ok {
  651. return false
  652. }
  653. for _, rawTool := range tools {
  654. toolMap, ok := rawTool.(map[string]any)
  655. if !ok {
  656. continue
  657. }
  658. if isOpenAIImageGenerationToolMap(toolMap) {
  659. return true
  660. }
  661. }
  662. return false
  663. }
  664. func isOpenAIImageGenerationToolMap(tool map[string]any) bool {
  665. return isOpenAIImageGenerationType(firstNonEmptyString(tool["type"])) ||
  666. isImageGenNamespaceToolMap(tool)
  667. }
  668. func isImageGenNamespaceToolMap(tool map[string]any) bool {
  669. return strings.TrimSpace(firstNonEmptyString(tool["type"])) == "namespace" &&
  670. isOpenAIImageGenNamespaceName(firstNonEmptyString(tool["name"]))
  671. }
  672. func inputContainsImageGenerationTool(rawInput any) bool {
  673. input, ok := rawInput.([]any)
  674. if !ok {
  675. return false
  676. }
  677. for _, rawItem := range input {
  678. item, ok := rawItem.(map[string]any)
  679. if !ok {
  680. continue
  681. }
  682. if strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" {
  683. continue
  684. }
  685. if toolsContainImageGeneration(item["tools"]) {
  686. return true
  687. }
  688. }
  689. return false
  690. }
  691. // stripOpenAIImageGenerationTools keeps account-level strip policy symmetric
  692. // across standard Responses tools, Responses Lite additional_tools, and tool_choice.
  693. func stripOpenAIImageGenerationTools(reqBody map[string]any) bool {
  694. if reqBody == nil {
  695. return false
  696. }
  697. modified := stripOpenAIImageGenerationToolList(reqBody, "tools")
  698. if stripOpenAIImageGenerationToolsFromInput(reqBody) {
  699. modified = true
  700. }
  701. if openAIAnyToolChoiceSelectsImageGeneration(reqBody["tool_choice"]) {
  702. delete(reqBody, "tool_choice")
  703. modified = true
  704. }
  705. return modified
  706. }
  707. func stripOpenAIImageGenerationToolList(container map[string]any, key string) bool {
  708. rawTools, ok := container[key]
  709. if !ok || rawTools == nil {
  710. return false
  711. }
  712. tools, ok := rawTools.([]any)
  713. if !ok {
  714. return false
  715. }
  716. filtered := make([]any, 0, len(tools))
  717. removed := false
  718. for _, rawTool := range tools {
  719. if toolMap, ok := rawTool.(map[string]any); ok && isOpenAIImageGenerationToolMap(toolMap) {
  720. removed = true
  721. continue
  722. }
  723. filtered = append(filtered, rawTool)
  724. }
  725. if !removed {
  726. return false
  727. }
  728. if len(filtered) == 0 {
  729. delete(container, key)
  730. } else {
  731. container[key] = filtered
  732. }
  733. return true
  734. }
  735. func stripOpenAIImageGenerationToolsFromInput(reqBody map[string]any) bool {
  736. input, ok := reqBody["input"].([]any)
  737. if !ok {
  738. return false
  739. }
  740. filteredInput := make([]any, 0, len(input))
  741. modified := false
  742. for _, rawItem := range input {
  743. item, ok := rawItem.(map[string]any)
  744. if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) != "additional_tools" {
  745. filteredInput = append(filteredInput, rawItem)
  746. continue
  747. }
  748. if !stripOpenAIImageGenerationToolList(item, "tools") {
  749. filteredInput = append(filteredInput, rawItem)
  750. continue
  751. }
  752. modified = true
  753. if _, hasTools := item["tools"]; hasTools {
  754. filteredInput = append(filteredInput, rawItem)
  755. }
  756. // An empty additional_tools carrier is not useful upstream; drop the item
  757. // after its only declared capability has been removed.
  758. }
  759. if modified {
  760. reqBody["input"] = filteredInput
  761. }
  762. return modified
  763. }
  764. // stripOpenAIImageGenerationToolsFromRawPayload is the shared adapter for paths
  765. // that forward raw HTTP or WebSocket payloads without the normal request map.
  766. func stripOpenAIImageGenerationToolsFromRawPayload(payload []byte) ([]byte, bool, error) {
  767. if !openAIRequestBodyHasImageGenerationDeclaration(payload) {
  768. if json.Valid(payload) {
  769. return payload, false, nil
  770. }
  771. var invalidPayload map[string]any
  772. return payload, false, json.Unmarshal(payload, &invalidPayload)
  773. }
  774. payloadMap := make(map[string]any)
  775. if err := json.Unmarshal(payload, &payloadMap); err != nil {
  776. return payload, false, err
  777. }
  778. if !stripOpenAIImageGenerationTools(payloadMap) {
  779. return payload, false, nil
  780. }
  781. rebuilt, err := json.Marshal(payloadMap)
  782. if err != nil {
  783. return payload, false, err
  784. }
  785. return rebuilt, true, nil
  786. }
  787. // stripCodexSparkImageGenerationTools removes image tool declarations and choices.
  788. // gpt-5.3-codex-spark rejects those capabilities upstream, while Codex clients may
  789. // advertise them by default.
  790. func stripCodexSparkImageGenerationTools(reqBody map[string]any) bool {
  791. return stripOpenAIImageGenerationTools(reqBody)
  792. }
  793. func hasOpenAIInputImage(reqBody map[string]any) bool {
  794. if reqBody == nil {
  795. return false
  796. }
  797. return hasOpenAIInputImageValue(reqBody["input"]) || hasOpenAIInputImageValue(reqBody["messages"])
  798. }
  799. func hasOpenAIInputImageValue(value any) bool {
  800. switch v := value.(type) {
  801. case []any:
  802. for _, item := range v {
  803. if hasOpenAIInputImageValue(item) {
  804. return true
  805. }
  806. }
  807. case map[string]any:
  808. if strings.TrimSpace(firstNonEmptyString(v["type"])) == "input_image" {
  809. return true
  810. }
  811. if _, ok := v["image_url"]; ok {
  812. return true
  813. }
  814. return hasOpenAIInputImageValue(v["content"])
  815. }
  816. return false
  817. }
  818. func validateCodexSparkInput(reqBody map[string]any, model string) error {
  819. if !isCodexSparkModel(model) || !hasOpenAIInputImage(reqBody) {
  820. return nil
  821. }
  822. return fmt.Errorf("model %q does not support image input", strings.TrimSpace(model))
  823. }
  824. func normalizeOpenAIResponsesImageGenerationTools(reqBody map[string]any) bool {
  825. rawTools, ok := reqBody["tools"]
  826. if !ok || rawTools == nil {
  827. return false
  828. }
  829. tools, ok := rawTools.([]any)
  830. if !ok {
  831. return false
  832. }
  833. modified := false
  834. for _, rawTool := range tools {
  835. toolMap, ok := rawTool.(map[string]any)
  836. if !ok || strings.TrimSpace(firstNonEmptyString(toolMap["type"])) != "image_generation" {
  837. continue
  838. }
  839. if _, ok := toolMap["output_format"]; !ok {
  840. if value := strings.TrimSpace(firstNonEmptyString(toolMap["format"])); value != "" {
  841. toolMap["output_format"] = value
  842. modified = true
  843. }
  844. }
  845. if _, ok := toolMap["output_compression"]; !ok {
  846. if value, exists := toolMap["compression"]; exists && value != nil {
  847. toolMap["output_compression"] = value
  848. modified = true
  849. }
  850. }
  851. if _, ok := toolMap["format"]; ok {
  852. delete(toolMap, "format")
  853. modified = true
  854. }
  855. if _, ok := toolMap["compression"]; ok {
  856. delete(toolMap, "compression")
  857. modified = true
  858. }
  859. imageModel := strings.ToLower(strings.TrimSpace(firstNonEmptyString(toolMap["model"])))
  860. if strings.HasPrefix(imageModel, "gpt-image-2") {
  861. if _, ok := toolMap["input_fidelity"]; ok {
  862. delete(toolMap, "input_fidelity")
  863. modified = true
  864. }
  865. }
  866. }
  867. return modified
  868. }
  869. func normalizeOpenAIResponseFormatSchemas(reqBody map[string]any) bool {
  870. if reqBody == nil {
  871. return false
  872. }
  873. modified := false
  874. normalizeFormat := func(format map[string]any) {
  875. if format == nil || strings.TrimSpace(firstNonEmptyString(format["type"])) != "json_schema" {
  876. return
  877. }
  878. if schema, ok := format["schema"].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(schema) {
  879. modified = true
  880. }
  881. if jsonSchema, ok := format["json_schema"].(map[string]any); ok {
  882. if schema, ok := jsonSchema["schema"].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(schema) {
  883. modified = true
  884. }
  885. }
  886. }
  887. if text, ok := reqBody["text"].(map[string]any); ok {
  888. if format, ok := text["format"].(map[string]any); ok {
  889. normalizeFormat(format)
  890. }
  891. }
  892. if responseFormat, ok := reqBody["response_format"].(map[string]any); ok {
  893. normalizeFormat(responseFormat)
  894. }
  895. return modified
  896. }
  897. func normalizeOpenAIResponseJSONSchema(schema map[string]any) bool {
  898. if schema == nil {
  899. return false
  900. }
  901. modified := false
  902. for _, key := range []string{"uniqueItems", "minProperties"} {
  903. if _, exists := schema[key]; exists {
  904. delete(schema, key)
  905. modified = true
  906. }
  907. }
  908. if rawType, exists := schema["type"]; !exists || rawType == nil {
  909. switch {
  910. case schema["properties"] != nil:
  911. schema["type"] = "object"
  912. modified = true
  913. case schema["items"] != nil:
  914. schema["type"] = "array"
  915. modified = true
  916. }
  917. }
  918. if properties, ok := schema["properties"].(map[string]any); ok {
  919. for _, raw := range properties {
  920. if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
  921. modified = true
  922. }
  923. }
  924. }
  925. switch items := schema["items"].(type) {
  926. case map[string]any:
  927. if normalizeOpenAIResponseJSONSchema(items) {
  928. modified = true
  929. }
  930. case []any:
  931. for _, raw := range items {
  932. if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
  933. modified = true
  934. }
  935. }
  936. }
  937. for _, key := range []string{
  938. "additionalProperties",
  939. "additionalItems",
  940. "contains",
  941. "not",
  942. "if",
  943. "then",
  944. "else",
  945. "propertyNames",
  946. "unevaluatedProperties",
  947. "unevaluatedItems",
  948. } {
  949. if child, ok := schema[key].(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
  950. modified = true
  951. }
  952. }
  953. for _, key := range []string{"anyOf", "oneOf", "allOf", "prefixItems"} {
  954. children, _ := schema[key].([]any)
  955. for _, raw := range children {
  956. if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
  957. modified = true
  958. }
  959. }
  960. }
  961. for _, key := range []string{"$defs", "definitions", "patternProperties", "dependentSchemas"} {
  962. children, _ := schema[key].(map[string]any)
  963. for _, raw := range children {
  964. if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
  965. modified = true
  966. }
  967. }
  968. }
  969. if dependencies, ok := schema["dependencies"].(map[string]any); ok {
  970. for _, raw := range dependencies {
  971. if child, ok := raw.(map[string]any); ok && normalizeOpenAIResponseJSONSchema(child) {
  972. modified = true
  973. }
  974. }
  975. }
  976. return modified
  977. }
  978. func ensureOpenAIResponsesImageGenerationTool(reqBody map[string]any) bool {
  979. if len(reqBody) == 0 {
  980. return false
  981. }
  982. if isCodexSparkModel(firstNonEmptyString(reqBody["model"])) {
  983. return false
  984. }
  985. if hasCodexImageGenerationFunctionTool(reqBody) {
  986. return false
  987. }
  988. if hasOpenAIImageGenerationTool(reqBody) {
  989. return false
  990. }
  991. tool := map[string]any{
  992. "type": "image_generation",
  993. "output_format": "png",
  994. }
  995. rawTools, ok := reqBody["tools"]
  996. if !ok || rawTools == nil {
  997. reqBody["tools"] = []any{tool}
  998. return true
  999. }
  1000. tools, ok := rawTools.([]any)
  1001. if !ok {
  1002. reqBody["tools"] = []any{tool}
  1003. return true
  1004. }
  1005. reqBody["tools"] = append(tools, tool)
  1006. return true
  1007. }
  1008. func ensureOpenAIResponsesImageGenerationToolChoiceAuto(reqBody map[string]any) bool {
  1009. if len(reqBody) == 0 || hasCodexImageGenerationFunctionTool(reqBody) || !hasOpenAIImageGenerationTool(reqBody) {
  1010. return false
  1011. }
  1012. if isCodexSparkModel(firstNonEmptyString(reqBody["model"])) {
  1013. return false
  1014. }
  1015. if _, ok := reqBody["tool_choice"]; ok {
  1016. return false
  1017. }
  1018. reqBody["tool_choice"] = "auto"
  1019. return true
  1020. }
  1021. func applyCodexImageGenerationBridgeInstructions(reqBody map[string]any) bool {
  1022. if len(reqBody) == 0 || hasCodexImageGenerationFunctionTool(reqBody) || !hasOpenAIImageGenerationTool(reqBody) {
  1023. return false
  1024. }
  1025. if isCodexSparkModel(firstNonEmptyString(reqBody["model"])) {
  1026. return false
  1027. }
  1028. existing, _ := reqBody["instructions"].(string)
  1029. if strings.Contains(existing, codexImageGenerationBridgeMarker) {
  1030. return false
  1031. }
  1032. existing = strings.TrimRight(existing, " \t\r\n")
  1033. if strings.TrimSpace(existing) == "" {
  1034. reqBody["instructions"] = codexImageGenerationBridgeText
  1035. return true
  1036. }
  1037. reqBody["instructions"] = existing + "\n\n" + codexImageGenerationBridgeText
  1038. return true
  1039. }
  1040. func applyCodexSparkImageUnsupportedInstructions(reqBody map[string]any) bool {
  1041. if len(reqBody) == 0 {
  1042. return false
  1043. }
  1044. existing, _ := reqBody["instructions"].(string)
  1045. if strings.Contains(existing, codexSparkImageUnsupportedMarker) {
  1046. return false
  1047. }
  1048. existing = strings.TrimRight(existing, " \t\r\n")
  1049. if strings.TrimSpace(existing) == "" {
  1050. reqBody["instructions"] = codexSparkImageUnsupportedText
  1051. return true
  1052. }
  1053. reqBody["instructions"] = existing + "\n\n" + codexSparkImageUnsupportedText
  1054. return true
  1055. }
  1056. func validateOpenAIResponsesImageModel(reqBody map[string]any, model string) error {
  1057. if !hasOpenAIImageGenerationTool(reqBody) {
  1058. return nil
  1059. }
  1060. model = strings.TrimSpace(model)
  1061. if !isOpenAIImageGenerationModel(model) {
  1062. return nil
  1063. }
  1064. return fmt.Errorf("/v1/responses image_generation requests require a Responses-capable text model; image-only model %q is not allowed", model)
  1065. }
  1066. func normalizeOpenAIResponsesImageOnlyModel(reqBody map[string]any) bool {
  1067. if len(reqBody) == 0 {
  1068. return false
  1069. }
  1070. imageModel := strings.TrimSpace(firstNonEmptyString(reqBody["model"]))
  1071. if !isOpenAIImageGenerationModel(imageModel) {
  1072. return false
  1073. }
  1074. modified := false
  1075. tools, _ := reqBody["tools"].([]any)
  1076. imageToolIndex := -1
  1077. for i, rawTool := range tools {
  1078. toolMap, ok := rawTool.(map[string]any)
  1079. if !ok {
  1080. continue
  1081. }
  1082. if strings.TrimSpace(firstNonEmptyString(toolMap["type"])) == "image_generation" {
  1083. imageToolIndex = i
  1084. break
  1085. }
  1086. }
  1087. if imageToolIndex < 0 {
  1088. tools = append(tools, map[string]any{
  1089. "type": "image_generation",
  1090. "model": imageModel,
  1091. })
  1092. imageToolIndex = len(tools) - 1
  1093. reqBody["tools"] = tools
  1094. modified = true
  1095. }
  1096. if toolMap, ok := tools[imageToolIndex].(map[string]any); ok {
  1097. if strings.TrimSpace(firstNonEmptyString(toolMap["model"])) == "" {
  1098. toolMap["model"] = imageModel
  1099. modified = true
  1100. }
  1101. for _, key := range []string{
  1102. "size",
  1103. "quality",
  1104. "background",
  1105. "output_format",
  1106. "output_compression",
  1107. "moderation",
  1108. "style",
  1109. "partial_images",
  1110. } {
  1111. if value, exists := reqBody[key]; exists && value != nil {
  1112. if _, toolHas := toolMap[key]; !toolHas {
  1113. toolMap[key] = value
  1114. }
  1115. delete(reqBody, key)
  1116. modified = true
  1117. }
  1118. }
  1119. }
  1120. if prompt := strings.TrimSpace(firstNonEmptyString(reqBody["prompt"])); prompt != "" {
  1121. if _, hasInput := reqBody["input"]; !hasInput {
  1122. reqBody["input"] = prompt
  1123. }
  1124. delete(reqBody, "prompt")
  1125. modified = true
  1126. }
  1127. if _, ok := reqBody["tool_choice"]; !ok {
  1128. reqBody["tool_choice"] = map[string]any{"type": "image_generation"}
  1129. modified = true
  1130. }
  1131. mainModel := openAIImagesResponsesMainModelValue()
  1132. if imageModel != mainModel {
  1133. modified = true
  1134. }
  1135. reqBody["model"] = mainModel
  1136. return modified
  1137. }
  1138. func normalizeOpenAIModelForUpstream(account *Account, model string) string {
  1139. if account == nil || account.UsesOpenAICodexProtocol() {
  1140. return normalizeCodexModel(model)
  1141. }
  1142. return strings.TrimSpace(model)
  1143. }
  1144. func SupportsVerbosity(model string) bool {
  1145. if !strings.HasPrefix(model, "gpt-") {
  1146. return true
  1147. }
  1148. var major, minor int
  1149. n, _ := fmt.Sscanf(model, "gpt-%d.%d", &major, &minor)
  1150. if major > 5 {
  1151. return true
  1152. }
  1153. if major < 5 {
  1154. return false
  1155. }
  1156. // gpt-5
  1157. if n == 1 {
  1158. return true
  1159. }
  1160. return minor >= 3
  1161. }
  1162. func getNormalizedCodexModel(modelID string) string {
  1163. key := codexModelLookupKey(modelID)
  1164. if key == "" {
  1165. return ""
  1166. }
  1167. if mapped, ok := codexModelMap[key]; ok {
  1168. return mapped
  1169. }
  1170. return ""
  1171. }
  1172. // extractTextFromContent extracts plain text from a content value that is either
  1173. // a Go string or a []any of text-like content-part maps.
  1174. func extractTextFromContent(content any) string {
  1175. switch v := content.(type) {
  1176. case string:
  1177. return v
  1178. case []any:
  1179. var parts []string
  1180. for _, part := range v {
  1181. m, ok := part.(map[string]any)
  1182. if !ok {
  1183. continue
  1184. }
  1185. switch t, _ := m["type"].(string); t {
  1186. case "text", "input_text", "output_text":
  1187. if text, ok := m["text"].(string); ok {
  1188. parts = append(parts, text)
  1189. }
  1190. }
  1191. }
  1192. return strings.Join(parts, "")
  1193. default:
  1194. return ""
  1195. }
  1196. }
  1197. // extractSystemMessagesFromInput scans input for role=="system" and mirrors
  1198. // their text into reqBody["instructions"]. By default it maps those items to
  1199. // developer so Responses JSON mode can still see JSON instructions in input.
  1200. // When omitPromoted is true, text-only items are removed after their content is
  1201. // losslessly promoted; mixed or malformed content is retained as developer.
  1202. func extractSystemMessagesFromInput(reqBody map[string]any, omitPromoted bool) bool {
  1203. input, ok := reqBody["input"].([]any)
  1204. if !ok || len(input) == 0 {
  1205. return false
  1206. }
  1207. var systemTexts []string
  1208. filteredInput := make([]any, 0, len(input))
  1209. modified := false
  1210. for _, item := range input {
  1211. m, ok := item.(map[string]any)
  1212. if !ok || m["role"] != "system" {
  1213. filteredInput = append(filteredInput, item)
  1214. continue
  1215. }
  1216. if omitPromoted {
  1217. if losslessText, lossless := extractLosslessTextFromContent(m["content"]); lossless {
  1218. if losslessText != "" {
  1219. systemTexts = append(systemTexts, losslessText)
  1220. }
  1221. modified = true
  1222. continue
  1223. }
  1224. }
  1225. if text := extractTextFromContent(m["content"]); text != "" {
  1226. systemTexts = append(systemTexts, text)
  1227. }
  1228. m["role"] = "developer"
  1229. filteredInput = append(filteredInput, item)
  1230. modified = true
  1231. }
  1232. if omitPromoted && len(filteredInput) != len(input) {
  1233. reqBody["input"] = filteredInput
  1234. }
  1235. if len(systemTexts) == 0 {
  1236. return modified
  1237. }
  1238. extracted := strings.Join(systemTexts, "\n\n")
  1239. if existing, ok := reqBody["instructions"].(string); ok && strings.TrimSpace(existing) != "" {
  1240. reqBody["instructions"] = extracted + "\n\n" + existing
  1241. } else {
  1242. reqBody["instructions"] = extracted
  1243. }
  1244. return true
  1245. }
  1246. // extractLosslessTextFromContent returns text only when the entire content can
  1247. // be represented by an instructions string without dropping non-text parts.
  1248. func extractLosslessTextFromContent(content any) (string, bool) {
  1249. switch v := content.(type) {
  1250. case string:
  1251. return v, true
  1252. case []any:
  1253. var b strings.Builder
  1254. for _, part := range v {
  1255. m, ok := part.(map[string]any)
  1256. if !ok {
  1257. return "", false
  1258. }
  1259. typeName, ok := m["type"].(string)
  1260. if !ok || (typeName != "text" && typeName != "input_text" && typeName != "output_text") {
  1261. return "", false
  1262. }
  1263. text, ok := m["text"].(string)
  1264. if !ok {
  1265. return "", false
  1266. }
  1267. _, _ = b.WriteString(text)
  1268. }
  1269. return b.String(), true
  1270. default:
  1271. return "", false
  1272. }
  1273. }
  1274. func extractPromptLikeInstructionsFromInput(reqBody map[string]any) string {
  1275. input, ok := reqBody["input"].([]any)
  1276. if !ok || len(input) == 0 {
  1277. return ""
  1278. }
  1279. var texts []string
  1280. for _, item := range input {
  1281. m, ok := item.(map[string]any)
  1282. if !ok {
  1283. continue
  1284. }
  1285. role, _ := m["role"].(string)
  1286. switch role {
  1287. case "developer", "system":
  1288. if text := strings.TrimSpace(extractTextFromContent(m["content"])); text != "" {
  1289. texts = append(texts, text)
  1290. }
  1291. }
  1292. }
  1293. return strings.Join(texts, "\n\n")
  1294. }
  1295. // defaultCodexSynthInstructions 返回合成路径在 instructions 为空时应填入的默认提示词。
  1296. //
  1297. // 按 model 选择真实 Codex CLI 的 base instructions,使合成请求在提示词层面贴近真实 Codex 行为;
  1298. // 若内嵌 prompt 意外为空,回退到最小占位符以保证字段非空。
  1299. func defaultCodexSynthInstructions(model string) string {
  1300. if instructions := strings.TrimSpace(openai.CodexBaseInstructionsForModel(model)); instructions != "" {
  1301. return instructions
  1302. }
  1303. return "You are a helpful coding assistant."
  1304. }
  1305. // ensureCodexReasoningInclude 在请求带 reasoning 时补齐 include:["reasoning.encrypted_content"]。
  1306. //
  1307. // 真实 Codex 在 reasoning 存在时总会请求加密推理内容(ChatGPT/store=false 场景下用于上下文回放)。
  1308. // 该函数为加法式、幂等:仅在 include 缺失或未包含该项时追加;对非数组的异常 include 不做破坏性改写。
  1309. func ensureCodexReasoningInclude(reqBody map[string]any) bool {
  1310. reasoning, ok := reqBody["reasoning"].(map[string]any)
  1311. if !ok || len(reasoning) == 0 {
  1312. return false
  1313. }
  1314. const encrypted = "reasoning.encrypted_content"
  1315. switch existing := reqBody["include"].(type) {
  1316. case nil:
  1317. reqBody["include"] = []any{encrypted}
  1318. return true
  1319. case []any:
  1320. for _, v := range existing {
  1321. if s, ok := v.(string); ok && s == encrypted {
  1322. return false
  1323. }
  1324. }
  1325. reqBody["include"] = append(existing, encrypted)
  1326. return true
  1327. default:
  1328. // include 为非预期类型时保持原样,避免破坏调用方意图。
  1329. return false
  1330. }
  1331. }
  1332. // applyCodexClientMetadata 在请求体补齐 client_metadata["x-codex-installation-id"],
  1333. // 取值为账号真实的 openai_device_id(最新 Codex 在请求体携带的安装标识)。
  1334. //
  1335. // 加法式、幂等:仅在账号存在 device_id 且该键缺失时注入,绝不覆盖既有 client_metadata
  1336. // (如 turn metadata),也不伪造——无 device_id 时不写入。
  1337. func applyCodexClientMetadata(reqBody map[string]any, account *Account) bool {
  1338. if account == nil {
  1339. return false
  1340. }
  1341. deviceID := strings.TrimSpace(account.GetOpenAIDeviceID())
  1342. if deviceID == "" {
  1343. return false
  1344. }
  1345. const key = "x-codex-installation-id"
  1346. switch existing := reqBody["client_metadata"].(type) {
  1347. case map[string]any:
  1348. if v, ok := existing[key].(string); ok && strings.TrimSpace(v) != "" {
  1349. return false
  1350. }
  1351. existing[key] = deviceID
  1352. reqBody["client_metadata"] = existing
  1353. return true
  1354. case map[string]string:
  1355. if strings.TrimSpace(existing[key]) != "" {
  1356. return false
  1357. }
  1358. next := make(map[string]any, len(existing)+1)
  1359. for k, v := range existing {
  1360. next[k] = v
  1361. }
  1362. next[key] = deviceID
  1363. reqBody["client_metadata"] = next
  1364. return true
  1365. case nil:
  1366. reqBody["client_metadata"] = map[string]any{key: deviceID}
  1367. return true
  1368. default:
  1369. return false
  1370. }
  1371. }
  1372. // applyInstructions 处理 instructions 字段:仅在 instructions 为空时填充默认值。
  1373. func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool {
  1374. if !isInstructionsEmpty(reqBody) {
  1375. return false
  1376. }
  1377. model, _ := reqBody["model"].(string)
  1378. reqBody["instructions"] = defaultCodexSynthInstructions(model)
  1379. return true
  1380. }
  1381. // isInstructionsEmpty 检查 instructions 字段是否为空
  1382. // 处理以下情况:字段不存在、nil、空字符串、纯空白字符串
  1383. func isInstructionsEmpty(reqBody map[string]any) bool {
  1384. val, exists := reqBody["instructions"]
  1385. if !exists {
  1386. return true
  1387. }
  1388. if val == nil {
  1389. return true
  1390. }
  1391. str, ok := val.(string)
  1392. if !ok {
  1393. return true
  1394. }
  1395. return strings.TrimSpace(str) == ""
  1396. }
  1397. type codexInputFilterOptions struct {
  1398. PreserveReferences bool
  1399. PreserveCallIDs bool
  1400. }
  1401. // filterCodexInput 按需过滤 item_reference 与 id。
  1402. // preserveReferences 为 true 时保持引用与 id,以满足续链请求对上下文的依赖。
  1403. func filterCodexInput(input []any, preserveReferences bool) []any {
  1404. return filterCodexInputWithOptions(input, codexInputFilterOptions{
  1405. PreserveReferences: preserveReferences,
  1406. })
  1407. }
  1408. func normalizeCodexFilterCallID(itemType, id string, preserve bool) string {
  1409. if preserve && len(id) <= codexCallIDMaxLength {
  1410. return id
  1411. }
  1412. return normalizeCodexCallIDForItemType(itemType, id)
  1413. }
  1414. func codexItemReferenceIDMappings(input []any, preserveCallIDs bool) map[string]string {
  1415. mappings := make(map[string]string)
  1416. ambiguous := make(map[string]struct{})
  1417. for _, rawItem := range input {
  1418. item, ok := rawItem.(map[string]any)
  1419. if !ok {
  1420. continue
  1421. }
  1422. itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
  1423. if !isCodexToolCallItemType(itemType) {
  1424. continue
  1425. }
  1426. rawCallID := strings.TrimSpace(firstNonEmptyString(item["call_id"]))
  1427. if rawCallID == "" {
  1428. continue
  1429. }
  1430. normalized := normalizeCodexFilterCallID(itemType, rawCallID, preserveCallIDs)
  1431. if existing, exists := mappings[rawCallID]; exists && existing != normalized {
  1432. delete(mappings, rawCallID)
  1433. ambiguous[rawCallID] = struct{}{}
  1434. continue
  1435. }
  1436. if _, conflict := ambiguous[rawCallID]; !conflict {
  1437. mappings[rawCallID] = normalized
  1438. }
  1439. }
  1440. return mappings
  1441. }
  1442. func codexInputItemIDs(input []any) map[string]struct{} {
  1443. itemIDs := make(map[string]struct{})
  1444. for _, rawItem := range input {
  1445. item, ok := rawItem.(map[string]any)
  1446. if !ok || strings.TrimSpace(firstNonEmptyString(item["type"])) == "item_reference" {
  1447. continue
  1448. }
  1449. itemType := strings.TrimSpace(firstNonEmptyString(item["type"]))
  1450. id := strings.TrimSpace(firstNonEmptyString(item["id"]))
  1451. if id != "" && !shouldStripOpenAIResponsesInputItemID(itemType, id) {
  1452. itemIDs[id] = struct{}{}
  1453. }
  1454. }
  1455. return itemIDs
  1456. }
  1457. func codexInputCallIDs(input []any) map[string]struct{} {
  1458. callIDs := make(map[string]struct{})
  1459. for _, rawItem := range input {
  1460. item, ok := rawItem.(map[string]any)
  1461. if !ok || !isCodexToolCallItemType(strings.TrimSpace(firstNonEmptyString(item["type"]))) {
  1462. continue
  1463. }
  1464. if callID := strings.TrimSpace(firstNonEmptyString(item["call_id"])); callID != "" {
  1465. callIDs[callID] = struct{}{}
  1466. }
  1467. }
  1468. return callIDs
  1469. }
  1470. func filterCodexInputWithOptions(input []any, opts codexInputFilterOptions) []any {
  1471. filtered := make([]any, 0, len(input))
  1472. referenceIDMappings := codexItemReferenceIDMappings(input, opts.PreserveCallIDs)
  1473. inputItemIDs := codexInputItemIDs(input)
  1474. inputCallIDs := codexInputCallIDs(input)
  1475. for _, item := range input {
  1476. m, ok := item.(map[string]any)
  1477. if !ok {
  1478. filtered = append(filtered, item)
  1479. continue
  1480. }
  1481. typ, _ := m["type"].(string)
  1482. // chatgpt.com codex (OAuth path) runs with store=false (forced by
  1483. // applyCodexOAuthTransform). Replaying a reasoning item with its rs_*
  1484. // id but no encrypted_content 404s upstream ("Item with id 'rs_...'
  1485. // not found") — the 404 is triggered by the id lookup, not by the
  1486. // reasoning item itself. So strip the id (always, independent of
  1487. // PreserveReferences) yet keep the item: under store=false
  1488. // encrypted_content is the official channel for carrying reasoning
  1489. // context across turns, and dropping the whole item silently degrades
  1490. // multi-turn agent reasoning. Preserve encrypted_content/content/
  1491. // summary and every other field verbatim. Upstream additionally
  1492. // requires a summary field — a missing one is rejected with 400
  1493. // "Missing required parameter 'input[N].summary'" — so backfill an
  1494. // empty array when it is absent. Contracts verified end-to-end against
  1495. // chatgpt.com codex (gpt-5.5); see issue #1957.
  1496. // compaction_summary items (cmp_*) are the other encrypted_content
  1497. // carrier. Verified against the live backend: they require
  1498. // encrypted_content (a missing one is rejected with 400), and with it
  1499. // present the cmp_* id does not 404 whether kept or stripped. Being
  1500. // neither reasoning nor tool calls, they flow through the generic path
  1501. // below (id stripped when !PreserveReferences, encrypted_content
  1502. // preserved either way), which is safe and needs no special-casing.
  1503. if typ == "reasoning" {
  1504. newItem := make(map[string]any, len(m))
  1505. for key, value := range m {
  1506. if key == "id" || key == "call_id" {
  1507. // rs_* id replayed under store=false 404s; strip it.
  1508. continue
  1509. }
  1510. newItem[key] = value
  1511. }
  1512. if summary, ok := newItem["summary"]; !ok || summary == nil {
  1513. // Upstream requires a summary field; an empty array satisfies it.
  1514. newItem["summary"] = []any{}
  1515. }
  1516. filtered = append(filtered, newItem)
  1517. continue
  1518. }
  1519. // 仅修正真正的 tool/function call 标识,避免误改普通 message/reasoning id;
  1520. // 若 item_reference 指向 legacy call_* 标识,则仅修正该引用本身。
  1521. fixCallIDPrefix := func(id string) string {
  1522. return normalizeCodexFilterCallID(typ, id, opts.PreserveCallIDs)
  1523. }
  1524. if typ == "item_reference" {
  1525. if !opts.PreserveReferences {
  1526. continue
  1527. }
  1528. newItem := make(map[string]any, len(m))
  1529. for key, value := range m {
  1530. newItem[key] = value
  1531. }
  1532. if id, ok := newItem["id"].(string); ok && strings.HasPrefix(strings.TrimSpace(id), "call_") {
  1533. trimmedID := strings.TrimSpace(id)
  1534. _, referencesExistingItem := inputItemIDs[trimmedID]
  1535. if !referencesExistingItem {
  1536. if normalizedID, mapped := referenceIDMappings[trimmedID]; mapped {
  1537. newItem["id"] = normalizedID
  1538. } else if _, hasSameTurnCall := inputCallIDs[trimmedID]; !hasSameTurnCall {
  1539. // A bare call_* reference is a legacy function-call identifier.
  1540. // Normalize it even when its call item lives in an earlier turn.
  1541. newItem["id"] = normalizeCodexCallID(trimmedID)
  1542. }
  1543. }
  1544. }
  1545. filtered = append(filtered, newItem)
  1546. continue
  1547. }
  1548. newItem := m
  1549. copied := false
  1550. // 仅在需要修改字段时创建副本,避免直接改写原始输入。
  1551. ensureCopy := func() {
  1552. if copied {
  1553. return
  1554. }
  1555. newItem = make(map[string]any, len(m))
  1556. for key, value := range m {
  1557. newItem[key] = value
  1558. }
  1559. copied = true
  1560. }
  1561. if isCodexToolCallItemType(typ) {
  1562. callID, ok := m["call_id"].(string)
  1563. if !ok || strings.TrimSpace(callID) == "" {
  1564. if id, ok := m["id"].(string); ok && strings.TrimSpace(id) != "" {
  1565. callID = id
  1566. ensureCopy()
  1567. newItem["call_id"] = callID
  1568. }
  1569. }
  1570. if callID != "" {
  1571. fixedCallID := fixCallIDPrefix(callID)
  1572. if fixedCallID != callID {
  1573. ensureCopy()
  1574. newItem["call_id"] = fixedCallID
  1575. }
  1576. }
  1577. }
  1578. if !isCodexToolCallItemType(typ) {
  1579. ensureCopy()
  1580. delete(newItem, "call_id")
  1581. }
  1582. if codexInputItemRequiresName(typ) {
  1583. if strings.TrimSpace(firstNonEmptyString(m["name"])) == "" {
  1584. name := firstNonEmptyString(m["tool_name"])
  1585. if name == "" {
  1586. if function, ok := m["function"].(map[string]any); ok {
  1587. name = firstNonEmptyString(function["name"])
  1588. }
  1589. }
  1590. if name == "" {
  1591. name = "tool"
  1592. }
  1593. ensureCopy()
  1594. newItem["name"] = name
  1595. }
  1596. }
  1597. if !opts.PreserveReferences {
  1598. ensureCopy()
  1599. delete(newItem, "id")
  1600. } else if id, ok := m["id"].(string); ok && shouldStripOpenAIResponsesInputItemID(typ, id) {
  1601. ensureCopy()
  1602. delete(newItem, "id")
  1603. }
  1604. filtered = append(filtered, newItem)
  1605. }
  1606. return filtered
  1607. }
  1608. func isCodexToolCallItemType(typ string) bool {
  1609. switch typ {
  1610. case "function_call",
  1611. "tool_call",
  1612. "local_shell_call",
  1613. "tool_search_call",
  1614. "custom_tool_call",
  1615. "mcp_tool_call",
  1616. "function_call_output",
  1617. "mcp_tool_call_output",
  1618. "custom_tool_call_output",
  1619. "tool_search_output":
  1620. return true
  1621. default:
  1622. return false
  1623. }
  1624. }
  1625. // isCodexToolCallInputType 仅匹配 call-input 类型(不含 output),这些类型的
  1626. // id 必须以 "fc" 开头,上游会校验 "Expected an ID that begins with 'fc'."。
  1627. func isCodexToolCallInputType(typ string) bool {
  1628. switch typ {
  1629. case "function_call",
  1630. "tool_call",
  1631. "local_shell_call",
  1632. "tool_search_call",
  1633. "custom_tool_call",
  1634. "mcp_tool_call":
  1635. return true
  1636. default:
  1637. return false
  1638. }
  1639. }
  1640. func codexInputItemRequiresName(typ string) bool {
  1641. switch strings.TrimSpace(typ) {
  1642. case "function_call", "custom_tool_call", "mcp_tool_call":
  1643. return true
  1644. default:
  1645. return false
  1646. }
  1647. }
  1648. func normalizeCodexTools(reqBody map[string]any) bool {
  1649. rawTools, ok := reqBody["tools"]
  1650. if !ok || rawTools == nil {
  1651. return false
  1652. }
  1653. tools, ok := rawTools.([]any)
  1654. if !ok {
  1655. return false
  1656. }
  1657. modified := false
  1658. validTools := make([]any, 0, len(tools))
  1659. for _, tool := range tools {
  1660. toolMap, ok := tool.(map[string]any)
  1661. if !ok {
  1662. // Keep unknown structure as-is to avoid breaking upstream behavior.
  1663. validTools = append(validTools, tool)
  1664. continue
  1665. }
  1666. toolType, _ := toolMap["type"].(string)
  1667. toolType = strings.TrimSpace(toolType)
  1668. if toolType != "function" {
  1669. validTools = append(validTools, toolMap)
  1670. continue
  1671. }
  1672. // OpenAI Responses-style tools use top-level name/parameters.
  1673. if name, ok := toolMap["name"].(string); ok && strings.TrimSpace(name) != "" {
  1674. validTools = append(validTools, toolMap)
  1675. continue
  1676. }
  1677. // ChatCompletions-style tools use {type:"function", function:{...}}.
  1678. functionValue, hasFunction := toolMap["function"]
  1679. function, ok := functionValue.(map[string]any)
  1680. if !hasFunction || functionValue == nil || !ok || function == nil {
  1681. // Drop invalid function tools.
  1682. modified = true
  1683. continue
  1684. }
  1685. if _, ok := toolMap["name"]; !ok {
  1686. if name, ok := function["name"].(string); ok && strings.TrimSpace(name) != "" {
  1687. toolMap["name"] = name
  1688. modified = true
  1689. }
  1690. }
  1691. if _, ok := toolMap["description"]; !ok {
  1692. if desc, ok := function["description"].(string); ok && strings.TrimSpace(desc) != "" {
  1693. toolMap["description"] = desc
  1694. modified = true
  1695. }
  1696. }
  1697. if _, ok := toolMap["parameters"]; !ok {
  1698. if params, ok := function["parameters"]; ok {
  1699. toolMap["parameters"] = params
  1700. modified = true
  1701. }
  1702. }
  1703. if _, ok := toolMap["strict"]; !ok {
  1704. if strict, ok := function["strict"]; ok {
  1705. toolMap["strict"] = strict
  1706. modified = true
  1707. }
  1708. }
  1709. validTools = append(validTools, toolMap)
  1710. }
  1711. if modified {
  1712. reqBody["tools"] = validTools
  1713. }
  1714. return modified
  1715. }