mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-10-17 04:55:19 +08:00
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:
@@ -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:
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
233
internal/config/oauth_settings_test.go
Normal file
233
internal/config/oauth_settings_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
100
internal/watcher/diff/oauth_settings.go
Normal file
100
internal/watcher/diff/oauth_settings.go
Normal 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),
|
||||
}
|
||||
}
|
||||
77
internal/watcher/diff/oauth_settings_test.go
Normal file
77
internal/watcher/diff/oauth_settings_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
166
sdk/cliproxy/service_oauth_settings_test.go
Normal file
166
sdk/cliproxy/service_oauth_settings_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user