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 {