| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678 |
- //go:build unit
- package service
- import (
- "testing"
- "github.com/Wei-Shaw/sub2api/internal/domain"
- "github.com/Wei-Shaw/sub2api/internal/pkg/xai"
- )
- func TestGrokAccountModelMappingCacheInvalidatesWithRuntimeSettings(t *testing.T) {
- original := xai.RuntimeModelMappingOptions()
- t.Cleanup(func() { xai.SetRuntimeModelMappingOptions(original) })
- account := &Account{Platform: PlatformGrok, Credentials: map[string]any{}}
- xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{})
- requireMappedModel(t, account, "claude-sonnet-4-5", "claude-sonnet-4-5")
- xai.SetRuntimeModelMappingOptions(xai.ModelMappingOptions{
- DefaultText: "grok-build-0.1",
- EnableCrossClientMap: true,
- })
- requireMappedModel(t, account, "claude-sonnet-4-5", "grok-build-0.1")
- }
- func requireMappedModel(t *testing.T, account *Account, requested, expected string) {
- t.Helper()
- if actual := account.GetMappedModel(requested); actual != expected {
- t.Fatalf("GetMappedModel(%q) = %q, want %q", requested, actual, expected)
- }
- }
- func TestMatchWildcard(t *testing.T) {
- tests := []struct {
- name string
- pattern string
- str string
- expected bool
- }{
- // 精确匹配
- {"exact match", "claude-sonnet-4-5", "claude-sonnet-4-5", true},
- {"exact mismatch", "claude-sonnet-4-5", "claude-opus-4-5", false},
- // 通配符匹配
- {"wildcard prefix match", "claude-*", "claude-sonnet-4-5", true},
- {"wildcard prefix match 2", "claude-*", "claude-opus-4-5-thinking", true},
- {"wildcard prefix mismatch", "claude-*", "gemini-3-flash", false},
- {"wildcard partial match", "gemini-3*", "gemini-3-flash", true},
- {"wildcard partial match 2", "gemini-3*", "gemini-3-pro-image", true},
- {"wildcard partial mismatch", "gemini-3*", "gemini-2.5-flash", false},
- // 边界情况
- {"empty pattern exact", "", "", true},
- {"empty pattern mismatch", "", "claude", false},
- {"single star", "*", "anything", true},
- {"star at end only", "abc*", "abcdef", true},
- {"star at end empty suffix", "abc*", "abc", true},
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result := matchWildcard(tt.pattern, tt.str)
- if result != tt.expected {
- t.Errorf("matchWildcard(%q, %q) = %v, want %v", tt.pattern, tt.str, result, tt.expected)
- }
- })
- }
- }
- func TestMatchWildcardMappingResult(t *testing.T) {
- tests := []struct {
- name string
- mapping map[string]string
- requestedModel string
- expected string
- matched bool
- }{
- // 精确匹配优先于通配符
- {
- name: "exact match takes precedence",
- mapping: map[string]string{
- "claude-sonnet-4-5": "claude-sonnet-4-5-exact",
- "claude-*": "claude-default",
- },
- requestedModel: "claude-sonnet-4-5",
- expected: "claude-sonnet-4-5-exact",
- matched: true,
- },
- // 最长通配符优先
- {
- name: "longer wildcard takes precedence",
- mapping: map[string]string{
- "claude-*": "claude-default",
- "claude-sonnet-*": "claude-sonnet-default",
- "claude-sonnet-4*": "claude-sonnet-4-series",
- },
- requestedModel: "claude-sonnet-4-5",
- expected: "claude-sonnet-4-series",
- matched: true,
- },
- // 单个通配符
- {
- name: "single wildcard",
- mapping: map[string]string{
- "claude-*": "claude-mapped",
- },
- requestedModel: "claude-opus-4-5",
- expected: "claude-mapped",
- matched: true,
- },
- // 无匹配返回原始模型
- {
- name: "no match returns original",
- mapping: map[string]string{
- "claude-*": "claude-mapped",
- },
- requestedModel: "gemini-3-flash",
- expected: "gemini-3-flash",
- matched: false,
- },
- // 空映射返回原始模型
- {
- name: "empty mapping returns original",
- mapping: map[string]string{},
- requestedModel: "claude-sonnet-4-5",
- expected: "claude-sonnet-4-5",
- matched: false,
- },
- // Gemini 模型映射
- {
- name: "gemini wildcard mapping",
- mapping: map[string]string{
- "gemini-3*": "gemini-3-pro-high",
- "gemini-2.5*": "gemini-2.5-flash",
- },
- requestedModel: "gemini-3-flash-preview",
- expected: "gemini-3-pro-high",
- matched: true,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- result, matched := matchWildcardMappingResult(tt.mapping, tt.requestedModel)
- if result != tt.expected || matched != tt.matched {
- t.Errorf("matchWildcardMappingResult(%v, %q) = (%q, %v), want (%q, %v)", tt.mapping, tt.requestedModel, result, matched, tt.expected, tt.matched)
- }
- })
- }
- }
- func TestAccountIsModelSupported(t *testing.T) {
- tests := []struct {
- name string
- platform string
- credentials map[string]any
- requestedModel string
- expected bool
- }{
- // 无映射 = 允许所有
- {
- name: "no mapping allows all",
- credentials: nil,
- requestedModel: "any-model",
- expected: true,
- },
- {
- name: "empty mapping allows all",
- credentials: map[string]any{},
- requestedModel: "any-model",
- expected: true,
- },
- // 精确匹配
- {
- name: "exact match supported",
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "claude-sonnet-4-5": "target-model",
- },
- },
- requestedModel: "claude-sonnet-4-5",
- expected: true,
- },
- {
- name: "exact match not supported",
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "claude-sonnet-4-5": "target-model",
- },
- },
- requestedModel: "claude-opus-4-5",
- expected: false,
- },
- // 通配符匹配
- {
- name: "wildcard match supported",
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "claude-*": "claude-sonnet-4-5",
- },
- },
- requestedModel: "claude-opus-4-5-thinking",
- expected: true,
- },
- {
- name: "gemini customtools alias matches normalized mapping",
- platform: PlatformGemini,
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "gemini-3.1-pro-preview": "gemini-3.1-pro-preview",
- },
- },
- requestedModel: "gemini-3.1-pro-preview-customtools",
- expected: true,
- },
- {
- name: "wildcard match not supported",
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "claude-*": "claude-sonnet-4-5",
- },
- },
- requestedModel: "gemini-3-flash",
- expected: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- account := &Account{
- Platform: tt.platform,
- Credentials: tt.credentials,
- }
- result := account.IsModelSupported(tt.requestedModel)
- if result != tt.expected {
- t.Errorf("IsModelSupported(%q) = %v, want %v", tt.requestedModel, result, tt.expected)
- }
- })
- }
- }
- func TestAccountGetMappedModel(t *testing.T) {
- tests := []struct {
- name string
- platform string
- credentials map[string]any
- requestedModel string
- expected string
- }{
- // 无映射 = 返回原始模型
- {
- name: "no mapping returns original",
- credentials: nil,
- requestedModel: "claude-sonnet-4-5",
- expected: "claude-sonnet-4-5",
- },
- {
- name: "no mapping preserves gemini customtools model",
- platform: PlatformGemini,
- credentials: nil,
- requestedModel: "gemini-3.1-pro-preview-customtools",
- expected: "gemini-3.1-pro-preview-customtools",
- },
- // 精确匹配
- {
- name: "exact match",
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "claude-sonnet-4-5": "target-model",
- },
- },
- requestedModel: "claude-sonnet-4-5",
- expected: "target-model",
- },
- // 通配符匹配(最长优先)
- {
- name: "wildcard longest match",
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "claude-*": "claude-default",
- "claude-sonnet-*": "claude-sonnet-mapped",
- },
- },
- requestedModel: "claude-sonnet-4-5",
- expected: "claude-sonnet-mapped",
- },
- // 无匹配返回原始模型
- {
- name: "gemini customtools alias resolves through normalized mapping",
- platform: PlatformGemini,
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "gemini-3.1-pro-preview": "gemini-3.1-pro-preview",
- },
- },
- requestedModel: "gemini-3.1-pro-preview-customtools",
- expected: "gemini-3.1-pro-preview",
- },
- {
- name: "gemini customtools exact mapping wins over normalized fallback",
- platform: PlatformGemini,
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "gemini-3.1-pro-preview": "gemini-3.1-pro-preview",
- "gemini-3.1-pro-preview-customtools": "gemini-3.1-pro-preview-customtools",
- },
- },
- requestedModel: "gemini-3.1-pro-preview-customtools",
- expected: "gemini-3.1-pro-preview-customtools",
- },
- {
- name: "no match returns original",
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "gemini-*": "gemini-mapped",
- },
- },
- requestedModel: "claude-sonnet-4-5",
- expected: "claude-sonnet-4-5",
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- account := &Account{
- Platform: tt.platform,
- Credentials: tt.credentials,
- }
- result := account.GetMappedModel(tt.requestedModel)
- if result != tt.expected {
- t.Errorf("GetMappedModel(%q) = %q, want %q", tt.requestedModel, result, tt.expected)
- }
- })
- }
- }
- func TestAccountGetModelMapping_AntigravityNormalizesGemini31ProAliases(t *testing.T) {
- t.Parallel()
- account := &Account{
- Platform: PlatformAntigravity,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- domain.AntigravityGemini31ProAgentModel: domain.AntigravityGemini31ProAgentModel,
- "gemini-3.1-pro-high": "gemini-3.1-pro-high",
- "gemini-3.1-pro-preview": "gemini-3.1-pro-high",
- },
- },
- }
- mapping := account.GetModelMapping()
- if got := mapping["gemini-3.1-pro"]; got != domain.AntigravityGemini31ProAgentModel {
- t.Fatalf("expected gemini-3.1-pro to map to %q, got %q", domain.AntigravityGemini31ProAgentModel, got)
- }
- if got := mapping["gemini-3.1-pro-high"]; got != domain.AntigravityGemini31ProAgentModel {
- t.Fatalf("expected gemini-3.1-pro-high to map to %q, got %q", domain.AntigravityGemini31ProAgentModel, got)
- }
- if got := mapping["gemini-3.1-pro-preview"]; got != domain.AntigravityGemini31ProAgentModel {
- t.Fatalf("expected gemini-3.1-pro-preview to map to %q, got %q", domain.AntigravityGemini31ProAgentModel, got)
- }
- }
- func TestAccountGetModelMapping_AntigravityPreservesGemini31ProOverrides(t *testing.T) {
- t.Parallel()
- account := &Account{
- Platform: PlatformAntigravity,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- domain.AntigravityGemini31ProAgentModel: domain.AntigravityGemini31ProAgentModel,
- "gemini-3.1-pro-high": "custom-high",
- "gemini-3.1-pro-preview": "custom-preview",
- },
- },
- }
- mapping := account.GetModelMapping()
- if got := mapping["gemini-3.1-pro-high"]; got != "custom-high" {
- t.Fatalf("expected gemini-3.1-pro-high override to be preserved, got %q", got)
- }
- if got := mapping["gemini-3.1-pro-preview"]; got != "custom-preview" {
- t.Fatalf("expected gemini-3.1-pro-preview override to be preserved, got %q", got)
- }
- if got := mapping["gemini-3.1-pro"]; got != domain.AntigravityGemini31ProAgentModel {
- t.Fatalf("expected gemini-3.1-pro alias to default to %q, got %q", domain.AntigravityGemini31ProAgentModel, got)
- }
- }
- func TestAccountGetModelMapping_AntigravityGemini31ProAliasesRespectWildcard(t *testing.T) {
- t.Parallel()
- account := &Account{
- Platform: PlatformAntigravity,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- domain.AntigravityGemini31ProAgentModel: domain.AntigravityGemini31ProAgentModel,
- "gemini-3.1-*": "custom-wildcard",
- },
- },
- }
- mapping := account.GetModelMapping()
- if got := mapping["gemini-3.1-pro"]; got != "" {
- t.Fatalf("expected gemini-3.1-pro exact alias to stay unset when wildcard exists, got %q", got)
- }
- if got := mapping["gemini-3.1-pro-high"]; got != "" {
- t.Fatalf("expected gemini-3.1-pro-high exact alias to stay unset when wildcard exists, got %q", got)
- }
- if got := mapping["gemini-3.1-pro-preview"]; got != "" {
- t.Fatalf("expected gemini-3.1-pro-preview exact alias to stay unset when wildcard exists, got %q", got)
- }
- }
- func TestAccountResolveMappedModel(t *testing.T) {
- tests := []struct {
- name string
- platform string
- credentials map[string]any
- requestedModel string
- expectedModel string
- expectedMatch bool
- }{
- {
- name: "no mapping reports unmatched",
- credentials: nil,
- requestedModel: "gpt-5.4",
- expectedModel: "gpt-5.4",
- expectedMatch: false,
- },
- {
- name: "exact passthrough mapping still counts as matched",
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "gpt-5.4": "gpt-5.4",
- },
- },
- requestedModel: "gpt-5.4",
- expectedModel: "gpt-5.4",
- expectedMatch: true,
- },
- {
- name: "wildcard passthrough mapping still counts as matched",
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "gpt-*": "gpt-5.4",
- },
- },
- requestedModel: "gpt-5.4",
- expectedModel: "gpt-5.4",
- expectedMatch: true,
- },
- {
- name: "gemini customtools alias reports normalized match",
- platform: PlatformGemini,
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "gemini-3.1-pro-preview": "gemini-3.1-pro-preview",
- },
- },
- requestedModel: "gemini-3.1-pro-preview-customtools",
- expectedModel: "gemini-3.1-pro-preview",
- expectedMatch: true,
- },
- {
- name: "gemini customtools exact mapping reports exact match",
- platform: PlatformGemini,
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "gemini-3.1-pro-preview": "gemini-3.1-pro-preview",
- "gemini-3.1-pro-preview-customtools": "gemini-3.1-pro-preview-customtools",
- },
- },
- requestedModel: "gemini-3.1-pro-preview-customtools",
- expectedModel: "gemini-3.1-pro-preview-customtools",
- expectedMatch: true,
- },
- {
- name: "missing mapping reports unmatched",
- credentials: map[string]any{
- "model_mapping": map[string]any{
- "gpt-5.2": "gpt-5.2",
- },
- },
- requestedModel: "gpt-5.4",
- expectedModel: "gpt-5.4",
- expectedMatch: false,
- },
- }
- for _, tt := range tests {
- t.Run(tt.name, func(t *testing.T) {
- account := &Account{
- Platform: tt.platform,
- Credentials: tt.credentials,
- }
- mappedModel, matched := account.ResolveMappedModel(tt.requestedModel)
- if mappedModel != tt.expectedModel || matched != tt.expectedMatch {
- t.Fatalf("ResolveMappedModel(%q) = (%q, %v), want (%q, %v)", tt.requestedModel, mappedModel, matched, tt.expectedModel, tt.expectedMatch)
- }
- })
- }
- }
- func TestAccountGetModelMapping_AntigravityEnsuresGeminiDefaultPassthroughs(t *testing.T) {
- account := &Account{
- Platform: PlatformAntigravity,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "gemini-3-pro-high": "gemini-3.1-pro-high",
- },
- },
- }
- mapping := account.GetModelMapping()
- if mapping["gemini-3-flash"] != "gemini-3-flash" {
- t.Fatalf("expected gemini-3-flash passthrough to be auto-filled, got: %q", mapping["gemini-3-flash"])
- }
- if mapping["gemini-3.1-pro-high"] != "gemini-3.1-pro-high" {
- t.Fatalf("expected gemini-3.1-pro-high passthrough to be auto-filled, got: %q", mapping["gemini-3.1-pro-high"])
- }
- if mapping["gemini-3.1-pro-low"] != "gemini-3.1-pro-low" {
- t.Fatalf("expected gemini-3.1-pro-low passthrough to be auto-filled, got: %q", mapping["gemini-3.1-pro-low"])
- }
- }
- func TestAccountGetModelMapping_GoogleOneUsesConservativeDefaults(t *testing.T) {
- account := &Account{
- Platform: PlatformGemini,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "oauth_type": "google_one",
- },
- }
- mapping := account.GetModelMapping()
- for _, model := range []string{"gemini-2.0-flash", "gemini-2.5-flash", "gemini-2.5-pro"} {
- if mapping[model] != model {
- t.Fatalf("expected Google One model %q to map to itself, got %q", model, mapping[model])
- }
- }
- for _, model := range []string{"gemini-2.5-flash-image", "gemini-3.1-flash-image", "gemini-3.5-flash"} {
- if _, ok := mapping[model]; ok {
- t.Fatalf("did not expect unsupported Google One model %q", model)
- }
- }
- if account.IsModelSupported("gemini-3.5-flash") {
- t.Fatal("Google One defaults must not treat unsupported models as eligible")
- }
- }
- func TestAccountGetModelMapping_GoogleOnePreservesExplicitMapping(t *testing.T) {
- account := &Account{
- Platform: PlatformGemini,
- Type: AccountTypeOAuth,
- Credentials: map[string]any{
- "oauth_type": "google_one",
- "model_mapping": map[string]any{
- "custom-model": "gemini-2.5-flash",
- },
- },
- }
- mapping := account.GetModelMapping()
- if mapping["custom-model"] != "gemini-2.5-flash" {
- t.Fatalf("expected explicit Google One mapping to be preserved, got %v", mapping)
- }
- if _, ok := mapping["gemini-2.5-flash"]; ok {
- t.Fatalf("did not expect defaults to overwrite an explicit mapping: %v", mapping)
- }
- }
- func TestAccountGetModelMapping_AntigravityRespectsWildcardOverride(t *testing.T) {
- account := &Account{
- Platform: PlatformAntigravity,
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "gemini-3*": "gemini-3.1-pro-high",
- },
- },
- }
- mapping := account.GetModelMapping()
- if _, exists := mapping["gemini-3-flash"]; exists {
- t.Fatalf("did not expect explicit gemini-3-flash passthrough when wildcard already exists")
- }
- if _, exists := mapping["gemini-3.1-pro-high"]; exists {
- t.Fatalf("did not expect explicit gemini-3.1-pro-high passthrough when wildcard already exists")
- }
- if _, exists := mapping["gemini-3.1-pro-low"]; exists {
- t.Fatalf("did not expect explicit gemini-3.1-pro-low passthrough when wildcard already exists")
- }
- if mapped := account.GetMappedModel("gemini-3-flash"); mapped != "gemini-3.1-pro-high" {
- t.Fatalf("expected wildcard mapping to stay effective, got: %q", mapped)
- }
- }
- func TestAccountGetModelMapping_CacheInvalidatesOnCredentialsReplace(t *testing.T) {
- account := &Account{
- Credentials: map[string]any{
- "model_mapping": map[string]any{
- "claude-3-5-sonnet": "upstream-a",
- },
- },
- }
- first := account.GetModelMapping()
- if first["claude-3-5-sonnet"] != "upstream-a" {
- t.Fatalf("unexpected first mapping: %v", first)
- }
- account.Credentials = map[string]any{
- "model_mapping": map[string]any{
- "claude-3-5-sonnet": "upstream-b",
- },
- }
- second := account.GetModelMapping()
- if second["claude-3-5-sonnet"] != "upstream-b" {
- t.Fatalf("expected cache invalidated after credentials replace, got: %v", second)
- }
- }
- func TestAccountGetModelMapping_CacheInvalidatesOnMappingLenChange(t *testing.T) {
- rawMapping := map[string]any{
- "claude-sonnet": "sonnet-a",
- }
- account := &Account{
- Credentials: map[string]any{
- "model_mapping": rawMapping,
- },
- }
- first := account.GetModelMapping()
- if len(first) != 1 {
- t.Fatalf("unexpected first mapping length: %d", len(first))
- }
- rawMapping["claude-opus"] = "opus-b"
- second := account.GetModelMapping()
- if second["claude-opus"] != "opus-b" {
- t.Fatalf("expected cache invalidated after mapping len change, got: %v", second)
- }
- }
- func TestAccountGetModelMapping_CacheInvalidatesOnInPlaceValueChange(t *testing.T) {
- rawMapping := map[string]any{
- "claude-sonnet": "sonnet-a",
- }
- account := &Account{
- Credentials: map[string]any{
- "model_mapping": rawMapping,
- },
- }
- first := account.GetModelMapping()
- if first["claude-sonnet"] != "sonnet-a" {
- t.Fatalf("unexpected first mapping: %v", first)
- }
- rawMapping["claude-sonnet"] = "sonnet-b"
- second := account.GetModelMapping()
- if second["claude-sonnet"] != "sonnet-b" {
- t.Fatalf("expected cache invalidated after in-place value change, got: %v", second)
- }
- }
|