Merge pull request #1895 from gaoren002/fix/codex-spark-limitations
fix(openai): handle codex spark model limitations
This commit is contained in:
@@ -48,6 +48,8 @@ type codexTransformResult struct {
|
||||
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>"
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 账号则应保留原始/映射后的模型名,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user