diff --git a/config.example.yaml b/config.example.yaml index 8924f6d5f..0b1d7f876 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -758,6 +758,16 @@ oauth: # - name: "sample-model-latest" # alias: "sample-latest" + # OAuth provider model settings (per channel) + # Allows overriding model-level capabilities (such as context window) for OAuth credentials. + # Supported channels: vertex, aistudio, antigravity, claude, codex, kimi, xai, meta. + # settings: + # codex: + # - name: "gpt-6-sol" + # max-context-length: 524288 # overrides context window advertised to Codex clients + # - name: "gpt-6-astra" + # max-context-length: 1048576 + # OAuth provider excluded models # excluded-models: # vertex: diff --git a/internal/api/server_routes.go b/internal/api/server_routes.go index 7d02db2bb..546ca7315 100644 --- a/internal/api/server_routes.go +++ b/internal/api/server_routes.go @@ -19,6 +19,7 @@ import ( codexmodels "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/models" "github.com/router-for-me/CLIProxyAPI/v8/internal/client/grokbuild" "github.com/router-for-me/CLIProxyAPI/v8/internal/clienterror" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" "github.com/router-for-me/CLIProxyAPI/v8/internal/home" "github.com/router-for-me/CLIProxyAPI/v8/internal/logging" "github.com/router-for-me/CLIProxyAPI/v8/internal/registry" @@ -682,7 +683,7 @@ func (s *Server) handleHomeCodexClientModels(c *gin.Context, clientVersion strin models := make([]map[string]any, 0, len(entries)) for _, entry := range entries { - models = append(models, formatHomeCodexModel(entry)) + models = append(models, formatHomeCodexModelWithSettings(entry, s.cfg)) } var webSearchCapabilityForModel codexmodels.WebSearchCapabilityForModelFunc @@ -732,6 +733,9 @@ func formatHomeCodexModel(entry homeModelEntry) map[string]any { if entry.contextLength > 0 { model["context_length"] = entry.contextLength } + if entry.maxContextLength > 0 { + model["max_context_length"] = entry.maxContextLength + } if entry.maxCompletionTokens > 0 { model["max_completion_tokens"] = entry.maxCompletionTokens } @@ -741,6 +745,33 @@ func formatHomeCodexModel(entry homeModelEntry) map[string]any { return model } +func formatHomeCodexModelWithSettings(entry homeModelEntry, cfg *config.Config) map[string]any { + model := formatHomeCodexModel(entry) + if cfg == nil || len(cfg.OAuthSettings) == 0 { + return model + } + providers := append([]string(nil), entry.providers...) + sort.SliceStable(providers, func(i, j int) bool { + if strings.EqualFold(providers[i], "codex") { + return true + } + if strings.EqualFold(providers[j], "codex") { + return false + } + return strings.ToLower(providers[i]) < strings.ToLower(providers[j]) + }) + for _, p := range providers { + channel := strings.ToLower(strings.TrimSpace(p)) + if channelSettings, okChannel := cfg.OAuthSettings[channel]; okChannel { + if setting := config.ResolveOAuthModelSetting(channelSettings, entry.id, "", ""); setting != nil && setting.MaxContextLength > 0 { + model["max_context_length"] = setting.MaxContextLength + break + } + } + } + return model +} + func (s *Server) geminiModelsHandler(geminiHandler *gemini.GeminiAPIHandler) gin.HandlerFunc { return func(c *gin.Context) { if s != nil && s.cfg != nil && s.cfg.Home.Enabled { @@ -769,6 +800,7 @@ type homeModelEntry struct { ownedBy string displayName string contextLength int + maxContextLength int maxCompletionTokens int thinking *registry.ThinkingSupport providers []string @@ -1084,6 +1116,7 @@ func decodeHomeModels(raw []byte) ([]homeModelEntry, error) { ownedBy: ownedBy, displayName: displayName, contextLength: int(homeModelInt64Value(model, "context_length", "contextLength", "inputTokenLimit", "max_input_tokens")), + maxContextLength: int(homeModelInt64Value(model, "max_context_length", "maxContextLength")), maxCompletionTokens: int(homeModelInt64Value(model, "max_completion_tokens", "maxCompletionTokens", "outputTokenLimit", "max_tokens")), thinking: thinking, providers: appendUniqueHomeProvider(nil, provider), diff --git a/internal/api/server_test.go b/internal/api/server_test.go index 1393448ee..a089f2833 100644 --- a/internal/api/server_test.go +++ b/internal/api/server_test.go @@ -2799,6 +2799,78 @@ func TestDecodeHomeModelsKeepsTokenMetadata(t *testing.T) { } } +func TestHomeCodexModels_MaxContextLength(t *testing.T) { + entries, errDecode := decodeHomeModels([]byte(`{ + "codex": [ + {"id": "gpt-6-sol", "context_length": 272000, "max_context_length": 524288} + ] + }`)) + if errDecode != nil { + t.Fatalf("decodeHomeModels error = %v", errDecode) + } + if len(entries) != 1 || entries[0].maxContextLength != 524288 { + t.Fatalf("unexpected decoded entry: %+v", entries) + } + + formatted := formatHomeCodexModel(entries[0]) + if got := formatted["max_context_length"]; got != 524288 { + t.Fatalf("formatHomeCodexModel max_context_length = %v, want 524288", got) + } +} + +func TestHomeCodexModels_OAuthSettingsChannelIsolation(t *testing.T) { + cfg := &proxyconfig.Config{ + OAuthSettings: map[string][]proxyconfig.OAuthModelSetting{ + "codex": { + {Name: "shared-model", MaxContextLength: 524288}, + }, + "claude": { + {Name: "shared-model", MaxContextLength: 200000}, + }, + }, + } + + // 1. Entry from codex only -> receives codex setting (524288) + codexEntry := homeModelEntry{ + id: "shared-model", + providers: []string{"codex"}, + } + mCodex := formatHomeCodexModelWithSettings(codexEntry, cfg) + if got := mCodex["max_context_length"]; got != 524288 { + t.Errorf("mCodex max_context_length = %v, want 524288", got) + } + + // 2. Entry from claude only -> receives claude setting (200000), NOT codex setting + claudeEntry := homeModelEntry{ + id: "shared-model", + providers: []string{"claude"}, + } + mClaude := formatHomeCodexModelWithSettings(claudeEntry, cfg) + if got := mClaude["max_context_length"]; got != 200000 { + t.Errorf("mClaude max_context_length = %v, want 200000", got) + } + + // 3. Entry from other provider -> does not receive either setting + otherEntry := homeModelEntry{ + id: "shared-model", + providers: []string{"vertex"}, + } + mOther := formatHomeCodexModelWithSettings(otherEntry, cfg) + if got := mOther["max_context_length"]; got != nil { + t.Errorf("mOther max_context_length = %v, want nil", got) + } + + // 4. Entry from multiple providers (claude, codex) -> deterministic codex precedence + multiEntry := homeModelEntry{ + id: "shared-model", + providers: []string{"claude", "codex"}, + } + mMulti := formatHomeCodexModelWithSettings(multiEntry, cfg) + if got := mMulti["max_context_length"]; got != 524288 { + t.Errorf("mMulti max_context_length = %v, want 524288", got) + } +} + func TestHomeModelsAuthStatus(t *testing.T) { cases := []struct { name string diff --git a/internal/config/config.go b/internal/config/config.go index fe251c3c5..ca3767a5d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -185,6 +185,9 @@ type Config struct { // NOTE: This applies only to OAuth credentials and does not affect per-credential request-scoped-errors under *-api-key. OAuthRequestScopedErrors map[string][]RequestScopedErrorRule `yaml:"oauth-request-scoped-errors,omitempty" json:"oauth-request-scoped-errors,omitempty"` + // OAuthSettings defines per-channel model settings (such as max-context-length) applied to OAuth/file-backed auth entries. + OAuthSettings map[string][]OAuthModelSetting `yaml:"oauth-settings,omitempty" json:"oauth-settings,omitempty"` + // Payload defines default and override rules for provider payload parameters. Payload PayloadConfig `yaml:"payload" json:"payload"` } diff --git a/internal/config/config_load.go b/internal/config/config_load.go index 0e27154d8..20780461f 100644 --- a/internal/config/config_load.go +++ b/internal/config/config_load.go @@ -200,6 +200,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Normalize global OAuth model name aliases. cfg.SanitizeOAuthModelAlias() + // Normalize global OAuth model settings. + cfg.SanitizeOAuthSettings() + // Normalize global OAuth request-scoped error rules. cfg.SanitizeOAuthRequestScopedErrors() diff --git a/internal/config/config_normalization.go b/internal/config/config_normalization.go index 32462435b..12aa39822 100644 --- a/internal/config/config_normalization.go +++ b/internal/config/config_normalization.go @@ -102,6 +102,50 @@ func (cfg *Config) SanitizeOAuthModelAlias() { cfg.OAuthModelAlias = out } +// SanitizeOAuthSettings normalizes and deduplicates global OAuth model settings. +// It trims whitespace, normalizes channel keys to lower-case, drops empty entries, +// and ensures entries are unique within each channel. +func (cfg *Config) SanitizeOAuthSettings() { + if cfg == nil || len(cfg.OAuthSettings) == 0 { + return + } + out := make(map[string][]OAuthModelSetting, len(cfg.OAuthSettings)) + for rawChannel, settings := range cfg.OAuthSettings { + channel := strings.ToLower(strings.TrimSpace(rawChannel)) + if channel == "" || len(settings) == 0 { + continue + } + seen := make(map[string]struct{}, len(settings)) + reversed := make([]OAuthModelSetting, 0, len(settings)) + for i := len(settings) - 1; i >= 0; i-- { + entry := settings[i] + name := strings.TrimSpace(entry.Name) + if name == "" { + continue + } + alias := strings.TrimSpace(entry.Alias) + key := strings.ToLower(name) + "->" + strings.ToLower(alias) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + reversed = append(reversed, OAuthModelSetting{ + Name: name, + Alias: alias, + MaxContextLength: entry.MaxContextLength, + }) + } + if len(reversed) > 0 { + clean := make([]OAuthModelSetting, len(reversed)) + for i := range reversed { + clean[len(reversed)-1-i] = reversed[i] + } + out[channel] = clean + } + } + cfg.OAuthSettings = out +} + // SanitizeOAuthRequestScopedErrors normalizes and validates global OAuth request-scoped error rules. // It trims whitespace, normalizes channel keys to lower-case, validates status/action, and drops invalid rules. func (cfg *Config) SanitizeOAuthRequestScopedErrors() { diff --git a/internal/config/config_types.go b/internal/config/config_types.go index 5158d933a..4d44bf039 100644 --- a/internal/config/config_types.go +++ b/internal/config/config_types.go @@ -390,6 +390,53 @@ type OAuthModelAlias struct { ForceMapping bool `yaml:"force-mapping,omitempty" json:"force-mapping,omitempty"` } +// OAuthModelSetting defines provider/channel model settings (such as context window overrides) for OAuth credentials. +type OAuthModelSetting struct { + Name string `yaml:"name" json:"name"` + Alias string `yaml:"alias,omitempty" json:"alias,omitempty"` + + // MaxContextLength overrides the context window advertised to Codex clients. + MaxContextLength int `yaml:"max-context-length,omitempty" json:"max-context-length,omitempty"` +} + +// GetMaxContextLength returns the configured maximum context length override. +func (s OAuthModelSetting) GetMaxContextLength() int { return s.MaxContextLength } + +// ResolveOAuthModelSetting finds the best matching OAuthModelSetting for a given model. +// An exact Alias match on the model ID takes precedence over a general Name match. +// Within the same match specificity, later entries in the slice override earlier ones. +func ResolveOAuthModelSetting(settings []OAuthModelSetting, modelID, metadataModelID, modelName string) *OAuthModelSetting { + if len(settings) == 0 { + return nil + } + id := strings.ToLower(strings.TrimSpace(modelID)) + metaID := strings.ToLower(strings.TrimSpace(metadataModelID)) + name := strings.ToLower(strings.TrimSpace(modelName)) + + var aliasMatch *OAuthModelSetting + var nameMatch *OAuthModelSetting + + for i := range settings { + entry := &settings[i] + entryName := strings.ToLower(strings.TrimSpace(entry.Name)) + if entryName == "" { + continue + } + entryAlias := strings.ToLower(strings.TrimSpace(entry.Alias)) + + if entryAlias != "" && id != "" && id == entryAlias { + aliasMatch = entry + } else if (entryAlias == "" || entryAlias == id) && (id == entryName || (metaID != "" && metaID == entryName) || (name != "" && name == entryName)) { + nameMatch = entry + } + } + + if aliasMatch != nil { + return aliasMatch + } + return nameMatch +} + // PayloadConfig defines default and override parameter rules applied to provider payloads. type PayloadConfig struct { // Default defines rules that only set parameters when they are missing in the payload. diff --git a/internal/config/config_v8.go b/internal/config/config_v8.go index 9404d61d3..388ce34be 100644 --- a/internal/config/config_v8.go +++ b/internal/config/config_v8.go @@ -43,7 +43,7 @@ func buildV8Paths() []configPath { {"nonstream-keepalive-interval", "requests.nonstream-keepalive-interval"}, {"streaming", "requests.streaming"}, {"payload", "requests.payload"}, {"auth-dir", "oauth.auth-dir"}, {"auth-auto-refresh-workers", "oauth.auth-auto-refresh-workers"}, {"oauth-model-alias", "oauth.model-alias"}, {"oauth-excluded-models", "oauth.excluded-models"}, - {"oauth-request-scoped-errors", "oauth.request-scoped-errors"}, {"ws-auth", "oauth.providers.aistudio.ws-auth"}, + {"oauth-request-scoped-errors", "oauth.request-scoped-errors"}, {"oauth-settings", "oauth.settings"}, {"ws-auth", "oauth.providers.aistudio.ws-auth"}, {"codex", "oauth.providers.codex"}, {"codex-header-defaults", "oauth.providers.codex.header-defaults"}, {"claude", "oauth.providers.claude"}, {"claude-code", "oauth.providers.claude.claude-code"}, {"disable-claude-cloak-mode", "oauth.providers.claude.disable-claude-cloak-mode"}, diff --git a/internal/config/config_yaml.go b/internal/config/config_yaml.go index 17ca834ac..a1543607b 100644 --- a/internal/config/config_yaml.go +++ b/internal/config/config_yaml.go @@ -66,6 +66,7 @@ func SaveConfigPreserveComments(configFile string, cfg *Config, migrateV8 ...boo pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models") pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-model-alias") pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-request-scoped-errors") + pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-settings") replacePluginConfigsSubtree(original.Content[0], generated.Content[0]) // Merge generated into original in-place, preserving comments/order of existing nodes. @@ -747,7 +748,7 @@ func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, keyPath ...string) if srcIdx < 0 { // Keep explicit OAuth maps when the last channel is removed. Their presence // must survive saves and override legacy fields when restored to the v8 layout. - if key == "oauth-excluded-models" || key == "oauth-model-alias" || key == "oauth-request-scoped-errors" { + if key == "oauth-excluded-models" || key == "oauth-model-alias" || key == "oauth-request-scoped-errors" || key == "oauth-settings" { dstRoot.Content[dstIdx+1] = &yaml.Node{Kind: yaml.MappingNode, Tag: "!!map"} return } diff --git a/internal/config/oauth_settings_test.go b/internal/config/oauth_settings_test.go new file mode 100644 index 000000000..298291ab2 --- /dev/null +++ b/internal/config/oauth_settings_test.go @@ -0,0 +1,233 @@ +package config_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" + "gopkg.in/yaml.v3" +) + +func TestOAuthSettingsConfigDecoding_V8(t *testing.T) { + const rawYAML = ` +config-version: 8 +oauth: + settings: + codex: + - name: "gpt-6-sol" + max-context-length: 524288 + - name: "deepseek-v4-flash" + max-context-length: 1048576 +` + var cfg config.Config + if err := yaml.Unmarshal([]byte(rawYAML), &cfg); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + cfg.SanitizeOAuthSettings() + + codexSettings, ok := cfg.OAuthSettings["codex"] + if !ok { + t.Fatalf("expected cfg.OAuthSettings[codex] to exist, got: %#v", cfg.OAuthSettings) + } + if len(codexSettings) != 2 { + t.Fatalf("expected 2 codex settings, got %d", len(codexSettings)) + } + if codexSettings[0].Name != "gpt-6-sol" || codexSettings[0].MaxContextLength != 524288 { + t.Errorf("unexpected entry 0: %+v", codexSettings[0]) + } + if codexSettings[1].Name != "deepseek-v4-flash" || codexSettings[1].MaxContextLength != 1048576 { + t.Errorf("unexpected entry 1: %+v", codexSettings[1]) + } +} + +func TestOAuthSettingsConfigDecoding_Legacy(t *testing.T) { + const rawYAML = ` +oauth-settings: + codex: + - name: "gpt-6-sol" + max-context-length: 524288 +` + var cfg config.Config + if err := yaml.Unmarshal([]byte(rawYAML), &cfg); err != nil { + t.Fatalf("Unmarshal error: %v", err) + } + + cfg.SanitizeOAuthSettings() + + codexSettings, ok := cfg.OAuthSettings["codex"] + if !ok { + t.Fatalf("expected cfg.OAuthSettings[codex] to exist, got: %#v", cfg.OAuthSettings) + } + if len(codexSettings) != 1 { + t.Fatalf("expected 1 codex setting, got %d", len(codexSettings)) + } + if codexSettings[0].Name != "gpt-6-sol" || codexSettings[0].MaxContextLength != 524288 { + t.Errorf("unexpected entry 0: %+v", codexSettings[0]) + } +} + +func TestSanitizeOAuthSettings(t *testing.T) { + cfg := config.Config{ + OAuthSettings: map[string][]config.OAuthModelSetting{ + " CODEX ": { + {Name: " gpt-6-sol ", MaxContextLength: 524288}, + {Name: "gpt-6-sol", MaxContextLength: 999999}, // duplicate name, same alias -> later entry wins! + {Name: " ", MaxContextLength: 12345}, // empty name + {Name: "deepseek-v4-flash", MaxContextLength: 1048576}, + }, + " ": { + {Name: "ignored", MaxContextLength: 100}, + }, + }, + } + + cfg.SanitizeOAuthSettings() + + if _, ok := cfg.OAuthSettings[" "]; ok { + t.Error("expected empty channel to be dropped") + } + + codex, ok := cfg.OAuthSettings["codex"] + if !ok { + t.Fatalf("expected codex channel in sanitized settings: %#v", cfg.OAuthSettings) + } + if len(codex) != 2 { + t.Fatalf("expected 2 codex entries, got %d", len(codex)) + } + if codex[0].Name != "gpt-6-sol" || codex[0].MaxContextLength != 999999 { + t.Errorf("unexpected entry 0: %+v, want MaxContextLength = 999999 (later entry wins)", codex[0]) + } + if codex[1].Name != "deepseek-v4-flash" || codex[1].MaxContextLength != 1048576 { + t.Errorf("unexpected entry 1: %+v", codex[1]) + } +} + +func TestParseConfigBytes_DuplicateOAuthSettingsLaterWins(t *testing.T) { + const rawYAML = ` +config-version: 8 +oauth: + settings: + codex: + - name: "gpt-6-sol" + max-context-length: 524288 + - name: "gpt-6-sol" + max-context-length: 1048576 +` + cfg, err := config.ParseConfigBytes([]byte(rawYAML)) + if err != nil { + t.Fatalf("ParseConfigBytes error = %v", err) + } + codexSettings, ok := cfg.OAuthSettings["codex"] + if !ok || len(codexSettings) != 1 { + t.Fatalf("expected 1 codex setting after sanitization, got: %#v", codexSettings) + } + if codexSettings[0].MaxContextLength != 1048576 { + t.Errorf("MaxContextLength = %d, want 1048576 (later entry wins)", codexSettings[0].MaxContextLength) + } +} + +func TestSanitizeOAuthSettings_PreservesLastOccurrenceOrderWithInterleavedRules(t *testing.T) { + cfg := config.Config{ + OAuthSettings: map[string][]config.OAuthModelSetting{ + "codex": { + {Name: "upstream", MaxContextLength: 524288}, + {Name: "public", MaxContextLength: 600000}, + {Name: "upstream", MaxContextLength: 1048576}, + }, + }, + } + + rawResolved := config.ResolveOAuthModelSetting(cfg.OAuthSettings["codex"], "public", "upstream", "") + if rawResolved == nil || rawResolved.MaxContextLength != 1048576 { + t.Fatalf("pre-sanitize resolved to %+v, want 1048576", rawResolved) + } + + cfg.SanitizeOAuthSettings() + + codex := cfg.OAuthSettings["codex"] + if len(codex) != 2 { + t.Fatalf("expected 2 codex entries, got %d", len(codex)) + } + if codex[0].Name != "public" || codex[0].MaxContextLength != 600000 { + t.Errorf("entry 0: %+v, want public:600000", codex[0]) + } + if codex[1].Name != "upstream" || codex[1].MaxContextLength != 1048576 { + t.Errorf("entry 1: %+v, want upstream:1048576", codex[1]) + } + + sanitizedResolved := config.ResolveOAuthModelSetting(codex, "public", "upstream", "") + if sanitizedResolved == nil || sanitizedResolved.MaxContextLength != 1048576 { + t.Fatalf("post-sanitize resolved to %+v, want 1048576", sanitizedResolved) + } +} + +func TestSaveConfigPreserveComments_OAuthSettings(t *testing.T) { + configPath := filepath.Join(t.TempDir(), "config.yaml") + initialYAML := `config-version: 8 +oauth: + # Provider model settings + settings: + codex: + - name: "gpt-6-sol" + max-context-length: 524288 +` + if errWrite := os.WriteFile(configPath, []byte(initialYAML), 0o600); errWrite != nil { + t.Fatalf("os.WriteFile error = %v", errWrite) + } + + cfg, errLoad := config.LoadConfig(configPath) + if errLoad != nil { + t.Fatalf("LoadConfig error = %v", errLoad) + } + + if len(cfg.OAuthSettings["codex"]) != 1 || cfg.OAuthSettings["codex"][0].MaxContextLength != 524288 { + t.Fatalf("unexpected loaded settings: %#v", cfg.OAuthSettings) + } + + // Update setting + cfg.OAuthSettings["codex"][0].MaxContextLength = 1048576 + + if errSave := config.SaveConfigPreserveComments(configPath, cfg); errSave != nil { + t.Fatalf("SaveConfigPreserveComments error = %v", errSave) + } + + savedBytes, errRead := os.ReadFile(configPath) + if errRead != nil { + t.Fatalf("os.ReadFile error = %v", errRead) + } + savedText := string(savedBytes) + + if !strings.Contains(savedText, "1048576") { + t.Errorf("saved config does not contain updated max-context-length: %s", savedText) + } + if !strings.Contains(savedText, "# Provider model settings") { + t.Errorf("saved config lost comment: %s", savedText) + } +} + +func TestResolveOAuthModelSetting_Priority(t *testing.T) { + settings := []config.OAuthModelSetting{ + {Name: "upstream", MaxContextLength: 524288}, + {Name: "upstream", Alias: "public", MaxContextLength: 1048576}, + } + + // Model with ID "upstream" (no alias) should match the general "upstream" rule + sUpstream := config.ResolveOAuthModelSetting(settings, "upstream", "upstream", "") + if sUpstream == nil || sUpstream.MaxContextLength != 524288 { + t.Fatalf("upstream model resolved to %+v, want 524288", sUpstream) + } + + // Model with ID "public" and MetadataModelID "upstream" should match the more specific alias rule + sPublic := config.ResolveOAuthModelSetting(settings, "public", "upstream", "") + if sPublic == nil || sPublic.MaxContextLength != 1048576 { + t.Fatalf("public model resolved to %+v, want 1048576", sPublic) + } + + // Unknown model should return nil + if got := config.ResolveOAuthModelSetting(settings, "unknown", "", ""); got != nil { + t.Fatalf("unknown model resolved to %+v, want nil", got) + } +} diff --git a/internal/config/parse.go b/internal/config/parse.go index 57ed59369..9172a2314 100644 --- a/internal/config/parse.go +++ b/internal/config/parse.go @@ -121,6 +121,7 @@ func ParseConfigBytes(data []byte) (*Config, error) { cfg.SanitizeOpenAICompatibility() cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels) cfg.SanitizeOAuthModelAlias() + cfg.SanitizeOAuthSettings() cfg.SanitizeOAuthRequestScopedErrors() cfg.SanitizePayloadRules() diff --git a/internal/watcher/config_reload.go b/internal/watcher/config_reload.go index b4ca3fd32..6992b4bf5 100644 --- a/internal/watcher/config_reload.go +++ b/internal/watcher/config_reload.go @@ -136,7 +136,7 @@ func (w *Watcher) reloadConfig() bool { authDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir retryConfigChanged := oldConfig != nil && (oldConfig.RequestRetry != newConfig.RequestRetry || oldConfig.MaxRetryInterval != newConfig.MaxRetryInterval || oldConfig.MaxRetryCredentials != newConfig.MaxRetryCredentials) - forceAuthRefresh := oldConfig != nil && (oldConfig.ForceModelPrefix != newConfig.ForceModelPrefix || !reflect.DeepEqual(oldConfig.OAuthModelAlias, newConfig.OAuthModelAlias) || retryConfigChanged) + forceAuthRefresh := oldConfig != nil && (oldConfig.ForceModelPrefix != newConfig.ForceModelPrefix || !reflect.DeepEqual(oldConfig.OAuthModelAlias, newConfig.OAuthModelAlias) || !reflect.DeepEqual(oldConfig.OAuthSettings, newConfig.OAuthSettings) || retryConfigChanged) log.Infof("config successfully reloaded, triggering client reload") w.reloadClients(authDirChanged, affectedOAuthProviders, forceAuthRefresh) diff --git a/internal/watcher/diff/config_diff.go b/internal/watcher/diff/config_diff.go index eff31003d..6bb9036c7 100644 --- a/internal/watcher/diff/config_diff.go +++ b/internal/watcher/diff/config_diff.go @@ -453,6 +453,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { if entries, _ := DiffOAuthRequestScopedErrorsChanges(oldCfg.OAuthRequestScopedErrors, newCfg.OAuthRequestScopedErrors); len(entries) > 0 { changes = append(changes, entries...) } + if entries, _ := DiffOAuthSettingsChanges(oldCfg.OAuthSettings, newCfg.OAuthSettings); len(entries) > 0 { + changes = append(changes, entries...) + } // Remote management (never print the key) if oldCfg.RemoteManagement.AllowRemote != newCfg.RemoteManagement.AllowRemote { diff --git a/internal/watcher/diff/oauth_settings.go b/internal/watcher/diff/oauth_settings.go new file mode 100644 index 000000000..babd556a2 --- /dev/null +++ b/internal/watcher/diff/oauth_settings.go @@ -0,0 +1,100 @@ +package diff + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "sort" + "strings" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" +) + +type OAuthSettingsSummary struct { + hash string + count int +} + +// SummarizeOAuthSettings summarizes OAuth model settings per channel. +func SummarizeOAuthSettings(entries map[string][]config.OAuthModelSetting) map[string]OAuthSettingsSummary { + if len(entries) == 0 { + return nil + } + out := make(map[string]OAuthSettingsSummary, len(entries)) + for k, v := range entries { + key := strings.ToLower(strings.TrimSpace(k)) + if key == "" { + continue + } + out[key] = summarizeOAuthSettingsList(v) + } + if len(out) == 0 { + return nil + } + return out +} + +// DiffOAuthSettingsChanges compares OAuth model settings maps. +func DiffOAuthSettingsChanges(oldMap, newMap map[string][]config.OAuthModelSetting) ([]string, []string) { + oldSummary := SummarizeOAuthSettings(oldMap) + newSummary := SummarizeOAuthSettings(newMap) + keys := make(map[string]struct{}, len(oldSummary)+len(newSummary)) + for k := range oldSummary { + keys[k] = struct{}{} + } + for k := range newSummary { + keys[k] = struct{}{} + } + changes := make([]string, 0, len(keys)) + affected := make([]string, 0, len(keys)) + for key := range keys { + oldInfo, okOld := oldSummary[key] + newInfo, okNew := newSummary[key] + switch { + case okOld && !okNew: + changes = append(changes, fmt.Sprintf("oauth-settings[%s]: removed", key)) + affected = append(affected, key) + case !okOld && okNew: + changes = append(changes, fmt.Sprintf("oauth-settings[%s]: added (%d entries)", key, newInfo.count)) + affected = append(affected, key) + case okOld && okNew && oldInfo.hash != newInfo.hash: + changes = append(changes, fmt.Sprintf("oauth-settings[%s]: updated (%d -> %d entries)", key, oldInfo.count, newInfo.count)) + affected = append(affected, key) + } + } + sort.Strings(changes) + sort.Strings(affected) + return changes, affected +} + +func summarizeOAuthSettingsList(list []config.OAuthModelSetting) OAuthSettingsSummary { + if len(list) == 0 { + return OAuthSettingsSummary{} + } + seen := make(map[string]struct{}, len(list)) + normalized := make([]string, 0, len(list)) + for _, setting := range list { + name := strings.ToLower(strings.TrimSpace(setting.Name)) + if name == "" { + continue + } + alias := strings.ToLower(strings.TrimSpace(setting.Alias)) + key := name + "->" + alias + if setting.MaxContextLength > 0 { + key += fmt.Sprintf("|max-context-length=%d", setting.MaxContextLength) + } + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + normalized = append(normalized, key) + } + if len(normalized) == 0 { + return OAuthSettingsSummary{} + } + sum := sha256.Sum256([]byte(strings.Join(normalized, "|"))) + return OAuthSettingsSummary{ + hash: hex.EncodeToString(sum[:]), + count: len(normalized), + } +} diff --git a/internal/watcher/diff/oauth_settings_test.go b/internal/watcher/diff/oauth_settings_test.go new file mode 100644 index 000000000..949b4e85e --- /dev/null +++ b/internal/watcher/diff/oauth_settings_test.go @@ -0,0 +1,77 @@ +package diff + +import ( + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" +) + +func TestDiffOAuthSettingsChanges(t *testing.T) { + oldMap := map[string][]config.OAuthModelSetting{ + "codex": { + {Name: "gpt-6-sol", MaxContextLength: 272000}, + }, + "vertex": { + {Name: "gemini-2.5-pro", MaxContextLength: 1048576}, + }, + } + + newMap := map[string][]config.OAuthModelSetting{ + "codex": { + {Name: "gpt-6-sol", MaxContextLength: 524288}, + }, + "claude": { + {Name: "claude-sonnet-4-5-20250929", MaxContextLength: 200000}, + }, + } + + changes, affected := DiffOAuthSettingsChanges(oldMap, newMap) + + // vertex removed, claude added, codex updated + expectContains := func(slice []string, expected string) { + t.Helper() + found := false + for _, s := range slice { + if strings.Contains(s, expected) { + found = true + break + } + } + if !found { + t.Errorf("expected slice to contain %q, but got %#v", expected, slice) + } + } + + expectContains(changes, "oauth-settings[vertex]: removed") + expectContains(changes, "oauth-settings[claude]: added (1 entries)") + expectContains(changes, "oauth-settings[codex]: updated (1 -> 1 entries)") + + expectContains(affected, "vertex") + expectContains(affected, "claude") + expectContains(affected, "codex") +} + +func TestDiffOAuthSettingsChanges_Reordering(t *testing.T) { + oldMap := map[string][]config.OAuthModelSetting{ + "codex": { + {Name: "gpt-6-sol", MaxContextLength: 524288}, + {Name: "deepseek-v4-flash", MaxContextLength: 1048576}, + }, + } + + newMap := map[string][]config.OAuthModelSetting{ + "codex": { + {Name: "deepseek-v4-flash", MaxContextLength: 1048576}, + {Name: "gpt-6-sol", MaxContextLength: 524288}, + }, + } + + changes, affected := DiffOAuthSettingsChanges(oldMap, newMap) + if len(changes) == 0 { + t.Fatalf("expected changes when rules are reordered, got none") + } + if len(affected) == 0 || affected[0] != "codex" { + t.Fatalf("expected codex to be affected by reordering: %#v", affected) + } +} diff --git a/sdk/cliproxy/service_executors.go b/sdk/cliproxy/service_executors.go index 60e87d3b6..c0578881c 100644 --- a/sdk/cliproxy/service_executors.go +++ b/sdk/cliproxy/service_executors.go @@ -562,6 +562,7 @@ func (s *Service) tryRegisterPluginModelsForAuth(ctx context.Context, a *coreaut models := applyExcludedModels(result.Models, activeExcluded) models = applyOAuthModelAliasForAuth(s.cfg, providerKey, activeAuthKind, activeAuth.Attributes, models) if len(models) > 0 { + models = applyOAuthSettingsForAuth(s.cfg, providerKey, activeAuthKind, models) s.registerResolvedModelsForAuth(activeAuth, providerKey, applyModelPrefixes(models, activeAuth.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix)) return true } diff --git a/sdk/cliproxy/service_models.go b/sdk/cliproxy/service_models.go index df40676aa..1bb4b621f 100644 --- a/sdk/cliproxy/service_models.go +++ b/sdk/cliproxy/service_models.go @@ -295,6 +295,7 @@ func (s *Service) registerModelsForAuthWithCache(ctx context.Context, a *coreaut } models = s.appendPluginModels(key, models) if len(models) > 0 { + models = applyOAuthSettingsForAuth(s.cfg, provider, authKind, models) s.registerResolvedModelsForAuth(a, key, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix)) if strings.EqualFold(strings.TrimSpace(a.Provider), "antigravity") { s.asyncProbeAntigravityCapabilities(ctx, a, key) @@ -1129,3 +1130,51 @@ func applyOAuthModelAliasEntries(aliases []config.OAuthModelAlias, models []*Mod } return out } + +func applyOAuthSettings(cfg *config.Config, provider, authKind string, models []*ModelInfo) []*ModelInfo { + return applyOAuthSettingsForAuth(cfg, provider, authKind, models) +} + +func applyOAuthSettingsForAuth(cfg *config.Config, provider, authKind string, models []*ModelInfo) []*ModelInfo { + if len(models) == 0 { + return models + } + channel := coreauth.OAuthModelAliasChannel(provider, authKind) + if channel == "" { + return models + } + settings := oauthSettingsForAuth(cfg, channel) + if len(settings) == 0 { + return models + } + return applyOAuthSettingEntries(settings, models) +} + +func oauthSettingsForAuth(cfg *config.Config, channel string) []config.OAuthModelSetting { + if cfg == nil || len(cfg.OAuthSettings) == 0 { + return nil + } + return cfg.OAuthSettings[channel] +} + +func applyOAuthSettingEntries(settings []config.OAuthModelSetting, models []*ModelInfo) []*ModelInfo { + if len(settings) == 0 || len(models) == 0 { + return models + } + out := make([]*ModelInfo, 0, len(models)) + for _, model := range models { + if model == nil { + continue + } + setting := config.ResolveOAuthModelSetting(settings, model.ID, model.MetadataModelID, model.Name) + if setting != nil && setting.MaxContextLength > 0 { + clone := *model + clone.ContextLength = setting.MaxContextLength + clone.MaxContextLength = setting.MaxContextLength + out = append(out, &clone) + } else { + out = append(out, model) + } + } + return out +} diff --git a/sdk/cliproxy/service_oauth_settings_test.go b/sdk/cliproxy/service_oauth_settings_test.go new file mode 100644 index 000000000..4fb4a52d2 --- /dev/null +++ b/sdk/cliproxy/service_oauth_settings_test.go @@ -0,0 +1,166 @@ +package cliproxy + +import ( + "testing" + + codexmodels "github.com/router-for-me/CLIProxyAPI/v8/internal/client/codex/models" + "github.com/router-for-me/CLIProxyAPI/v8/internal/config" +) + +func TestApplyOAuthSettings_MaxContextLength(t *testing.T) { + cfg := &config.Config{ + OAuthSettings: map[string][]config.OAuthModelSetting{ + "codex": { + {Name: "gpt-6-sol", MaxContextLength: 524288}, + {Name: "deepseek-v4-flash", MaxContextLength: 1048576}, + }, + }, + } + + models := []*ModelInfo{ + {ID: "gpt-6-sol", ContextLength: 272000}, + {ID: "deepseek-v4-flash", ContextLength: 272000}, + {ID: "gpt-5-codex", ContextLength: 272000}, + } + + out := applyOAuthSettings(cfg, "codex", "oauth", models) + + if len(out) != 3 { + t.Fatalf("expected 3 models, got %d", len(out)) + } + if out[0].MaxContextLength != 524288 || out[0].ContextLength != 524288 { + t.Errorf("gpt-6-sol MaxContextLength = %d, ContextLength = %d; want 524288", out[0].MaxContextLength, out[0].ContextLength) + } + if out[1].MaxContextLength != 1048576 || out[1].ContextLength != 1048576 { + t.Errorf("deepseek-v4-flash MaxContextLength = %d, ContextLength = %d; want 1048576", out[1].MaxContextLength, out[1].ContextLength) + } + if out[2].MaxContextLength != 0 || out[2].ContextLength != 272000 { + t.Errorf("gpt-5-codex MaxContextLength = %d, ContextLength = %d; want untouched (0, 272000)", out[2].MaxContextLength, out[2].ContextLength) + } +} + +func TestApplyOAuthSettings_AliasedModel(t *testing.T) { + cfg := &config.Config{ + OAuthSettings: map[string][]config.OAuthModelSetting{ + "codex": { + {Name: "gpt-6-sol", MaxContextLength: 524288}, + }, + }, + } + + // Model was aliased: ID is custom-sol, MetadataModelID is gpt-6-sol + models := []*ModelInfo{ + {ID: "custom-sol", MetadataModelID: "gpt-6-sol", ContextLength: 272000}, + } + + out := applyOAuthSettings(cfg, "codex", "oauth", models) + + if len(out) != 1 { + t.Fatalf("expected 1 model, got %d", len(out)) + } + if out[0].MaxContextLength != 524288 { + t.Errorf("aliased model MaxContextLength = %d, want 524288", out[0].MaxContextLength) + } +} + +func TestApplyOAuthSettings_MatchedByAlias(t *testing.T) { + cfg := &config.Config{ + OAuthSettings: map[string][]config.OAuthModelSetting{ + "codex": { + {Name: "gpt-6-sol", Alias: "sol-preview", MaxContextLength: 524288}, + }, + }, + } + + models := []*ModelInfo{ + {ID: "sol-preview", ContextLength: 272000}, + {ID: "other-model", ContextLength: 272000}, + } + + out := applyOAuthSettings(cfg, "codex", "oauth", models) + + if len(out) != 2 { + t.Fatalf("expected 2 models, got %d", len(out)) + } + if out[0].MaxContextLength != 524288 { + t.Errorf("sol-preview MaxContextLength = %d, want 524288", out[0].MaxContextLength) + } + if out[1].MaxContextLength != 0 { + t.Errorf("other-model MaxContextLength = %d, want 0", out[1].MaxContextLength) + } +} + +func TestApplyOAuthSettings_CodexCatalogPipeline(t *testing.T) { + cfg := &config.Config{ + OAuthSettings: map[string][]config.OAuthModelSetting{ + "codex": { + {Name: "gpt-6-sol", MaxContextLength: 524288}, + }, + }, + } + + models := []*ModelInfo{ + {ID: "gpt-6-sol", ContextLength: 272000}, + } + + out := applyOAuthSettings(cfg, "codex", "oauth", models) + if len(out) != 1 || out[0].MaxContextLength != 524288 { + t.Fatalf("unexpected model output: %+v", out) + } + + availableModels := []map[string]any{ + { + "id": out[0].ID, + "object": "model", + "context_length": out[0].ContextLength, + "max_context_length": out[0].MaxContextLength, + }, + } + + resp := codexmodels.BuildResponse(availableModels, nil, false) + respModels, ok := resp["models"].([]map[string]any) + if !ok || len(respModels) == 0 { + t.Fatalf("unexpected catalog models response: %#v", resp) + } + + var foundSol map[string]any + for _, m := range respModels { + if id, _ := m["slug"].(string); id == "gpt-6-sol" { + foundSol = m + break + } + } + if foundSol == nil { + t.Fatalf("gpt-6-sol not found in catalog response: %#v", respModels) + } + if contextWindow, _ := foundSol["context_window"].(int); contextWindow != 524288 { + t.Errorf("catalog context_window = %v, want 524288", foundSol["context_window"]) + } + if maxContextWindow, _ := foundSol["max_context_window"].(int); maxContextWindow != 524288 { + t.Errorf("catalog max_context_window = %v, want 524288", foundSol["max_context_window"]) + } +} + +func TestApplyOAuthSettings_SkipsAPIKey(t *testing.T) { + cfg := &config.Config{ + OAuthSettings: map[string][]config.OAuthModelSetting{ + "codex": { + {Name: "gpt-6-sol", MaxContextLength: 524288}, + }, + }, + } + + // An API-key credential with its own configured max-context-length + models := []*ModelInfo{ + {ID: "gpt-6-sol", ContextLength: 1048576, MaxContextLength: 1048576}, + } + + out := applyOAuthSettingsForAuth(cfg, "codex", "api_key", models) + + if len(out) != 1 { + t.Fatalf("expected 1 model, got %d", len(out)) + } + if out[0].MaxContextLength != 1048576 || out[0].ContextLength != 1048576 { + t.Errorf("API-key model was modified by OAuthSettings: MaxContextLength = %d, ContextLength = %d; want 1048576", out[0].MaxContextLength, out[0].ContextLength) + } +} diff --git a/sdk/config/config.go b/sdk/config/config.go index 7fdddb9bd..69285f7dd 100644 --- a/sdk/config/config.go +++ b/sdk/config/config.go @@ -17,6 +17,7 @@ type DiscoveryConfig = internalconfig.DiscoveryConfig type DiscoveryInterfacesConfig = internalconfig.DiscoveryInterfacesConfig type RemoteManagement = internalconfig.RemoteManagement type OAuthModelAlias = internalconfig.OAuthModelAlias +type OAuthModelSetting = internalconfig.OAuthModelSetting type PayloadConfig = internalconfig.PayloadConfig type PayloadRule = internalconfig.PayloadRule type PayloadFilterRule = internalconfig.PayloadFilterRule @@ -47,6 +48,10 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { return internalconfig.LoadConfigOptional(configFile, optional) } +func ResolveOAuthModelSetting(settings []OAuthModelSetting, modelID, metadataModelID, modelName string) *OAuthModelSetting { + return internalconfig.ResolveOAuthModelSetting(settings, modelID, metadataModelID, modelName) +} + func ParseConfigBytes(data []byte) (*Config, error) { return internalconfig.ParseConfigBytes(data) } func SaveConfigPreserveComments(configFile string, cfg *Config) error {