From 9d7c636db9ab7ba4375fb51cdeacb37db16cd8d2 Mon Sep 17 00:00:00 2001 From: Thinker-Joe <94465898+Thinker-Joe@users.noreply.github.com> Date: Wed, 23 Sep 2026 01:43:06 +0800 Subject: [PATCH] fix(openai): respect passthrough in picker image choices Validate mapped targets for default image models as well as aliases. Preserve native image choices in passthrough and cover mapping changes over the shared catalog cache. --- .../service/account_test_models_test.go | 110 ++++++++++++++++-- .../internal/service/account_test_service.go | 11 +- 2 files changed, 112 insertions(+), 9 deletions(-) diff --git a/backend/internal/service/account_test_models_test.go b/backend/internal/service/account_test_models_test.go index 3104b4e430..eb11cca954 100644 --- a/backend/internal/service/account_test_models_test.go +++ b/backend/internal/service/account_test_models_test.go @@ -3,6 +3,7 @@ package service import ( "context" "net/http" + "sync/atomic" "testing" "github.com/Wei-Shaw/sub2api/internal/pkg/openai" @@ -185,15 +186,108 @@ func TestFetchOpenAIAccountModelsOAuthLocalImageAlias(t *testing.T) { require.Equal(t, "paint", models[0].DisplayName) } -// A public name that merely looks like an image model must not be enough to -// synthesize a picker entry when its target is a text model. -func TestFetchOpenAIAccountModelsOAuthImageLookalikeAliasIsNotSynthesized(t *testing.T) { - newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-5.6-sol"}]}`) - svc := &AccountTestService{openaiGatewayService: &OpenAIGatewayService{}} - account := newCodexModelsTestAccount() - account.Credentials["model_mapping"] = map[string]any{"gpt-image-2.5-lookalike": "text-target-missing"} +// Image-shaped public names must come from projection when their target is text, +// whether or not the public name also appears in the local image catalog. +func TestFetchOpenAIAccountModelsOAuthImageNamesMappedToText(t *testing.T) { + for _, publicID := range []string{"gpt-image-2.5-lookalike", "gpt-image-2.5-flare"} { + for _, target := range []string{"text-target-missing", "gpt-6-astra"} { + t.Run(publicID+"/"+target, func(t *testing.T) { + newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-6-astra","display_name":"Upstream Text Model"}]}`) + svc := &AccountTestService{openaiGatewayService: &OpenAIGatewayService{}} + account := newCodexModelsTestAccount() + account.Credentials["model_mapping"] = map[string]any{publicID: target} + + models, err := svc.FetchOpenAIAccountModels(context.Background(), account) + require.NoError(t, err) + if target == "text-target-missing" { + require.Empty(t, models, "a missing text target must not be synthesized as an image choice") + return + } + require.Equal(t, []string{publicID}, pickerModelIDs(models)) + require.Equal(t, publicID, models[0].DisplayName, "a real text target uses the projected alias label") + }) + } + } +} + +func TestFetchOpenAIAccountModelsOAuthPassthroughIgnoresMappingTargets(t *testing.T) { + for _, flag := range []string{"openai_passthrough", "openai_oauth_passthrough"} { + t.Run(flag, func(t *testing.T) { + newCodexModelsOAuthCacheServer(t, `{"models":[{"slug":"gpt-6-astra"}]}`) + svc := &AccountTestService{openaiGatewayService: &OpenAIGatewayService{}} + account := newCodexModelsTestAccount() + account.Extra = map[string]any{flag: true} + account.Credentials["model_mapping"] = map[string]any{ + "gpt-image-custom": "text-target-missing", + "gpt-image-2.5-flare": "text-target-missing", + "paint": "gpt-image-2.5-flare", + } + + models, err := svc.FetchOpenAIAccountModels(context.Background(), account) + require.NoError(t, err) + ids := pickerModelIDs(models) + require.Contains(t, ids, "gpt-6-astra", "passthrough preserves the upstream catalog") + require.Contains(t, ids, "gpt-image-2.5-flare") + require.Contains(t, ids, "gpt-image-2.5-sunburst") + require.Contains(t, ids, "gpt-image-custom", "preserve existing native image choices in passthrough") + require.NotContains(t, ids, "paint", "passthrough must not revive a stale mapping alias") + }) + } +} + +func TestFetchOpenAIAccountModelsAPIKeyPassthroughIgnoresMapping(t *testing.T) { + gateway := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + return ordinaryModelsUpstreamResponse(`{"data":[{"id":"target"},{"id":"other"}]}`), nil + }}) + svc := &AccountTestService{openaiGatewayService: gateway} + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Extra = map[string]any{"openai_passthrough": true} + account.Credentials["model_mapping"] = map[string]any{"paint": "gpt-image-2.5-flare"} models, err := svc.FetchOpenAIAccountModels(context.Background(), account) require.NoError(t, err) - require.Empty(t, pickerModelIDs(models)) + require.Equal(t, []string{"target", "other"}, pickerModelIDs(models)) +} + +func TestFetchOpenAIAccountModelsMappingChangesReuseRawCache(t *testing.T) { + var calls atomic.Int32 + gateway := newCodexModelsAPIKeyTestService(&codexModelsHTTPUpstreamStub{do: func(_ *http.Request, _ string, _ int64, _ int) (*http.Response, error) { + calls.Add(1) + return ordinaryModelsUpstreamResponse(`{"data":[ + {"id":"target","owned_by":"provider","created":123}, + {"id":"gpt-a"}, + {"id":"gpt-special"}, + {"id":"other","owned_by":"other-provider","created":456} + ]}`), nil + }}) + svc := &AccountTestService{openaiGatewayService: gateway} + account := newCodexModelsAPIKeyTestAccount("https://models.example/v1") + account.Credentials["model_mapping"] = map[string]any{"gpt-*": "target", "gpt-special": "other"} + ctx := context.Background() + + before, err := gateway.FetchOpenAIModelsList(ctx, account) + require.NoError(t, err) + original := append([]byte(nil), before.Body...) + models, err := svc.FetchOpenAIAccountModels(ctx, account) + require.NoError(t, err) + require.ElementsMatch(t, []string{"gpt-a", "gpt-special"}, pickerModelIDs(models)) + for _, model := range models { + switch model.ID { + case "gpt-a": + require.EqualValues(t, 123, model.Created) + require.Equal(t, "provider", model.OwnedBy) + case "gpt-special": + require.EqualValues(t, 456, model.Created, "exact mapping takes priority over the wildcard") + require.Equal(t, "other-provider", model.OwnedBy) + } + } + + account.Credentials["model_mapping"] = map[string]any{"second": "other"} + models, err = svc.FetchOpenAIAccountModels(ctx, account) + require.NoError(t, err) + require.Equal(t, []string{"second"}, pickerModelIDs(models), "mapping changes apply without refreshing discovery") + after, err := gateway.FetchOpenAIModelsList(ctx, account) + require.NoError(t, err) + require.Equal(t, original, after.Body, "projection must not mutate the shared catalog") + require.EqualValues(t, 1, calls.Load(), "both mappings must reuse the same discovery cache") } diff --git a/backend/internal/service/account_test_service.go b/backend/internal/service/account_test_service.go index c2540f5b10..32cf2cce0e 100644 --- a/backend/internal/service/account_test_service.go +++ b/backend/internal/service/account_test_service.go @@ -218,12 +218,16 @@ func (s *AccountTestService) FetchOpenAIAccountModels(ctx context.Context, accou // Add locally supported image choices only to the OAuth test picker; keep the // shared upstream catalog and API-key discovery authoritative. if account != nil && account.IsOpenAIOAuthLike() { + passthrough := account.IsOpenAIPassthroughEnabled() seen := make(map[string]bool, len(payload.Data)) for _, model := range payload.Data { seen[model.ID] = true } for _, model := range openai.DefaultModels { if IsGPTImageGenerationModel(model.ID) && account.IsModelSupported(model.ID) && !seen[model.ID] { + if !passthrough && !IsGPTImageGenerationModel(account.GetMappedModel(model.ID)) { + continue + } payload.Data = append(payload.Data, model) seen[model.ID] = true } @@ -234,11 +238,16 @@ func (s *AccountTestService) FetchOpenAIAccountModels(ctx context.Context, accou // Judging by the target rather than the public name keeps a lookalike name // (for example an alias spelled "gpt-image-*" that maps to a text model) // from being synthesized into the picker. + // Passthrough keeps native image names without applying mapping targets. for publicID := range account.GetModelMapping() { if strings.Contains(publicID, "*") || seen[publicID] { continue } - if !IsGPTImageGenerationModel(account.GetMappedModel(publicID)) { + target := publicID + if !passthrough { + target = account.GetMappedModel(publicID) + } + if !IsGPTImageGenerationModel(target) { continue } payload.Data = append(payload.Data, openai.Model{ID: publicID, Object: "model", Type: "model", OwnedBy: "openai", DisplayName: openaiCodexDisplayName(publicID)})