diff --git a/backend/internal/service/openai_codex_transform.go b/backend/internal/service/openai_codex_transform.go index 65f7f5b4..20d303b3 100644 --- a/backend/internal/service/openai_codex_transform.go +++ b/backend/internal/service/openai_codex_transform.go @@ -48,6 +48,8 @@ type codexTransformResult struct { const ( codexImageGenerationBridgeMarker = "" 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" + codexSparkImageUnsupportedMarker = "" + 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" ) func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact bool) codexTransformResult { @@ -165,6 +167,9 @@ func applyCodexOAuthTransform(reqBody map[string]any, isCodexCLI bool, isCompact if applyInstructions(reqBody, isCodexCLI) { result.Modified = true } + if isCodexSparkModel(normalizedModel) && applyCodexSparkImageUnsupportedInstructions(reqBody) { + result.Modified = true + } // 续链场景保留 item_reference 与 id,避免 call_id 上下文丢失。 if input, ok := reqBody["input"].([]any); ok { @@ -244,6 +249,10 @@ func normalizeCodexModel(model string) string { return "gpt-5.4" } +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 { @@ -265,6 +274,40 @@ func hasOpenAIImageGenerationTool(reqBody map[string]any) bool { 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 { @@ -309,6 +352,9 @@ 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", @@ -344,6 +390,9 @@ 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) { @@ -360,6 +409,23 @@ func applyCodexImageGenerationBridgeInstructions(reqBody map[string]any) bool { 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 diff --git a/backend/internal/service/openai_codex_transform_test.go b/backend/internal/service/openai_codex_transform_test.go index 476f1ea9..f655e61c 100644 --- a/backend/internal/service/openai_codex_transform_test.go +++ b/backend/internal/service/openai_codex_transform_test.go @@ -333,6 +333,17 @@ func TestEnsureOpenAIResponsesImageGenerationTool_NoTools(t *testing.T) { require.Equal(t, "png", tool["output_format"]) } +func TestEnsureOpenAIResponsesImageGenerationTool_SkipsSpark(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.3-codex-spark", + "input": "draw a cat", + } + + modified := ensureOpenAIResponsesImageGenerationTool(reqBody) + require.False(t, modified) + require.NotContains(t, reqBody, "tools") +} + func TestEnsureOpenAIResponsesImageGenerationTool_AppendsToExistingTools(t *testing.T) { reqBody := map[string]any{ "model": "gpt-5.4", @@ -378,6 +389,7 @@ func TestEnsureOpenAIResponsesImageGenerationTool_PreservesExistingImageTool(t * func TestApplyCodexImageGenerationBridgeInstructions_AppendsBridgeOnce(t *testing.T) { reqBody := map[string]any{ + "model": "gpt-5.4", "instructions": "existing instructions", "tools": []any{ map[string]any{"type": "image_generation", "output_format": "png"}, @@ -397,6 +409,20 @@ func TestApplyCodexImageGenerationBridgeInstructions_AppendsBridgeOnce(t *testin require.False(t, modified) } +func TestApplyCodexImageGenerationBridgeInstructions_SkipsSpark(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.3-codex-spark", + "instructions": "existing instructions", + "tools": []any{ + map[string]any{"type": "image_generation", "output_format": "png"}, + }, + } + + modified := applyCodexImageGenerationBridgeInstructions(reqBody) + require.False(t, modified) + require.Equal(t, "existing instructions", reqBody["instructions"]) +} + func TestApplyCodexImageGenerationBridgeInstructions_SkipsWithoutImageTool(t *testing.T) { reqBody := map[string]any{ "instructions": "existing instructions", @@ -410,6 +436,91 @@ func TestApplyCodexImageGenerationBridgeInstructions_SkipsWithoutImageTool(t *te require.Equal(t, "existing instructions", reqBody["instructions"]) } +func TestValidateCodexSparkInputRejectsInputImage(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.3-codex-spark", + "input": []any{ + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{"type": "input_text", "text": "describe"}, + map[string]any{"type": "input_image", "image_url": "data:image/png;base64,aGVsbG8="}, + }, + }, + }, + } + + err := validateCodexSparkInput(reqBody, "gpt-5.3-codex-spark") + require.Error(t, err) + require.Contains(t, err.Error(), "does not support image input") +} + +func TestValidateCodexSparkInputRejectsChatImageURL(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.3-codex-spark", + "messages": []any{ + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{"type": "text", "text": "describe"}, + map[string]any{"type": "image_url", "image_url": map[string]any{"url": "data:image/png;base64,aGVsbG8="}}, + }, + }, + }, + } + + err := validateCodexSparkInput(reqBody, "gpt-5.3-codex-spark") + require.Error(t, err) +} + +func TestValidateCodexSparkInputAllowsTextOnly(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.3-codex-spark", + "input": []any{ + map[string]any{ + "role": "user", + "content": []any{ + map[string]any{"type": "input_text", "text": "hello"}, + }, + }, + }, + } + + require.NoError(t, validateCodexSparkInput(reqBody, "gpt-5.3-codex-spark")) +} + +func TestApplyCodexOAuthTransform_AddsSparkImageUnsupportedInstructions(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.3-codex-spark", + "instructions": "existing instructions", + "input": "hello", + } + + result := applyCodexOAuthTransform(reqBody, true, false) + require.True(t, result.Modified) + + instructions, ok := reqBody["instructions"].(string) + require.True(t, ok) + require.Contains(t, instructions, "existing instructions") + require.Contains(t, instructions, codexSparkImageUnsupportedMarker) + require.Contains(t, instructions, "does not support image generation") + require.Contains(t, instructions, "switch to a non-Spark Codex model") + require.NotContains(t, instructions, codexImageGenerationBridgeMarker) +} + +func TestApplyCodexOAuthTransform_DoesNotAddSparkImageUnsupportedForNonSpark(t *testing.T) { + reqBody := map[string]any{ + "model": "gpt-5.4", + "instructions": "existing instructions", + "input": "hello", + } + + applyCodexOAuthTransform(reqBody, true, false) + instructions, ok := reqBody["instructions"].(string) + require.True(t, ok) + require.NotContains(t, instructions, codexSparkImageUnsupportedMarker) +} + func TestNormalizeOpenAIResponsesImageOnlyModel_BuildsImageToolRequest(t *testing.T) { reqBody := map[string]any{ "model": "gpt-image-2", diff --git a/backend/internal/service/openai_gateway_service.go b/backend/internal/service/openai_gateway_service.go index d99cd7da..2d05c3ea 100644 --- a/backend/internal/service/openai_gateway_service.go +++ b/backend/internal/service/openai_gateway_service.go @@ -1995,6 +1995,17 @@ func (s *OpenAIGatewayService) Forward(ctx context.Context, c *gin.Context, acco account.Type, ) } + if err := validateCodexSparkInput(reqBody, upstreamModel); err != nil { + setOpsUpstreamError(c, http.StatusBadRequest, err.Error(), "") + c.JSON(http.StatusBadRequest, gin.H{ + "error": gin.H{ + "type": "invalid_request_error", + "message": err.Error(), + "param": "input", + }, + }) + return nil, err + } // OpenAI OAuth 账号走 ChatGPT internal Codex endpoint,需要将模型名规范化为 // 上游可识别的 Codex/GPT 系列。API Key 账号则应保留原始/映射后的模型名, diff --git a/backend/internal/service/openai_model_mapping.go b/backend/internal/service/openai_model_mapping.go index 9bf3fba3..993c0b13 100644 --- a/backend/internal/service/openai_model_mapping.go +++ b/backend/internal/service/openai_model_mapping.go @@ -1,5 +1,7 @@ package service +import "strings" + // resolveOpenAIForwardModel determines the upstream model for OpenAI-compatible // forwarding. Group-level default mapping only applies when the account itself // did not match any explicit model_mapping rule. @@ -12,8 +14,28 @@ func resolveOpenAIForwardModel(account *Account, requestedModel, defaultMappedMo } mappedModel, matched := account.ResolveMappedModel(requestedModel) - if !matched && defaultMappedModel != "" { + if !matched && defaultMappedModel != "" && !isExplicitCodexModel(requestedModel) { return defaultMappedModel } return mappedModel } + +func isExplicitCodexModel(model string) bool { + model = strings.TrimSpace(model) + if model == "" { + return false + } + if strings.Contains(model, "/") { + parts := strings.Split(model, "/") + model = parts[len(parts)-1] + } + model = strings.ToLower(strings.TrimSpace(model)) + if getNormalizedCodexModel(model) != "" { + return true + } + if strings.HasSuffix(model, "-openai-compact") { + base := strings.TrimSuffix(model, "-openai-compact") + return getNormalizedCodexModel(base) != "" + } + return false +} diff --git a/backend/internal/service/openai_model_mapping_test.go b/backend/internal/service/openai_model_mapping_test.go index f25863a8..21a2e9a0 100644 --- a/backend/internal/service/openai_model_mapping_test.go +++ b/backend/internal/service/openai_model_mapping_test.go @@ -15,10 +15,19 @@ func TestResolveOpenAIForwardModel(t *testing.T) { account: &Account{ Credentials: map[string]any{}, }, - requestedModel: "gpt-5.4", + requestedModel: "claude-opus-4-6", defaultMappedModel: "gpt-4o-mini", expectedModel: "gpt-4o-mini", }, + { + name: "preserves explicit gpt-5.4 instead of group default", + account: &Account{ + Credentials: map[string]any{}, + }, + requestedModel: "gpt-5.4", + defaultMappedModel: "gpt-4o-mini", + expectedModel: "gpt-5.4", + }, { name: "preserves exact passthrough mapping instead of group default", account: &Account{ @@ -58,6 +67,42 @@ func TestResolveOpenAIForwardModel(t *testing.T) { defaultMappedModel: "gpt-4o-mini", expectedModel: "gpt-5.4", }, + { + name: "preserves codex spark instead of group default", + account: &Account{ + Credentials: map[string]any{}, + }, + requestedModel: "gpt-5.3-codex-spark", + defaultMappedModel: "gpt-5.4", + expectedModel: "gpt-5.3-codex-spark", + }, + { + name: "preserves gpt-5.5 instead of group default", + account: &Account{ + Credentials: map[string]any{}, + }, + requestedModel: "gpt-5.5", + defaultMappedModel: "gpt-5.4", + expectedModel: "gpt-5.5", + }, + { + name: "preserves openai namespaced gpt-5.5 instead of group default", + account: &Account{ + Credentials: map[string]any{}, + }, + requestedModel: "openai/gpt-5.5", + defaultMappedModel: "gpt-5.4", + expectedModel: "openai/gpt-5.5", + }, + { + name: "preserves compact gpt-5.5 instead of group default", + account: &Account{ + Credentials: map[string]any{}, + }, + requestedModel: "gpt-5.5-openai-compact", + defaultMappedModel: "gpt-5.4", + expectedModel: "gpt-5.5-openai-compact", + }, } for _, tt := range tests {