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)})