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