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.
This commit is contained in:
Thinker-Joe
2026-09-23 01:43:06 +08:00
parent 4daea093f9
commit 9d7c636db9
2 changed files with 112 additions and 9 deletions
@@ -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")
}
@@ -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)})