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..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,6 +27,9 @@ func ConvertOpenAIResponsesRequestToCodex(modelName string, inputRawJSON []byte, rawJSON, _ = sjson.DeleteBytes(rawJSON, "top_p") rawJSON, _ = sjson.DeleteBytes(rawJSON, "service_tier") + // 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) 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..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 @@ -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.Errorf("user field should be deleted, but it was found with value: %s", userField.Raw) + } +} diff --git a/internal/watcher/synthesizer/file.go b/internal/watcher/synthesizer/file.go index c80ebc66..4e053117 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,23 @@ 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: + priority := strings.TrimSpace(v) + if _, errAtoi := strconv.Atoi(priority); errAtoi == nil { + a.Attributes["priority"] = priority + } + } + } + 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 +183,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 +259,40 @@ 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 + } + var stringSlice []string + switch v := raw.(type) { + case []string: + stringSlice = v + case []interface{}: + stringSlice = make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok { + stringSlice = append(stringSlice, s) + } + } + 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 +} 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.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 } 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.go b/sdk/cliproxy/service.go index d40f85f9..aef0ca5f 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -767,6 +767,13 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { provider = "openai-compatibility" } excluded := s.oauthExcludedModels(provider, authKind) + // 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 val, ok := a.Attributes["excluded_models"]; ok && strings.TrimSpace(val) != "" { + excluded = strings.Split(val, ",") + } + } var models []*ModelInfo switch provider { case "gemini": 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") + } +}