From 2b97cb98b586d1bd4d9d9496205a9a40394f1018 Mon Sep 17 00:00:00 2001 From: xxddff <772327379@qq.com> Date: Tue, 10 Feb 2026 17:35:54 +0900 Subject: [PATCH 01/10] Delete 'user' field from raw JSON Remove the 'user' field from the raw JSON as requested. --- .../codex/openai/responses/codex_openai-responses_request.go | 3 +++ 1 file changed, 3 insertions(+) diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request.go b/internal/translator/codex/openai/responses/codex_openai-responses_request.go index 828c4d87..692cfaa6 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request.go @@ -27,6 +27,9 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p") rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier") + // Delete user field as requested + rawJSON, _ = sjson.DeleteBytes(rawJSON, "user") + // Convert role "system" to "developer" in input array to comply with Codex API requirements. rawJSON = convertSystemRoleToDeveloper(rawJSON) From 865af9f19ea90c2684b8e1703732a3451932f679 Mon Sep 17 00:00:00 2001 From: xxddff <772327379@qq.com> Date: Tue, 10 Feb 2026 17:38:49 +0900 Subject: [PATCH 02/10] Implement test for user field deletion Add test to verify deletion of user field in response --- .../codex_openai-responses_request_test.go | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go index ea413238..2d1d47a1 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go @@ -263,3 +263,20 @@ func TestConvertSystemRoleToDeveloper_AssistantRole(t *testing.T) { t.Errorf("Expected third role 'assistant', got '%s'", thirdRole.String()) } } + +func TestUserFieldDeletion(t *testing.T) { + inputJSON := []byte(`{ + "model": "gpt-5.2", + "user": "test-user", + "input": [{"role": "user", "content": "Hello"}] + }`) + + output := ConvertOpenAIResponsesRequestToCodex("gpt-5.2", inputJSON, false) + outputStr := string(output) + + // Verify user field is deleted + userField := gjson.Get(outputStr, "user") + if userField.Exists() { + t.Error("user field should be deleted") + } +} From afe4c1bfb7dfd2d0259ebc306e098c2cff33038d Mon Sep 17 00:00:00 2001 From: xxddff <772327379@qq.com> Date: Tue, 10 Feb 2026 18:24:26 +0900 Subject: [PATCH 03/10] =?UTF-8?q?=E6=9B=B4=E6=96=B0internal/translator/cod?= =?UTF-8?q?ex/openai/responses/codex=5Fopenai-responses=5Frequest.go?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../codex/openai/responses/codex_openai-responses_request.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request.go b/internal/translator/codex/openai/responses/codex_openai-responses_request.go index 692cfaa6..f0407149 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request.go @@ -27,8 +27,8 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p") rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier") - // Delete user field as requested - rawJSON, _ = sjson.DeleteBytes(rawJSON, "user") + // Delete the user field as it is not supported by the Codex upstream. + rawJSON, _ = sjson.DeleteBytes(rawJSON, "user") // Convert role "system" to "developer" in input array to comply with Codex API requirements. rawJSON = convertSystemRoleToDeveloper(rawJSON) From bb9fe52f1e8aa592fd7a5b3c40bd9dd1b8f7c38d Mon Sep 17 00:00:00 2001 From: xxddff <772327379@qq.com> Date: Tue, 10 Feb 2026 18:24:58 +0900 Subject: [PATCH 04/10] Update internal/translator/codex/openai/responses/codex_openai-responses_request_test.go Co-authored-by: gemini-code-assist[bot] <176961590+gemini-code-assist[bot]@users.noreply.github.com> --- .../openai/responses/codex_openai-responses_request_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go index 2d1d47a1..4f562486 100644 --- a/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go +++ b/internal/translator/codex/openai/responses/codex_openai-responses_request_test.go @@ -276,7 +276,7 @@ func TestUserFieldDeletion(t *testing.T) { // Verify user field is deleted userField := gjson.Get(outputStr, "user") - if userField.Exists() { - t.Error("user field should be deleted") - } + if userField.Exists() { + t.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw) + } } From 5ed2133ff9a96f5e51796ed2df6867a494a01bea Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Wed, 11 Feb 2026 15:21:12 +0800 Subject: [PATCH 05/10] feat: add per-account excluded_models and priority parsing --- internal/watcher/synthesizer/file.go | 61 +++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 2 deletions(-) diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index c80ebc66..20b2faec 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -5,6 +5,7 @@ import ( "fmt" "os" "path/filepath" + "strconv" "strings" "time" @@ -92,6 +93,9 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e status = coreauth.StatusDisabled } + // Read per-account excluded models from the OAuth JSON file + perAccountExcluded := extractExcludedModelsFromMetadata(metadata) + a := &coreauth.Auth{ ID: id, Provider: provider, @@ -108,11 +112,22 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e CreatedAt: now, UpdatedAt: now, } - ApplyAuthExcludedModelsMeta(a, cfg, nil, "oauth") + // Read priority from auth file + if rawPriority, ok := metadata["priority"]; ok { + switch v := rawPriority.(type) { + case float64: + a.Attributes["priority"] = strconv.Itoa(int(v)) + case string: + if _, err := strconv.Atoi(v); err == nil { + a.Attributes["priority"] = v + } + } + } + ApplyAuthExcludedModelsMeta(a, cfg, perAccountExcluded, "oauth") if provider == "gemini-cli" { if virtuals := SynthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 { for _, v := range virtuals { - ApplyAuthExcludedModelsMeta(v, cfg, nil, "oauth") + ApplyAuthExcludedModelsMeta(v, cfg, perAccountExcluded, "oauth") } out = append(out, a) out = append(out, virtuals...) @@ -167,6 +182,10 @@ func SynthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an if authPath != "" { attrs["path"] = authPath } + // Propagate priority from primary auth to virtual auths + if priorityVal, hasPriority := primary.Attributes["priority"]; hasPriority && priorityVal != "" { + attrs["priority"] = priorityVal + } metadataCopy := map[string]any{ "email": email, "project_id": projectID, @@ -239,3 +258,41 @@ func buildGeminiVirtualID(baseID, projectID string) string { replacer := strings.NewReplacer("/", "_", "\\", "_", " ", "_") return fmt.Sprintf("%s::%s", baseID, replacer.Replace(project)) } + +// extractExcludedModelsFromMetadata reads per-account excluded models from the OAuth JSON metadata. +// Supports both "excluded_models" and "excluded-models" keys, and accepts both []string and []interface{}. +func extractExcludedModelsFromMetadata(metadata map[string]any) []string { + if metadata == nil { + return nil + } + // Try both key formats + raw, ok := metadata["excluded_models"] + if !ok { + raw, ok = metadata["excluded-models"] + } + if !ok || raw == nil { + return nil + } + switch v := raw.(type) { + case []string: + result := make([]string, 0, len(v)) + for _, s := range v { + if trimmed := strings.TrimSpace(s); trimmed != "" { + result = append(result, trimmed) + } + } + return result + case []interface{}: + result := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + if trimmed := strings.TrimSpace(s); trimmed != "" { + result = append(result, trimmed) + } + } + } + return result + default: + return nil + } +} From b93026d83a8da573f4871c8a483287d2ea8c02d6 Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Wed, 11 Feb 2026 15:21:15 +0800 Subject: [PATCH 06/10] feat: merge per-account excluded_models with global config --- internal/watcher/synthesizer/helpers.go | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/internal/watcher/synthesizer/helpers.go b/internal/watcher/synthesizer/helpers.go index 621f3600..102dc77e 100644 --- a/internal/watcher/synthesizer/helpers.go +++ b/internal/watcher/synthesizer/helpers.go @@ -53,6 +53,8 @@ func (g *StableIDGenerator) Next(kind string, parts ...string) (string, string) // ApplyAuthExcludedModelsMeta applies excluded models metadata to an auth entry. // It computes a hash of excluded models and sets the auth_kind attribute. +// For OAuth entries, perKey (from the JSON file's excluded-models field) is merged +// with the global oauth-excluded-models config for the provider. func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) { if auth == nil || cfg == nil { return @@ -72,9 +74,13 @@ func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey } if authKindKey == "apikey" { add(perKey) - } else if cfg.OAuthExcludedModels != nil { - providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) - add(cfg.OAuthExcludedModels[providerKey]) + } else { + // For OAuth: merge per-account excluded models with global provider-level exclusions + add(perKey) + if cfg.OAuthExcludedModels != nil { + providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) + add(cfg.OAuthExcludedModels[providerKey]) + } } combined := make([]string, 0, len(seen)) for k := range seen { @@ -88,6 +94,10 @@ func ApplyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey if hash != "" { auth.Attributes["excluded_models_hash"] = hash } + // Store the combined excluded models list so that routing can read it at runtime + if len(combined) > 0 { + auth.Attributes["excluded_models"] = strings.Join(combined, ",") + } if authKind != "" { auth.Attributes["auth_kind"] = authKind } From 4cbcc835d1e7fc616a23a6d516e9cc68b1282d40 Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Wed, 11 Feb 2026 15:21:19 +0800 Subject: [PATCH 07/10] feat: read per-account excluded_models at routing time --- sdk/cliproxy/service.go | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 0ae05c08..b77de8c6 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -740,6 +740,26 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { provider = "openai-compatibility" } excluded := s.oauthExcludedModels(provider, authKind) + // Merge per-account excluded models from auth attributes (set by synthesizer) + if a.Attributes != nil { + if perAccount := strings.TrimSpace(a.Attributes["excluded_models"]); perAccount != "" { + parts := strings.Split(perAccount, ",") + seen := make(map[string]struct{}, len(excluded)+len(parts)) + for _, e := range excluded { + seen[strings.ToLower(strings.TrimSpace(e))] = struct{}{} + } + for _, p := range parts { + seen[strings.ToLower(strings.TrimSpace(p))] = struct{}{} + } + merged := make([]string, 0, len(seen)) + for k := range seen { + if k != "" { + merged = append(merged, k) + } + } + excluded = merged + } + } var models []*ModelInfo switch provider { case "gemini": From bf1634bda0fe3388a50e00ac227ad653639ec7e5 Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Wed, 11 Feb 2026 15:57:15 +0800 Subject: [PATCH 08/10] refactor: simplify per-account excluded_models merge in routing --- sdk/cliproxy/service.go | 21 ++++----------------- 1 file changed, 4 insertions(+), 17 deletions(-) diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index b77de8c6..536329b5 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -740,24 +740,11 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { provider = "openai-compatibility" } excluded := s.oauthExcludedModels(provider, authKind) - // Merge per-account excluded models from auth attributes (set by synthesizer) + // The synthesizer pre-merges per-account and global exclusions into the "excluded_models" attribute. + // If this attribute is present, it represents the complete list of exclusions and overrides the global config. if a.Attributes != nil { - if perAccount := strings.TrimSpace(a.Attributes["excluded_models"]); perAccount != "" { - parts := strings.Split(perAccount, ",") - seen := make(map[string]struct{}, len(excluded)+len(parts)) - for _, e := range excluded { - seen[strings.ToLower(strings.TrimSpace(e))] = struct{}{} - } - for _, p := range parts { - seen[strings.ToLower(strings.TrimSpace(p))] = struct{}{} - } - merged := make([]string, 0, len(seen)) - for k := range seen { - if k != "" { - merged = append(merged, k) - } - } - excluded = merged + if val, ok := a.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" { + excluded = strings.Split(val, ",") } } var models []*ModelInfo From dc279de443f60594c01efae29011ea59503f6aef Mon Sep 17 00:00:00 2001 From: RGBadmin Date: Wed, 11 Feb 2026 15:57:16 +0800 Subject: [PATCH 09/10] refactor: reduce code duplication in extractExcludedModelsFromMetadata --- internal/watcher/synthesizer/file.go | 23 +++++++++++------------ 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 20b2faec..8f4ec6da 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -273,26 +273,25 @@ func extractExcludedModelsFromMetadata(metadata map[string]any) []string { if !ok || raw == nil { return nil } + var stringSlice []string switch v := raw.(type) { case []string: - result := make([]string, 0, len(v)) - for _, s := range v { - if trimmed := strings.TrimSpace(s); trimmed != "" { - result = append(result, trimmed) - } - } - return result + stringSlice = v case []interface{}: - result := make([]string, 0, len(v)) + stringSlice = make([]string, 0, len(v)) for _, item := range v { if s, ok := item.(string); ok { - if trimmed := strings.TrimSpace(s); trimmed != "" { - result = append(result, trimmed) - } + stringSlice = append(stringSlice, s) } } - return result default: return nil } + result := make([]string, 0, len(stringSlice)) + for _, s := range stringSlice { + if trimmed := strings.TrimSpace(s); trimmed != "" { + result = append(result, trimmed) + } + } + return result } From 4c133d3ea9dc77b740b5b454d7bc582a1045b37b Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Wed, 11 Feb 2026 20:35:13 +0800 Subject: [PATCH 10/10] test(sdk/watcher): add tests for excluded models merging and priority parsing logic - Added unit tests for combining OAuth excluded models across global and attribute-specific scopes. - Implemented priority attribute parsing with support for different formats and trimming. --- internal/watcher/synthesizer/file.go | 5 +- internal/watcher/synthesizer/file_test.go | 118 +++++++++++++++++++ internal/watcher/synthesizer/helpers_test.go | 25 ++++ sdk/cliproxy/service_excluded_models_test.go | 65 ++++++++++ 4 files changed, 211 insertions(+), 2 deletions(-) create mode 100644 sdk/cliproxy/service_excluded_models_test.go diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index 8f4ec6da..4e053117 100644 --- a/internal/watcher/synthesizer/file.go +++ b/internal/watcher/synthesizer/file.go @@ -118,8 +118,9 @@ func (s *FileSynthesizer) Synthesize(ctx *SynthesisContext) ([]*coreauth.Auth, e case float64: a.Attributes["priority"] = strconv.Itoa(int(v)) case string: - if _, err := strconv.Atoi(v); err == nil { - a.Attributes["priority"] = v + priority := strings.TrimSpace(v) + if _, errAtoi := strconv.Atoi(priority); errAtoi == nil { + a.Attributes["priority"] = priority } } } diff --git a/internal/watcher/synthesizer/file_test.go b/internal/watcher/synthesizer/file_test.go index 93025fba..105d9207 100644 --- a/internal/watcher/synthesizer/file_test.go +++ b/internal/watcher/synthesizer/file_test.go @@ -297,6 +297,117 @@ func TestFileSynthesizer_Synthesize_PrefixValidation(t *testing.T) { } } +func TestFileSynthesizer_Synthesize_PriorityParsing(t *testing.T) { + tests := []struct { + name string + priority any + want string + hasValue bool + }{ + { + name: "string with spaces", + priority: " 10 ", + want: "10", + hasValue: true, + }, + { + name: "number", + priority: 8, + want: "8", + hasValue: true, + }, + { + name: "invalid string", + priority: "1x", + hasValue: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + tempDir := t.TempDir() + authData := map[string]any{ + "type": "claude", + "priority": tt.priority, + } + data, _ := json.Marshal(authData) + errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644) + if errWriteFile != nil { + t.Fatalf("failed to write auth file: %v", errWriteFile) + } + + synth := NewFileSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{}, + AuthDir: tempDir, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, errSynthesize := synth.Synthesize(ctx) + if errSynthesize != nil { + t.Fatalf("unexpected error: %v", errSynthesize) + } + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + + value, ok := auths[0].Attributes["priority"] + if tt.hasValue { + if !ok { + t.Fatal("expected priority attribute to be set") + } + if value != tt.want { + t.Fatalf("expected priority %q, got %q", tt.want, value) + } + return + } + if ok { + t.Fatalf("expected priority attribute to be absent, got %q", value) + } + }) + } +} + +func TestFileSynthesizer_Synthesize_OAuthExcludedModelsMerged(t *testing.T) { + tempDir := t.TempDir() + authData := map[string]any{ + "type": "claude", + "excluded_models": []string{"custom-model", "MODEL-B"}, + } + data, _ := json.Marshal(authData) + errWriteFile := os.WriteFile(filepath.Join(tempDir, "auth.json"), data, 0644) + if errWriteFile != nil { + t.Fatalf("failed to write auth file: %v", errWriteFile) + } + + synth := NewFileSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{ + OAuthExcludedModels: map[string][]string{ + "claude": {"shared", "model-b"}, + }, + }, + AuthDir: tempDir, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, errSynthesize := synth.Synthesize(ctx) + if errSynthesize != nil { + t.Fatalf("unexpected error: %v", errSynthesize) + } + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + + got := auths[0].Attributes["excluded_models"] + want := "custom-model,model-b,shared" + if got != want { + t.Fatalf("expected excluded_models %q, got %q", want, got) + } +} + func TestSynthesizeGeminiVirtualAuths_NilInputs(t *testing.T) { now := time.Now() @@ -533,6 +644,7 @@ func TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) { "type": "gemini", "email": "multi@example.com", "project_id": "project-a, project-b, project-c", + "priority": " 10 ", } data, _ := json.Marshal(authData) err := os.WriteFile(filepath.Join(tempDir, "gemini-multi.json"), data, 0644) @@ -565,6 +677,9 @@ func TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) { if primary.Status != coreauth.StatusDisabled { t.Errorf("expected primary status disabled, got %s", primary.Status) } + if gotPriority := primary.Attributes["priority"]; gotPriority != "10" { + t.Errorf("expected primary priority 10, got %q", gotPriority) + } // Remaining auths should be virtuals for i := 1; i < 4; i++ { @@ -575,6 +690,9 @@ func TestFileSynthesizer_Synthesize_MultiProjectGemini(t *testing.T) { if v.Attributes["gemini_virtual_parent"] != primary.ID { t.Errorf("expected virtual %d parent to be %s, got %s", i, primary.ID, v.Attributes["gemini_virtual_parent"]) } + if gotPriority := v.Attributes["priority"]; gotPriority != "10" { + t.Errorf("expected virtual %d priority 10, got %q", i, gotPriority) + } } } diff --git a/internal/watcher/synthesizer/helpers_test.go b/internal/watcher/synthesizer/helpers_test.go index 229c75bc..46b9c8a0 100644 --- a/internal/watcher/synthesizer/helpers_test.go +++ b/internal/watcher/synthesizer/helpers_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/router-for-me/CLIProxyAPI/v6/internal/config" + "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher/diff" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" ) @@ -200,6 +201,30 @@ func TestApplyAuthExcludedModelsMeta(t *testing.T) { } } +func TestApplyAuthExcludedModelsMeta_OAuthMergeWritesCombinedModels(t *testing.T) { + auth := &coreauth.Auth{ + Provider: "claude", + Attributes: make(map[string]string), + } + cfg := &config.Config{ + OAuthExcludedModels: map[string][]string{ + "claude": {"global-a", "shared"}, + }, + } + + ApplyAuthExcludedModelsMeta(auth, cfg, []string{"per", "SHARED"}, "oauth") + + const wantCombined = "global-a,per,shared" + if gotCombined := auth.Attributes["excluded_models"]; gotCombined != wantCombined { + t.Fatalf("expected excluded_models=%q, got %q", wantCombined, gotCombined) + } + + expectedHash := diff.ComputeExcludedModelsHash([]string{"global-a", "per", "shared"}) + if gotHash := auth.Attributes["excluded_models_hash"]; gotHash != expectedHash { + t.Fatalf("expected excluded_models_hash=%q, got %q", expectedHash, gotHash) + } +} + func TestAddConfigHeadersToAttrs(t *testing.T) { tests := []struct { name string diff --git a/sdk/cliproxy/service_excluded_models_test.go b/sdk/cliproxy/service_excluded_models_test.go new file mode 100644 index 00000000..198a5bed --- /dev/null +++ b/sdk/cliproxy/service_excluded_models_test.go @@ -0,0 +1,65 @@ +package cliproxy + +import ( + "strings" + "testing" + + coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" + "github.com/router-for-me/CLIProxyAPI/v6/sdk/config" +) + +func TestRegisterModelsForAuth_UsesPreMergedExcludedModelsAttribute(t *testing.T) { + service := &Service{ + cfg: &config.Config{ + OAuthExcludedModels: map[string][]string{ + "gemini-cli": {"gemini-2.5-pro"}, + }, + }, + } + auth := &coreauth.Auth{ + ID: "auth-gemini-cli", + Provider: "gemini-cli", + Status: coreauth.StatusActive, + Attributes: map[string]string{ + "auth_kind": "oauth", + "excluded_models": "gemini-2.5-flash", + }, + } + + registry := GlobalModelRegistry() + registry.UnregisterClient(auth.ID) + t.Cleanup(func() { + registry.UnregisterClient(auth.ID) + }) + + service.registerModelsForAuth(auth) + + models := registry.GetAvailableModelsByProvider("gemini-cli") + if len(models) == 0 { + t.Fatal("expected gemini-cli models to be registered") + } + + for _, model := range models { + if model == nil { + continue + } + modelID := strings.TrimSpace(model.ID) + if strings.EqualFold(modelID, "gemini-2.5-flash") { + t.Fatalf("expected model %q to be excluded by auth attribute", modelID) + } + } + + seenGlobalExcluded := false + for _, model := range models { + if model == nil { + continue + } + if strings.EqualFold(strings.TrimSpace(model.ID), "gemini-2.5-pro") { + seenGlobalExcluded = true + break + } + } + if !seenGlobalExcluded { + t.Fatal("expected global excluded model to be present when attribute override is set") + } +}