2026-05-15 11:33:26 +08:00
|
|
|
|
package handler
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
|
|
|
|
|
"context"
|
|
|
|
|
|
"encoding/json"
|
2026-10-01 22:54:05 +08:00
|
|
|
|
"errors"
|
2026-05-15 11:33:26 +08:00
|
|
|
|
"net/http"
|
|
|
|
|
|
"net/http/httptest"
|
2026-09-15 16:31:48 +08:00
|
|
|
|
"strings"
|
2026-05-15 11:33:26 +08:00
|
|
|
|
"testing"
|
|
|
|
|
|
|
2026-08-19 14:58:32 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/claude"
|
2026-09-06 02:02:49 +08:00
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/pkg/openai"
|
2026-05-15 11:33:26 +08:00
|
|
|
|
middleware2 "github.com/Wei-Shaw/sub2api/internal/server/middleware"
|
|
|
|
|
|
"github.com/Wei-Shaw/sub2api/internal/service"
|
|
|
|
|
|
"github.com/gin-gonic/gin"
|
|
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
type gatewayModelsAccountRepoStub struct {
|
|
|
|
|
|
service.AccountRepository
|
|
|
|
|
|
|
|
|
|
|
|
byGroup map[int64][]service.Account
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 22:54:05 +08:00
|
|
|
|
type gatewayModelsRouteRepoStub struct {
|
|
|
|
|
|
service.CompositeModelRouteRepository
|
|
|
|
|
|
routes []service.CompositeModelRoute
|
|
|
|
|
|
err error
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *gatewayModelsRouteRepoStub) ListByGroup(_ context.Context, _ int64, _ bool) ([]service.CompositeModelRoute, error) {
|
|
|
|
|
|
return s.routes, s.err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 11:33:26 +08:00
|
|
|
|
type gatewayModelsResponseForTest struct {
|
|
|
|
|
|
Object string `json:"object"`
|
|
|
|
|
|
Data []gatewayModelItemForTest `json:"data"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 01:41:04 +09:00
|
|
|
|
type codexModelsResponseForTest struct {
|
|
|
|
|
|
Models []struct {
|
2026-08-21 07:20:08 +09:00
|
|
|
|
Slug string `json:"slug"`
|
|
|
|
|
|
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
|
|
|
|
|
|
InputModalities []string `json:"input_modalities"`
|
|
|
|
|
|
ModelMessages map[string]json.RawMessage `json:"model_messages"`
|
|
|
|
|
|
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
|
|
|
|
|
|
AvailabilityNUX json.RawMessage `json:"availability_nux"`
|
|
|
|
|
|
Upgrade json.RawMessage `json:"upgrade"`
|
2026-08-21 01:41:04 +09:00
|
|
|
|
} `json:"models"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 07:20:08 +09:00
|
|
|
|
type codexReasoningLevelForTest struct {
|
|
|
|
|
|
Effort string `json:"effort"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 11:33:26 +08:00
|
|
|
|
type gatewayModelItemForTest struct {
|
2026-07-16 16:52:25 +08:00
|
|
|
|
ID string `json:"id"`
|
|
|
|
|
|
Object string `json:"object"`
|
|
|
|
|
|
Created int64 `json:"created"`
|
|
|
|
|
|
OwnedBy string `json:"owned_by"`
|
|
|
|
|
|
CreatedAt string `json:"created_at"`
|
|
|
|
|
|
SupportsReasoningEffort bool `json:"supportsReasoningEffort"`
|
|
|
|
|
|
ReasoningEffort string `json:"reasoningEffort"`
|
|
|
|
|
|
ReasoningEfforts []gatewayReasoningEffortOptionForTest `json:"reasoningEfforts"`
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type gatewayReasoningEffortOptionForTest struct {
|
|
|
|
|
|
Value string `json:"value"`
|
|
|
|
|
|
Label string `json:"label"`
|
|
|
|
|
|
Default bool `json:"default"`
|
2026-05-15 11:33:26 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func (s *gatewayModelsAccountRepoStub) ListSchedulableByGroupID(ctx context.Context, groupID int64) ([]service.Account, error) {
|
|
|
|
|
|
accounts, ok := s.byGroup[groupID]
|
|
|
|
|
|
if !ok {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
out := make([]service.Account, len(accounts))
|
|
|
|
|
|
copy(out, accounts)
|
|
|
|
|
|
return out, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-25 21:01:58 +09:00
|
|
|
|
func (s *gatewayModelsAccountRepoStub) ListByGroup(ctx context.Context, groupID int64) ([]service.Account, error) {
|
|
|
|
|
|
return s.ListSchedulableByGroupID(ctx, groupID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-31 20:26:29 +08:00
|
|
|
|
func (s *gatewayModelsAccountRepoStub) ListModelAvailabilityCandidates(ctx context.Context, groupID *int64, _ []string, _ bool) ([]service.Account, error) {
|
|
|
|
|
|
if groupID == nil {
|
|
|
|
|
|
return nil, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
return s.ListSchedulableByGroupID(ctx, *groupID)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 22:54:05 +08:00
|
|
|
|
func newGatewayModelsHandlerForTest(repo service.AccountRepository, routeRepo ...service.CompositeModelRouteRepository) *GatewayHandler {
|
|
|
|
|
|
var resolver *service.CompositeRouteResolver
|
|
|
|
|
|
if len(routeRepo) > 0 {
|
|
|
|
|
|
resolver = service.NewCompositeRouteResolver(routeRepo[0])
|
|
|
|
|
|
}
|
2026-05-15 11:33:26 +08:00
|
|
|
|
return &GatewayHandler{
|
|
|
|
|
|
gatewayService: service.NewGatewayService(
|
|
|
|
|
|
repo,
|
|
|
|
|
|
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil,
|
2026-10-01 22:54:05 +08:00
|
|
|
|
nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, resolver, nil, nil,
|
2026-05-15 11:33:26 +08:00
|
|
|
|
),
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-10-01 22:54:05 +08:00
|
|
|
|
func TestGatewayCompositeRouteModelsAppearInBothCatalogs(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
const groupID int64 = 7784
|
|
|
|
|
|
accounts := &gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {{ID: 1, Platform: service.PlatformOpenAI, Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{"mapped-model": "upstream"},
|
|
|
|
|
|
}}},
|
|
|
|
|
|
}}
|
|
|
|
|
|
routes := &gatewayModelsRouteRepoStub{routes: []service.CompositeModelRoute{
|
|
|
|
|
|
{PublicModel: "route-only", MatchType: service.CompositeRouteMatchExact, Enabled: true},
|
|
|
|
|
|
{PublicModel: "route-only", MatchType: service.CompositeRouteMatchExact, Enabled: true},
|
|
|
|
|
|
{PublicModel: "mapped-model", MatchType: service.CompositeRouteMatchExact, Enabled: true},
|
|
|
|
|
|
{PublicModel: "image-only", MatchType: service.CompositeRouteMatchExact, Endpoint: service.CompositeRouteEndpointImages, Enabled: true},
|
|
|
|
|
|
{PublicModel: "prefix-", MatchType: service.CompositeRouteMatchPrefix, Enabled: true},
|
|
|
|
|
|
{PublicModel: "disabled", MatchType: service.CompositeRouteMatchExact, Enabled: false},
|
|
|
|
|
|
}}
|
|
|
|
|
|
for _, tc := range []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
repo service.CompositeModelRouteRepository
|
|
|
|
|
|
allowlist []string
|
|
|
|
|
|
want []string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{"routes", routes, nil, []string{"mapped-model", "route-only", "image-only"}},
|
|
|
|
|
|
{"allowlist", routes, []string{"route-only", "disabled", "unknown"}, []string{"route-only"}},
|
|
|
|
|
|
{"lookup failure", &gatewayModelsRouteRepoStub{err: errors.New("unavailable")}, nil, []string{"mapped-model"}},
|
|
|
|
|
|
{"no route repo", nil, nil, []string{"mapped-model"}},
|
|
|
|
|
|
} {
|
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(accounts, tc.repo)
|
|
|
|
|
|
group := &service.Group{ID: groupID, Platform: service.PlatformComposite}
|
|
|
|
|
|
if tc.allowlist != nil {
|
|
|
|
|
|
group.ModelAllowlist = service.GroupModelAllowlist{Enabled: true, Models: tc.allowlist}
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, endpoint := range []string{"models", "codex"} {
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
|
|
|
|
|
if endpoint == "models" {
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, tc.want, modelIDsForTest(got.Data))
|
|
|
|
|
|
} else {
|
|
|
|
|
|
h.CodexModels(c)
|
|
|
|
|
|
var got codexModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
want := tc.want
|
|
|
|
|
|
if tc.name == "routes" {
|
|
|
|
|
|
want = []string{"mapped-model", "route-only"}
|
|
|
|
|
|
}
|
|
|
|
|
|
require.ElementsMatch(t, want, codexModelSlugsForTest(got.Models))
|
|
|
|
|
|
}
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 10:20:51 +08:00
|
|
|
|
func TestDefaultModelIDsForCompositeIncludesAntigravityDefaults(t *testing.T) {
|
|
|
|
|
|
antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity)
|
|
|
|
|
|
require.NotEmpty(t, antigravityIDs)
|
|
|
|
|
|
|
|
|
|
|
|
compositeIDs := defaultModelIDsForPlatform(service.PlatformComposite)
|
|
|
|
|
|
require.Contains(t, compositeIDs, antigravityIDs[0])
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 09:31:56 +09:00
|
|
|
|
// Scenario: Anthropic defaults contain only Claude while Antigravity keeps its own Gemini models.
|
|
|
|
|
|
func TestDefaultModelIDsForAnthropicExcludeAntigravityGemini(t *testing.T) {
|
|
|
|
|
|
anthropicIDs := defaultModelIDsForPlatform(service.PlatformAnthropic)
|
|
|
|
|
|
require.Contains(t, anthropicIDs, "claude-opus-4-6")
|
|
|
|
|
|
require.NotContains(t, anthropicIDs, "gemini-2.5-flash")
|
|
|
|
|
|
|
|
|
|
|
|
antigravityIDs := defaultModelIDsForPlatform(service.PlatformAntigravity)
|
|
|
|
|
|
require.Contains(t, antigravityIDs, "gemini-2.5-flash")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 01:41:04 +09:00
|
|
|
|
// Scenario: non-OpenAI groups return a Codex manifest instead of a standard model list.
|
|
|
|
|
|
func TestGatewayCodexModels_NonOpenAIGroupsUseMappedModels(t *testing.T) {
|
|
|
|
|
|
tests := []struct {
|
2026-08-21 07:20:08 +09:00
|
|
|
|
name string
|
|
|
|
|
|
platform string
|
|
|
|
|
|
model string
|
|
|
|
|
|
efforts []string
|
|
|
|
|
|
modalities []string
|
2026-08-21 01:41:04 +09:00
|
|
|
|
}{
|
2026-08-21 07:20:08 +09:00
|
|
|
|
{
|
|
|
|
|
|
name: "Grok",
|
|
|
|
|
|
platform: service.PlatformGrok,
|
|
|
|
|
|
model: "grok-4.6",
|
|
|
|
|
|
efforts: []string{"low", "medium", "high", "xhigh"},
|
|
|
|
|
|
modalities: []string{"text", "image"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "DeepSeek",
|
|
|
|
|
|
platform: service.PlatformDeepseek,
|
|
|
|
|
|
model: "deepseek-v4-pro",
|
|
|
|
|
|
efforts: []string{"low", "high", "max"},
|
|
|
|
|
|
modalities: []string{"text"},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "provider-qualified Claude",
|
|
|
|
|
|
platform: service.PlatformAnthropic,
|
|
|
|
|
|
model: "anthropic/claude-sonnet-4-6",
|
|
|
|
|
|
efforts: []string{"low", "medium", "high", "max"},
|
|
|
|
|
|
modalities: []string{"text"},
|
|
|
|
|
|
},
|
2026-08-21 01:41:04 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for index, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
groupID := int64(100 + index)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: tt.platform,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{tt.model: tt.model},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: tt.platform},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.CodexModels(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got codexModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Len(t, got.Models, 1)
|
|
|
|
|
|
require.Equal(t, tt.model, got.Models[0].Slug)
|
2026-08-21 07:20:08 +09:00
|
|
|
|
require.NotEmpty(t, got.Models[0].ModelMessages)
|
|
|
|
|
|
require.NotEmpty(t, got.Models[0].TruncationPolicy)
|
|
|
|
|
|
require.NotNil(t, got.Models[0].AvailabilityNUX)
|
|
|
|
|
|
require.NotNil(t, got.Models[0].Upgrade)
|
|
|
|
|
|
require.Equal(t, tt.efforts, codexReasoningEffortsForTest(got.Models[0].SupportedReasoningLevels))
|
|
|
|
|
|
require.Equal(t, tt.modalities, got.Models[0].InputModalities)
|
2026-08-21 01:41:04 +09:00
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-06 02:02:49 +08:00
|
|
|
|
// Composite manifests include defaults from unmapped accounts and explicit mappings.
|
2026-08-21 01:41:04 +09:00
|
|
|
|
func TestGatewayCodexModels_CompositeUsesCompleteEffectiveModelList(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
const groupID int64 = 120
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
2026-08-21 20:02:58 +09:00
|
|
|
|
{
|
|
|
|
|
|
ID: 3,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
|
|
|
|
|
Status: service.StatusActive,
|
|
|
|
|
|
Schedulable: true,
|
|
|
|
|
|
Credentials: map[string]any{},
|
|
|
|
|
|
},
|
2026-08-21 01:41:04 +09:00
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 2,
|
|
|
|
|
|
Platform: service.PlatformGrok,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.CodexModels(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got codexModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
2026-09-06 02:02:49 +08:00
|
|
|
|
want := service.FilterCodexModelIDsForGroup(openai.DefaultModelIDs(), nil)
|
|
|
|
|
|
require.ElementsMatch(t, append(want, "grok-4.6"), codexModelSlugsForTest(got.Models))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestGatewayModels_UnmappedOpenAIAccountsSupplementMappedModels(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
const groupID int64 = 28
|
|
|
|
|
|
const sparkModel = "gpt-5.3-codex-spark"
|
|
|
|
|
|
const alias = "team-coder"
|
|
|
|
|
|
parentID := int64(1)
|
|
|
|
|
|
accounts := []service.Account{
|
|
|
|
|
|
{ID: parentID, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 2, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
|
|
|
|
|
|
ParentAccountID: &parentID, QuotaDimension: "spark",
|
|
|
|
|
|
Credentials: map[string]any{"model_mapping": map[string]any{sparkModel: sparkModel}},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 3, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey,
|
|
|
|
|
|
Credentials: map[string]any{"model_mapping": map[string]any{alias: "gpt-5.6-sol"}},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
accounts []service.Account
|
2026-09-07 19:44:03 +08:00
|
|
|
|
config service.GroupModelAllowlist
|
2026-09-06 02:02:49 +08:00
|
|
|
|
want []string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "unmapped parent and Spark shadow retain defaults and aliases",
|
|
|
|
|
|
accounts: accounts,
|
|
|
|
|
|
want: append(openai.DefaultModelIDs(), alias),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "unmapped API key account also contributes defaults",
|
|
|
|
|
|
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}}, accounts[1:]...),
|
|
|
|
|
|
want: append(openai.DefaultModelIDs(), alias),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "unmapped accounts alone retain default response shape",
|
|
|
|
|
|
accounts: accounts[:1],
|
|
|
|
|
|
want: openai.DefaultModelIDs(),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "custom list can select defaults and aliases",
|
|
|
|
|
|
accounts: accounts,
|
2026-09-07 19:44:03 +08:00
|
|
|
|
config: service.GroupModelAllowlist{Enabled: true, Models: []string{alias, "gpt-5.6-sol", sparkModel, "unknown-model"}},
|
2026-09-06 02:02:49 +08:00
|
|
|
|
want: []string{alias, "gpt-5.6-sol", sparkModel},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "unavailable custom selection remains empty",
|
|
|
|
|
|
accounts: accounts,
|
2026-09-07 19:44:03 +08:00
|
|
|
|
config: service.GroupModelAllowlist{Enabled: true, Models: []string{"unknown-model"}},
|
2026-09-06 02:02:49 +08:00
|
|
|
|
want: []string{},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
name: "mapped accounts alone do not gain defaults",
|
|
|
|
|
|
accounts: accounts[1:],
|
|
|
|
|
|
want: []string{sparkModel, alias},
|
|
|
|
|
|
},
|
2026-09-23 11:51:06 +08:00
|
|
|
|
{
|
|
|
|
|
|
// A passthrough account with a stale mapping behaves like an unmapped
|
|
|
|
|
|
// one: it adds the defaults but never its own mapping keys, and it no
|
|
|
|
|
|
// longer hides the aliases declared on ordinary accounts.
|
|
|
|
|
|
name: "passthrough account contributes defaults without hiding mapped aliases",
|
|
|
|
|
|
accounts: append([]service.Account{{
|
|
|
|
|
|
ID: 5, Platform: service.PlatformOpenAI, Type: service.AccountTypeOAuth,
|
|
|
|
|
|
Credentials: map[string]any{"model_mapping": map[string]any{"stale-model": "stale-model"}},
|
|
|
|
|
|
Extra: map[string]any{"openai_passthrough": true},
|
|
|
|
|
|
}}, accounts[1:]...),
|
|
|
|
|
|
want: append(openai.DefaultModelIDs(), alias),
|
|
|
|
|
|
},
|
2026-09-06 02:02:49 +08:00
|
|
|
|
{
|
|
|
|
|
|
name: "unmapped accounts from another platform do not add defaults",
|
|
|
|
|
|
accounts: append([]service.Account{{ID: 4, Platform: service.PlatformAnthropic}}, accounts[1:]...),
|
|
|
|
|
|
want: []string{sparkModel, alias},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{groupID: tt.accounts},
|
|
|
|
|
|
})
|
|
|
|
|
|
for range 2 {
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
2026-09-07 19:44:03 +08:00
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, ModelAllowlist: tt.config},
|
2026-09-06 02:02:49 +08:00
|
|
|
|
})
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, "list", got.Object)
|
|
|
|
|
|
require.ElementsMatch(t, tt.want, modelIDsForTest(got.Data))
|
|
|
|
|
|
for _, model := range got.Data {
|
|
|
|
|
|
require.Equal(t, "model", model.Object, model.ID)
|
|
|
|
|
|
require.Positive(t, model.Created, model.ID)
|
|
|
|
|
|
require.Equal(t, "openai", model.OwnedBy, model.ID)
|
|
|
|
|
|
require.Empty(t, model.CreatedAt, model.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
if tt.config.Enabled {
|
|
|
|
|
|
require.Equal(t, tt.want, modelIDsForTest(got.Data))
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
require.Empty(t, accounts[0].GetModelMapping())
|
|
|
|
|
|
require.True(t, accounts[0].IsModelSupported("gpt-future-model"))
|
|
|
|
|
|
require.False(t, accounts[1].IsModelSupported("gpt-5.6-sol"))
|
2026-08-21 01:41:04 +09:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 07:20:08 +09:00
|
|
|
|
func TestGatewayCodexModels_GeneratedManifestUsesFinalBodyETag(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
const groupID int64 = 122
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformDeepseek,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{"deepseek-v4-pro": "deepseek-v4-pro"},
|
|
|
|
|
|
},
|
|
|
|
|
|
}},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
group := &service.Group{ID: groupID, Platform: service.PlatformDeepseek}
|
|
|
|
|
|
|
|
|
|
|
|
first := httptest.NewRecorder()
|
|
|
|
|
|
firstContext, _ := gin.CreateTestContext(first)
|
|
|
|
|
|
firstContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
|
|
|
|
|
firstContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
|
|
|
|
|
h.CodexModels(firstContext)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, first.Code)
|
|
|
|
|
|
etag := first.Header().Get("ETag")
|
|
|
|
|
|
require.NotEmpty(t, etag)
|
|
|
|
|
|
require.Equal(t, service.CodexModelsManifestETag(first.Body.Bytes()), etag)
|
|
|
|
|
|
|
|
|
|
|
|
second := httptest.NewRecorder()
|
|
|
|
|
|
secondContext, _ := gin.CreateTestContext(second)
|
|
|
|
|
|
secondContext.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
|
|
|
|
|
secondContext.Request.Header.Set("If-None-Match", "W/"+etag)
|
|
|
|
|
|
secondContext.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: group})
|
|
|
|
|
|
h.CodexModels(secondContext)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusNotModified, second.Code)
|
|
|
|
|
|
require.Empty(t, second.Body.Bytes())
|
|
|
|
|
|
require.Equal(t, etag, second.Header().Get("ETag"))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-09-05 15:58:26 +08:00
|
|
|
|
// Scenario: group model_allowlist limits the generated Codex manifest.
|
2026-08-21 01:41:04 +09:00
|
|
|
|
func TestGatewayCodexModels_CustomModelsListFiltersCompositeManifest(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
const groupID int64 = 121
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{"gpt-5.5": "gpt-5.5"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 2,
|
|
|
|
|
|
Platform: service.PlatformGrok,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{"grok-4.6": "grok-4.6"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformComposite,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-08-21 01:41:04 +09:00
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Models: []string{"grok-4.6"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.CodexModels(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got codexModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, []string{"grok-4.6"}, codexModelSlugsForTest(got.Models))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func codexModelSlugsForTest(models []struct {
|
2026-08-21 07:20:08 +09:00
|
|
|
|
Slug string `json:"slug"`
|
|
|
|
|
|
SupportedReasoningLevels []codexReasoningLevelForTest `json:"supported_reasoning_levels"`
|
|
|
|
|
|
InputModalities []string `json:"input_modalities"`
|
|
|
|
|
|
ModelMessages map[string]json.RawMessage `json:"model_messages"`
|
|
|
|
|
|
TruncationPolicy map[string]json.RawMessage `json:"truncation_policy"`
|
|
|
|
|
|
AvailabilityNUX json.RawMessage `json:"availability_nux"`
|
|
|
|
|
|
Upgrade json.RawMessage `json:"upgrade"`
|
2026-08-21 01:41:04 +09:00
|
|
|
|
}) []string {
|
|
|
|
|
|
slugs := make([]string, 0, len(models))
|
|
|
|
|
|
for _, model := range models {
|
|
|
|
|
|
slugs = append(slugs, model.Slug)
|
|
|
|
|
|
}
|
|
|
|
|
|
return slugs
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 07:20:08 +09:00
|
|
|
|
func codexReasoningEffortsForTest(levels []codexReasoningLevelForTest) []string {
|
|
|
|
|
|
efforts := make([]string, 0, len(levels))
|
|
|
|
|
|
for _, level := range levels {
|
|
|
|
|
|
efforts = append(efforts, level.Effort)
|
|
|
|
|
|
}
|
|
|
|
|
|
return efforts
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 11:33:26 +08:00
|
|
|
|
func TestGatewayModels_GeminiGroupFallsBackToGeminiModels(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(20)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{ID: 1, Platform: service.PlatformGemini},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformGemini},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, "list", got.Object)
|
|
|
|
|
|
require.Contains(t, modelIDsForTest(got.Data), "gemini-2.5-flash")
|
|
|
|
|
|
require.NotContains(t, modelIDsForTest(got.Data), "claude-sonnet-4-6")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-16 16:52:25 +08:00
|
|
|
|
func TestGatewayModels_Grok45AdvertisesReasoningEffortForGrokBuild(t *testing.T) {
|
2026-08-27 15:48:06 +08:00
|
|
|
|
assertGrokGatewayReasoningEfforts(t, 4409, "grok-4.5", []gatewayReasoningEffortOptionForTest{
|
|
|
|
|
|
{Value: "low", Label: "Low"},
|
|
|
|
|
|
{Value: "medium", Label: "Medium"},
|
|
|
|
|
|
{Value: "high", Label: "High", Default: true},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestGatewayModels_Grok46AdvertisesXHighReasoningEffortForGrokBuild(t *testing.T) {
|
|
|
|
|
|
xhighEfforts := []gatewayReasoningEffortOptionForTest{
|
|
|
|
|
|
{Value: "low", Label: "Low"},
|
|
|
|
|
|
{Value: "medium", Label: "Medium"},
|
|
|
|
|
|
{Value: "high", Label: "High", Default: true},
|
|
|
|
|
|
{Value: "xhigh", Label: "xHigh"},
|
|
|
|
|
|
}
|
|
|
|
|
|
tests := []struct {
|
|
|
|
|
|
groupID int64
|
|
|
|
|
|
model string
|
|
|
|
|
|
}{
|
|
|
|
|
|
{groupID: 4410, model: "grok-4.6"},
|
|
|
|
|
|
{groupID: 4411, model: "grok-4.6-latest"},
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, tt := range tests {
|
|
|
|
|
|
t.Run(tt.model, func(t *testing.T) {
|
|
|
|
|
|
assertGrokGatewayReasoningEfforts(t, tt.groupID, tt.model, xhighEfforts)
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func assertGrokGatewayReasoningEfforts(t *testing.T, groupID int64, modelID string, want []gatewayReasoningEffortOptionForTest) {
|
|
|
|
|
|
t.Helper()
|
2026-07-16 16:52:25 +08:00
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformGrok,
|
|
|
|
|
|
Credentials: map[string]any{
|
2026-08-27 15:48:06 +08:00
|
|
|
|
"model_mapping": map[string]any{modelID: modelID},
|
2026-07-16 16:52:25 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformGrok},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Len(t, got.Data, 1)
|
|
|
|
|
|
model := got.Data[0]
|
2026-08-27 15:48:06 +08:00
|
|
|
|
require.Equal(t, modelID, model.ID)
|
2026-07-16 16:52:25 +08:00
|
|
|
|
require.True(t, model.SupportsReasoningEffort)
|
|
|
|
|
|
require.Equal(t, "high", model.ReasoningEffort)
|
2026-08-27 15:48:06 +08:00
|
|
|
|
require.Equal(t, want, model.ReasoningEfforts)
|
2026-07-16 16:52:25 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 11:33:26 +08:00
|
|
|
|
func TestGatewayModels_GeminiGroupFiltersMappedModelsByPlatform(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(21)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 2,
|
|
|
|
|
|
Platform: service.PlatformGemini,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"gemini-2.5-flash": "gemini-2.5-flash",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformGemini},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, []string{"gemini-2.5-flash"}, modelIDsForTest(got.Data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-21 09:31:56 +09:00
|
|
|
|
// Scenario: a Composite group with only Anthropic accounts must not inherit Antigravity Gemini defaults.
|
|
|
|
|
|
func TestGatewayCodexModels_CompositeAnthropicDoesNotAdvertiseAntigravityDefaults(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(64)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {{ID: 1, Platform: service.PlatformAnthropic}},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.CodexModels(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got codexModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
slugs := codexModelSlugsForTest(got.Models)
|
|
|
|
|
|
require.Contains(t, slugs, "claude-opus-4-6")
|
|
|
|
|
|
require.NotContains(t, slugs, "gemini-2.5-flash")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Scenario: Antigravity retains its own Claude and Gemini defaults inside Composite groups.
|
|
|
|
|
|
func TestGatewayModels_CompositeAntigravityAdvertisesAntigravityDefaults(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(65)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {{ID: 1, Platform: service.PlatformAntigravity}},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
ids := modelIDsForTest(got.Data)
|
|
|
|
|
|
require.Contains(t, ids, "claude-opus-4-6")
|
|
|
|
|
|
require.Contains(t, ids, "gemini-2.5-flash")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 17:56:31 +08:00
|
|
|
|
func TestGatewayModels_CustomModelsListDisabledKeepsOriginalModels(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(22)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"gpt-5.5": "gpt-5.5",
|
|
|
|
|
|
"gpt-5.4": "gpt-5.4",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-05-27 17:56:31 +08:00
|
|
|
|
Enabled: false,
|
|
|
|
|
|
Models: []string{"gpt-5.5"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, []string{"gpt-5.4", "gpt-5.5"}, modelIDsForTest(got.Data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestGatewayModels_CustomModelsListFiltersAndOrdersMappedModels(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(23)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"gpt-5.4": "gpt-5.4",
|
|
|
|
|
|
"gpt-5.5": "gpt-5.5",
|
|
|
|
|
|
"legacy-gpt-2024": "legacy-gpt-2024",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-05-27 17:56:31 +08:00
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Models: []string{"gpt-5.5", "missing-model", "gpt-5.4"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, []string{"gpt-5.5", "gpt-5.4"}, modelIDsForTest(got.Data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 08:25:47 +08:00
|
|
|
|
func TestGatewayModels_CompositeCustomModelsListFiltersAcrossConcretePlatforms(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(33)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"gpt-5.4": "gpt-5.4",
|
|
|
|
|
|
"gpt-5.5": "gpt-5.5",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 2,
|
|
|
|
|
|
Platform: service.PlatformGemini,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"gemini-2.5-flash": "gemini-2.5-flash",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-07-01 10:20:51 +08:00
|
|
|
|
{
|
|
|
|
|
|
ID: 3,
|
|
|
|
|
|
Platform: service.PlatformAntigravity,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"ag-custom-model": "ag-custom-model",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-08-19 13:01:25 +08:00
|
|
|
|
{
|
|
|
|
|
|
ID: 4,
|
|
|
|
|
|
Platform: service.PlatformKimi,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{"kimi-custom": "kimi-upstream"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 5,
|
|
|
|
|
|
Platform: service.PlatformZhipu,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{"glm-custom": "glm-upstream"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 6,
|
|
|
|
|
|
Platform: service.PlatformDeepseek,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{"deepseek-custom": "deepseek-upstream"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-09-08 17:41:29 +08:00
|
|
|
|
{
|
|
|
|
|
|
ID: 7,
|
|
|
|
|
|
Platform: service.PlatformMiniMax,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{"minimax-custom": "MiniMax-M3"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-07-01 08:25:47 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformComposite,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-07-01 08:25:47 +08:00
|
|
|
|
Enabled: true,
|
2026-09-08 17:41:29 +08:00
|
|
|
|
Models: []string{"gemini-2.5-flash", "missing-model", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"},
|
2026-07-01 08:25:47 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
2026-09-08 17:41:29 +08:00
|
|
|
|
require.Equal(t, []string{"gemini-2.5-flash", "ag-custom-model", "gpt-5.5", "kimi-custom", "glm-custom", "deepseek-custom", "minimax-custom"}, modelIDsForTest(got.Data))
|
2026-07-01 08:25:47 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-06 20:24:33 +08:00
|
|
|
|
func TestGatewayModels_CompositeUnmappedAccountsFallbackToLinkedPlatformsOnly(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(34)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{ID: 1, Platform: service.PlatformOpenAI},
|
|
|
|
|
|
{ID: 2, Platform: service.PlatformGrok},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
|
|
|
|
|
|
ids := modelIDsForTest(got.Data)
|
|
|
|
|
|
require.Contains(t, ids, "gpt-5.5")
|
|
|
|
|
|
require.Contains(t, ids, "grok-4.3")
|
|
|
|
|
|
require.NotContains(t, ids, "claude-sonnet-4-6")
|
|
|
|
|
|
require.NotContains(t, ids, "gemini-2.5-flash")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-19 14:58:32 +08:00
|
|
|
|
// CN 供应商没有静态默认模型列表:composite 下无映射的可调度 CN 账号不得把
|
|
|
|
|
|
// defaultModelIDsForPlatform default 分支的 Claude 列表挂到 CN 平台名下。
|
|
|
|
|
|
func TestGatewayModels_CompositeUnmappedCNAccountsContributeNoDefaults(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(35)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{ID: 1, Platform: service.PlatformOpenAI},
|
|
|
|
|
|
{ID: 2, Platform: service.PlatformKimi},
|
|
|
|
|
|
{ID: 3, Platform: service.PlatformZhipu},
|
|
|
|
|
|
{ID: 4, Platform: service.PlatformDeepseek},
|
2026-09-08 17:41:29 +08:00
|
|
|
|
{ID: 5, Platform: service.PlatformMiniMax},
|
2026-08-19 14:58:32 +08:00
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformComposite},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
|
|
|
|
|
|
ids := modelIDsForTest(got.Data)
|
|
|
|
|
|
require.Contains(t, ids, "gpt-5.5")
|
|
|
|
|
|
require.NotContains(t, ids, "claude-sonnet-4-6")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 独立 CN 分组沿用 default 分支的 Claude 默认列表(Claude Code 客户端请求的
|
|
|
|
|
|
// 就是这些模型名并经账号 model_mapping 转换),composite 支持不得改变该回退。
|
|
|
|
|
|
func TestDefaultModelIDsForPlatform_CNProvidersKeepClaudeDefaults(t *testing.T) {
|
|
|
|
|
|
want := make([]string, 0, len(claude.DefaultModels))
|
|
|
|
|
|
for _, model := range claude.DefaultModels {
|
|
|
|
|
|
want = append(want, model.ID)
|
|
|
|
|
|
}
|
2026-09-07 18:22:56 +08:00
|
|
|
|
for _, platform := range []string{service.PlatformKimi, service.PlatformZhipu, service.PlatformDeepseek, service.PlatformMiniMax} {
|
2026-08-19 14:58:32 +08:00
|
|
|
|
require.Equal(t, want, defaultModelIDsForPlatform(platform), "platform=%s", platform)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-08-26 10:56:17 +09:00
|
|
|
|
func TestDefaultCodexModelIDsForPlatform_DeepSeekUsesDeepSeekModels(t *testing.T) {
|
2026-09-10 22:10:29 +08:00
|
|
|
|
require.Equal(t, []string{"deepseek-v4-pro", "deepseek-v4-flash", "deepseek-flash"}, defaultCodexModelIDsForPlatform(service.PlatformDeepseek))
|
2026-09-07 18:22:56 +08:00
|
|
|
|
require.Equal(t, []string{"MiniMax-M3", "MiniMax-M2.7", "MiniMax-M2.5"}, defaultCodexModelIDsForPlatform(service.PlatformMiniMax))
|
2026-08-26 10:56:17 +09:00
|
|
|
|
require.Equal(t, defaultModelIDsForPlatform(service.PlatformAnthropic), defaultCodexModelIDsForPlatform(service.PlatformAnthropic))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestGatewayCodexModels_DeepSeekWithoutMappingUsesDeepSeekDefaults(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
const groupID int64 = 130
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformDeepseek,
|
|
|
|
|
|
Status: service.StatusActive,
|
|
|
|
|
|
Schedulable: true,
|
|
|
|
|
|
Credentials: map[string]any{},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.CodexModels(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got codexModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
slugs := make([]string, 0, len(got.Models))
|
|
|
|
|
|
for _, model := range got.Models {
|
|
|
|
|
|
slugs = append(slugs, model.Slug)
|
|
|
|
|
|
}
|
|
|
|
|
|
require.Contains(t, slugs, "deepseek-v4-pro")
|
|
|
|
|
|
require.Contains(t, slugs, "deepseek-v4-flash")
|
|
|
|
|
|
require.NotContains(t, slugs, "claude-sonnet-4-6")
|
|
|
|
|
|
require.NotContains(t, slugs, "claude-opus-4-6")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestGatewayCodexModels_OmitsWildcardMappingKeys(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
const groupID int64 = 131
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformDeepseek,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"foo-*": "deepseek-v4-pro",
|
|
|
|
|
|
"deepseek-v4-pro": "deepseek-v4-pro",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.150.0", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformDeepseek},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.CodexModels(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got codexModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
slugs := make([]string, 0, len(got.Models))
|
|
|
|
|
|
for _, model := range got.Models {
|
|
|
|
|
|
slugs = append(slugs, model.Slug)
|
|
|
|
|
|
}
|
|
|
|
|
|
require.Equal(t, []string{"deepseek-v4-pro"}, slugs)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 17:56:31 +08:00
|
|
|
|
func TestGatewayModels_CustomModelsListKeepsConcreteModelAllowedByWildcardMapping(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(26)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"claude-*": "claude-sonnet-4-6",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-05-27 17:56:31 +08:00
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Models: []string{"claude-sonnet-4-6"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, []string{"claude-sonnet-4-6"}, modelIDsForTest(got.Data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-05 08:35:16 +08:00
|
|
|
|
func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeAndMappedDeepSeek(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(28)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
|
|
|
|
|
Type: service.AccountTypeOAuth,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 2,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
|
|
|
|
|
Type: service.AccountTypeAPIKey,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"deepseek-v4-pro": "deepseek-v4-pro",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-07-05 08:35:16 +08:00
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Models: []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, []string{"claude-fable-5", "claude-opus-4-8", "deepseek-v4-pro"}, modelIDsForTest(got.Data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestGatewayModels_AnthropicCustomModelsListDisabledKeepsMappedModelList(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(29)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
|
|
|
|
|
Type: service.AccountTypeOAuth,
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 2,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
|
|
|
|
|
Type: service.AccountTypeAPIKey,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"deepseek-v4-pro": "deepseek-v4-pro",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-07-05 08:35:16 +08:00
|
|
|
|
Enabled: false,
|
|
|
|
|
|
Models: []string{"claude-fable-5", "deepseek-v4-pro"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, []string{"deepseek-v4-pro"}, modelIDsForTest(got.Data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestGatewayModels_AnthropicCustomModelsListIncludesOAuthClaudeWithoutMappings(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(30)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
|
|
|
|
|
Type: service.AccountTypeOAuth,
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-07-05 08:35:16 +08:00
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Models: []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, []string{"claude-opus-4-6-thinking", "claude-sonnet-4-5"}, modelIDsForTest(got.Data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-27 17:56:31 +08:00
|
|
|
|
func TestGatewayModels_CustomModelsListCanReturnEmptyWhenSelectionsUnavailable(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(24)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"gpt-5.4": "gpt-5.4",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-05-27 17:56:31 +08:00
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Models: []string{"gpt-5.5"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Empty(t, modelIDsForTest(got.Data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestGatewayModels_CustomModelsListFiltersDefaultFallbackModels(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(25)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{ID: 1, Platform: service.PlatformOpenAI},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-05-27 17:56:31 +08:00
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Models: []string{"gpt-5.5", "legacy-gpt-2024", "gpt-5.4"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, []string{"gpt-5.5", "gpt-5.4"}, modelIDsForTest(got.Data))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func TestGatewayModels_OpenAICustomModelsListKeepsOpenAIResponseShapeForDefaultFallback(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(27)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{ID: 1, Platform: service.PlatformOpenAI},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{
|
|
|
|
|
|
ID: groupID,
|
|
|
|
|
|
Platform: service.PlatformOpenAI,
|
2026-09-05 15:58:26 +08:00
|
|
|
|
ModelAllowlist: service.GroupModelAllowlist{
|
2026-05-27 17:56:31 +08:00
|
|
|
|
Enabled: true,
|
|
|
|
|
|
Models: []string{"gpt-5.5", "gpt-5.4"},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, []string{"gpt-5.5", "gpt-5.4"}, modelIDsForTest(got.Data))
|
|
|
|
|
|
require.Equal(t, "model", got.Data[0].Object)
|
|
|
|
|
|
require.NotZero(t, got.Data[0].Created)
|
|
|
|
|
|
require.Equal(t, "openai", got.Data[0].OwnedBy)
|
|
|
|
|
|
require.Empty(t, got.Data[0].CreatedAt)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-15 11:33:26 +08:00
|
|
|
|
func modelIDsForTest(models []gatewayModelItemForTest) []string {
|
|
|
|
|
|
ids := make([]string, 0, len(models))
|
|
|
|
|
|
for _, model := range models {
|
|
|
|
|
|
ids = append(ids, model.ID)
|
|
|
|
|
|
}
|
|
|
|
|
|
return ids
|
|
|
|
|
|
}
|
2026-09-15 16:31:48 +08:00
|
|
|
|
|
|
|
|
|
|
// gemini 分组混合调度:组内 antigravity 账号的 gemini-* 映射也应出现在 /v1/models,
|
|
|
|
|
|
// 与路由层(listSchedulableAccountsOnce 的 useMixedScheduling)同源;claude-* 不带入,
|
|
|
|
|
|
// 非混合调度的异平台账号(anthropic)仍被过滤。
|
|
|
|
|
|
func TestGatewayModels_GeminiGroupIncludesAntigravityGeminiMappings(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(22)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformAntigravity,
|
|
|
|
|
|
Extra: map[string]any{"mixed_scheduling": true},
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
|
|
|
|
|
|
"gemini-synced-custom": "gemini-3.8-flash-high",
|
|
|
|
|
|
"gemini-3.8-flash-low": "gemini-3.8-flash-low",
|
|
|
|
|
|
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{ID: 3, Platform: service.PlatformAntigravity,
|
|
|
|
|
|
Extra: map[string]any{"mixed_scheduling": false},
|
|
|
|
|
|
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-disabled-only": "gemini-3.8-flash-high"}}},
|
|
|
|
|
|
{ID: 4, Platform: service.PlatformAntigravity,
|
|
|
|
|
|
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-unset-only": "gemini-3.8-flash-high"}}},
|
|
|
|
|
|
{ID: 5, Platform: service.PlatformGemini,
|
|
|
|
|
|
Credentials: map[string]any{"model_mapping": map[string]any{"gemini-native-only": "gemini-2.5-pro"}}},
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 2,
|
|
|
|
|
|
Platform: service.PlatformAnthropic,
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"claude-opus-4-6": "claude-opus-4-6",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformGemini},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
ids := modelIDsForTest(got.Data)
|
|
|
|
|
|
// 账号显式配置的 gemini-* 映射必须出现(resolveModelMapping 还会补一组 antigravity
|
|
|
|
|
|
// 默认透传别名,这里只断言显式条目与排除项,不锁定补齐后的完整集合)。
|
|
|
|
|
|
require.Contains(t, ids, "gemini-3.8-flash-high")
|
|
|
|
|
|
require.Contains(t, ids, "gemini-synced-custom")
|
|
|
|
|
|
require.Contains(t, ids, "gemini-3.8-flash-low")
|
|
|
|
|
|
require.Contains(t, ids, "gemini-native-only")
|
|
|
|
|
|
require.NotContains(t, ids, "gemini-disabled-only")
|
|
|
|
|
|
require.NotContains(t, ids, "gemini-unset-only")
|
|
|
|
|
|
// antigravity 账号的 claude-* 映射与 anthropic 账号的映射都不应出现在 gemini 分组。
|
|
|
|
|
|
require.NotContains(t, ids, "claude-sonnet-4-6")
|
|
|
|
|
|
require.NotContains(t, ids, "claude-opus-4-6")
|
|
|
|
|
|
for _, id := range ids {
|
|
|
|
|
|
require.True(t, strings.HasPrefix(id, "gemini-"), "unexpected non-gemini model on gemini group: %s", id)
|
|
|
|
|
|
}
|
|
|
|
|
|
// 没有回落到 geminicli 静态表。
|
|
|
|
|
|
require.NotContains(t, ids, "gemini-2.0-flash")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// antigravity 账号未配置 model_mapping 时使用 DefaultAntigravityModelMapping:
|
|
|
|
|
|
// gemini 分组应列出其中的 gemini-* 条目,而不是回落到 geminicli 静态表;claude-* 仍不出现。
|
|
|
|
|
|
func TestGatewayModels_GeminiGroupUsesAntigravityDefaultMappingWhenUnset(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(23)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{ID: 1, Platform: service.PlatformAntigravity, Extra: map[string]any{"mixed_scheduling": true}, Credentials: map[string]any{}},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformGemini},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
ids := modelIDsForTest(got.Data)
|
|
|
|
|
|
require.Contains(t, ids, "gemini-3.8-flash-high")
|
|
|
|
|
|
for _, id := range ids {
|
|
|
|
|
|
require.True(t, strings.HasPrefix(id, "gemini-"), "unexpected non-gemini model on gemini group: %s", id)
|
|
|
|
|
|
}
|
|
|
|
|
|
// geminicli 静态表独有条目不应出现(说明没有回落到默认列表)。
|
|
|
|
|
|
require.NotContains(t, ids, "gemini-2.0-flash")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Codex 通过 /models?client_version= 走 CodexModels,同样应看到混合调度账号的 gemini-* 映射。
|
|
|
|
|
|
func TestGatewayModels_CodexGeminiGroupListsAntigravityGeminiMappings(t *testing.T) {
|
|
|
|
|
|
gin.SetMode(gin.TestMode)
|
|
|
|
|
|
|
|
|
|
|
|
groupID := int64(24)
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(
|
|
|
|
|
|
&gatewayModelsAccountRepoStub{
|
|
|
|
|
|
byGroup: map[int64][]service.Account{
|
|
|
|
|
|
groupID: {
|
|
|
|
|
|
{
|
|
|
|
|
|
ID: 1,
|
|
|
|
|
|
Platform: service.PlatformAntigravity,
|
|
|
|
|
|
Extra: map[string]any{"mixed_scheduling": true},
|
|
|
|
|
|
Credentials: map[string]any{
|
|
|
|
|
|
"model_mapping": map[string]any{
|
|
|
|
|
|
"gemini-3.8-flash-high": "gemini-3.8-flash-high",
|
|
|
|
|
|
"gemini-synced-custom": "gemini-3.8-flash-high",
|
|
|
|
|
|
"claude-sonnet-4-6": "claude-sonnet-4-6",
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/models?client_version=0.147.0", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{
|
|
|
|
|
|
Group: &service.Group{ID: groupID, Platform: service.PlatformGemini},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
h.CodexModels(c)
|
|
|
|
|
|
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got codexModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
slugs := codexModelSlugsForTest(got.Models)
|
|
|
|
|
|
require.Contains(t, slugs, "gemini-3.8-flash-high")
|
|
|
|
|
|
require.Contains(t, slugs, "gemini-synced-custom")
|
|
|
|
|
|
require.NotContains(t, slugs, "claude-sonnet-4-6")
|
|
|
|
|
|
require.NotContains(t, slugs, "gemini-2.0-flash")
|
|
|
|
|
|
for _, slug := range slugs {
|
|
|
|
|
|
require.True(t, strings.HasPrefix(slug, "gemini-"), "unexpected non-gemini model on gemini group: %s", slug)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2026-09-23 03:21:53 +08:00
|
|
|
|
|
|
|
|
|
|
func TestGatewayModels_GPT6SolLunaDiscoveryRespectsGroupAndAccountRestrictions(t *testing.T) {
|
|
|
|
|
|
for _, tc := range []struct {
|
|
|
|
|
|
name string
|
|
|
|
|
|
selected []string
|
|
|
|
|
|
restricted bool
|
|
|
|
|
|
want []string
|
|
|
|
|
|
}{
|
2026-09-29 11:49:26 -07:00
|
|
|
|
{"selected and ordered", []string{"gpt-6.1-sol", "gpt-6-luna", "gpt-6-sol"}, false, []string{"gpt-6.1-sol", "gpt-6-luna", "gpt-6-sol"}},
|
2026-09-23 03:21:53 +08:00
|
|
|
|
{"group excludes new models", []string{"gpt-5.6-sol"}, false, []string{"gpt-5.6-sol"}},
|
2026-09-29 11:49:26 -07:00
|
|
|
|
{"account restricts new models", []string{"gpt-6.1-sol", "gpt-6-sol", "gpt-6-luna", "gpt-5.6-sol"}, true, []string{"gpt-5.6-sol"}},
|
2026-09-23 03:21:53 +08:00
|
|
|
|
} {
|
|
|
|
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
|
|
|
|
groupID := int64(25)
|
|
|
|
|
|
account := service.Account{ID: 1, Platform: service.PlatformOpenAI, Type: service.AccountTypeAPIKey}
|
|
|
|
|
|
if tc.restricted {
|
|
|
|
|
|
account.Credentials = map[string]any{"model_mapping": map[string]any{"gpt-5.6-sol": "gpt-5.6-sol"}}
|
|
|
|
|
|
}
|
|
|
|
|
|
h := newGatewayModelsHandlerForTest(&gatewayModelsAccountRepoStub{byGroup: map[int64][]service.Account{groupID: {account}}})
|
|
|
|
|
|
rec := httptest.NewRecorder()
|
|
|
|
|
|
c, _ := gin.CreateTestContext(rec)
|
|
|
|
|
|
c.Request = httptest.NewRequest(http.MethodGet, "/v1/models", nil)
|
|
|
|
|
|
c.Set(string(middleware2.ContextKeyAPIKey), &service.APIKey{Group: &service.Group{ID: groupID, Platform: service.PlatformOpenAI, ModelAllowlist: service.GroupModelAllowlist{Enabled: true, Models: tc.selected}}})
|
|
|
|
|
|
h.Models(c)
|
|
|
|
|
|
require.Equal(t, http.StatusOK, rec.Code)
|
|
|
|
|
|
var got gatewayModelsResponseForTest
|
|
|
|
|
|
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &got))
|
|
|
|
|
|
require.Equal(t, tc.want, modelIDsForTest(got.Data))
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|