`fixCallIDPrefix` builds malformed ids when the input has the standard
OpenAI `call_<nanoid>` prefix:
input: call_YYen1qxDejd2myJwcTCf7Nyp
output: fcYYen1qxDejd2myJwcTCf7Nyp ← no underscore between 'fc' and the nanoid
ChatGPT's codex backend then rejects the replayed item with:
400 Invalid 'input[N].id': 'fcYYen1qxDejd2myJwcTCf7Nyp'.
Expected an ID that contains letters, numbers, underscores, or
dashes, but this value contained additional characters.
Sub2api wraps that into 502 to the client. Clients using the OpenAI SDK
on the OAuth/codex path see every multi-hop turn (after the first tool
call) fail because the item_reference rewritten this way gets sent on
every subsequent hop.
The other two branches of the same function correctly emit `fc_`
(line 1029: pass-through when already `fc*`; line 1035 fallback:
`fc_" + id`). Only the `call_` → `fc_` rewrite was missing the
underscore — looks like a copy-paste slip during the original commit.
Fix: change `"fc"` to `"fc_"` on the call_ branch. One character.
Repro:
client (OpenAI SDK) sends a function_call_output whose call_id is
`call_<nanoid>` (default OpenAI format). The sub2api request body
also contains an item_reference whose id mirrors the call_id (also
`call_<nanoid>`). On the codex OAuth path, this rewrite fires for
the item_reference's id, producing the malformed value.
Affects: `platform=openai type=oauth` accounts whose clients use the
official OpenAI SDK / Responses API conventions (id prefix `call_`).
API-key accounts and bridge-mode requests are untouched.
1221 lines
31 KiB
Go
1221 lines
31 KiB
Go
package service
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
var codexModelMap = map[string]string{
|
||
"gpt-5.5": "gpt-5.5",
|
||
"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-spark-low": "gpt-5.3-codex-spark",
|
||
"gpt-5.3-codex-spark-medium": "gpt-5.3-codex-spark",
|
||
"gpt-5.3-codex-spark-high": "gpt-5.3-codex-spark",
|
||
"gpt-5.3-codex-spark-xhigh": "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.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", 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
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
// 兼容遗留的 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
|
||
}
|
||
}
|
||
|
||
// 提取 input 中 role:"system" 消息至 instructions(OAuth 上游不支持 system role)。
|
||
if extractSystemMessagesFromInput(reqBody) {
|
||
result.Modified = true
|
||
}
|
||
|
||
// instructions 处理逻辑:根据是否是 Codex CLI 分别调用不同方法
|
||
if !opts.SkipDefaultInstructions && applyInstructions(reqBody, opts.IsCodexCLI) {
|
||
result.Modified = true
|
||
}
|
||
if isCodexSparkModel(normalizedModel) && applyCodexSparkImageUnsupportedInstructions(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) {
|
||
return modified
|
||
}
|
||
reqBody["tool_choice"] = "auto"
|
||
return true
|
||
}
|
||
|
||
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 {
|
||
rawTools, ok := reqBody["tools"]
|
||
if !ok || 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 strings.TrimSpace(firstNonEmptyString(toolMap["type"])) == "image_generation" {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
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
|
||
}
|
||
for _, rawTool := range tools {
|
||
toolMap, ok := rawTool.(map[string]any)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if strings.TrimSpace(firstNonEmptyString(toolMap["type"])) == "image_generation" {
|
||
return false
|
||
}
|
||
}
|
||
|
||
reqBody["tools"] = append(tools, tool)
|
||
return true
|
||
}
|
||
|
||
func applyCodexImageGenerationBridgeInstructions(reqBody map[string]any) bool {
|
||
if len(reqBody) == 0 || !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 the input array for items with role=="system",
|
||
// removes them, and merges their content into reqBody["instructions"].
|
||
// If instructions is already non-empty, extracted content is prepended with "\n\n".
|
||
// Returns true if any system messages were extracted.
|
||
func extractSystemMessagesFromInput(reqBody map[string]any) bool {
|
||
input, ok := reqBody["input"].([]any)
|
||
if !ok || len(input) == 0 {
|
||
return false
|
||
}
|
||
|
||
var systemTexts []string
|
||
remaining := make([]any, 0, len(input))
|
||
|
||
for _, item := range input {
|
||
m, ok := item.(map[string]any)
|
||
if !ok {
|
||
remaining = append(remaining, item)
|
||
continue
|
||
}
|
||
if role, _ := m["role"].(string); role != "system" {
|
||
remaining = append(remaining, item)
|
||
continue
|
||
}
|
||
if text := extractTextFromContent(m["content"]); text != "" {
|
||
systemTexts = append(systemTexts, text)
|
||
}
|
||
}
|
||
|
||
if len(systemTexts) == 0 {
|
||
return false
|
||
}
|
||
|
||
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
|
||
}
|
||
reqBody["input"] = remaining
|
||
return true
|
||
}
|
||
|
||
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")
|
||
}
|
||
|
||
// applyInstructions 处理 instructions 字段:仅在 instructions 为空时填充默认值。
|
||
func applyInstructions(reqBody map[string]any, isCodexCLI bool) bool {
|
||
if !isInstructionsEmpty(reqBody) {
|
||
return false
|
||
}
|
||
reqBody["instructions"] = "You are a helpful coding assistant."
|
||
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 backend (OAuth path) does not persist reasoning
|
||
// items because applyCodexOAuthTransform forces store=false. Any rs_*
|
||
// reference replayed in input is guaranteed to 404 upstream
|
||
// ("Item with id 'rs_...' not found"). Drop reasoning items entirely.
|
||
if typ == "reasoning" {
|
||
continue
|
||
}
|
||
|
||
// 仅修正真正的 tool/function call 标识,避免误改普通 message/reasoning id;
|
||
// 若 item_reference 指向 legacy call_* 标识,则仅修正该引用本身。
|
||
fixCallIDPrefix := func(id string) string {
|
||
if opts.PreserveCallIDs {
|
||
return id
|
||
}
|
||
if id == "" || strings.HasPrefix(id, "fc") {
|
||
return id
|
||
}
|
||
if strings.HasPrefix(id, "call_") {
|
||
return "fc_" + strings.TrimPrefix(id, "call_")
|
||
}
|
||
return "fc_" + 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")
|
||
}
|
||
|
||
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
|
||
}
|
||
}
|
||
|
||
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
|
||
}
|