feat(oauth): support per-channel model settings overrides

- Add `oauth-settings` configuration to override model capabilities such as `max-context-length`
- Implement sanitization, YAML preservation, and live reload diff detection for OAuth settings
- Apply OAuth model settings during model resolution and Codex model list responses

Closes: #6099
This commit is contained in:
Luis Pater
2026-09-29 11:43:51 +08:00
parent d33f63f8e3
commit df2774ca18
19 changed files with 852 additions and 4 deletions

View File

@@ -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:

View File

@@ -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),

View File

@@ -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

View File

@@ -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"`
}

View File

@@ -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()

View File

@@ -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() {

View File

@@ -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.

View File

@@ -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"},

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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()

View File

@@ -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)

View File

@@ -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 {

View File

@@ -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),
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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 {