From 559b7df404af45df539689b83b26a7e6484e6c65 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:29:36 +0800 Subject: [PATCH 01/10] refactor(config): restructure and uncomment example configuration --- config.example.yaml | 243 +++++++++++++++++++++----------------------- 1 file changed, 116 insertions(+), 127 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index 3086ca78..c1636166 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -55,141 +55,130 @@ quota-exceeded: # When true, enable authentication for the WebSocket API (/v1/ws). ws-auth: false -# Amp CLI Integration -# Configure upstream URL for Amp CLI OAuth and management features -#amp-upstream-url: "https://ampcode.com" - -# Optional: Override API key for Amp upstream (otherwise uses env or file) -#amp-upstream-api-key: "" - -# Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended) -#amp-restrict-management-to-localhost: true - -# Amp Model Mappings -# Route unavailable Amp models to alternative models available in your local proxy. -# Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5) -# but you have a similar model available (e.g., Claude Sonnet 4). -#amp-model-mappings: -# - from: "claude-opus-4.5" # Model requested by Amp CLI -# to: "claude-sonnet-4" # Route to this available model instead -# - from: "gpt-5" -# to: "gemini-2.5-pro" -# - from: "claude-3-opus-20240229" -# to: "claude-3-5-sonnet-20241022" - -# Gemini API keys (preferred) -#gemini-api-key: -# - api-key: "AIzaSy...01" -# base-url: "https://generativelanguage.googleapis.com" -# headers: -# X-Custom-Header: "custom-value" -# proxy-url: "socks5://proxy.example.com:1080" -# excluded-models: -# - "gemini-2.5-pro" # exclude specific models from this provider (exact match) -# - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) -# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview) -# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) -# - api-key: "AIzaSy...02" - -# API keys for official Generative Language API (legacy compatibility) -#generative-language-api-key: -# - "AIzaSy...01" -# - "AIzaSy...02" +# Gemini API keys +gemini-api-key: + - api-key: "AIzaSy...01" + base-url: "https://generativelanguage.googleapis.com" + headers: + X-Custom-Header: "custom-value" + proxy-url: "socks5://proxy.example.com:1080" + excluded-models: + - "gemini-2.5-pro" # exclude specific models from this provider (exact match) + - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) + - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview) + - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) + - api-key: "AIzaSy...02" # Codex API keys -#codex-api-key: -# - api-key: "sk-atSM..." -# base-url: "https://www.example.com" # use the custom codex API endpoint -# headers: -# X-Custom-Header: "custom-value" -# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override -# excluded-models: -# - "gpt-5.1" # exclude specific models (exact match) -# - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex) -# - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini) -# - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low) +codex-api-key: + - api-key: "sk-atSM..." + base-url: "https://www.example.com" # use the custom codex API endpoint + headers: + X-Custom-Header: "custom-value" + proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override + excluded-models: + - "gpt-5.1" # exclude specific models (exact match) + - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex) + - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini) + - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low) # Claude API keys -#claude-api-key: -# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url -# - api-key: "sk-atSM..." -# base-url: "https://www.example.com" # use the custom claude API endpoint -# headers: -# X-Custom-Header: "custom-value" -# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override -# models: -# - name: "claude-3-5-sonnet-20241022" # upstream model name -# alias: "claude-sonnet-latest" # client alias mapped to the upstream model -# excluded-models: -# - "claude-opus-4-5-20251101" # exclude specific models (exact match) -# - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219) -# - "*-think" # wildcard matching suffix (e.g. claude-opus-4-5-thinking) -# - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022) +claude-api-key: + - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url + - api-key: "sk-atSM..." + base-url: "https://www.example.com" # use the custom claude API endpoint + headers: + X-Custom-Header: "custom-value" + proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override + models: + - name: "claude-3-5-sonnet-20241022" # upstream model name + alias: "claude-sonnet-latest" # client alias mapped to the upstream model + excluded-models: + - "claude-opus-4-5-20251101" # exclude specific models (exact match) + - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219) + - "*-think" # wildcard matching suffix (e.g. claude-opus-4-5-thinking) + - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022) # OpenAI compatibility providers -#openai-compatibility: -# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. -# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider. -# headers: -# X-Custom-Header: "custom-value" -# # New format with per-key proxy support (recommended): -# api-key-entries: -# - api-key: "sk-or-v1-...b780" -# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override -# - api-key: "sk-or-v1-...b781" # without proxy-url -# # Legacy format (still supported, but cannot specify proxy per key): -# # api-keys: -# # - "sk-or-v1-...b780" -# # - "sk-or-v1-...b781" -# models: # The models supported by the provider. -# - name: "moonshotai/kimi-k2:free" # The actual model name. -# alias: "kimi-k2" # The alias used in the API. +openai-compatibility: + - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. + base-url: "https://openrouter.ai/api/v1" # The base URL of the provider. + headers: + X-Custom-Header: "custom-value" + api-key-entries: + - api-key: "sk-or-v1-...b780" + proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override + - api-key: "sk-or-v1-...b781" # without proxy-url + models: # The models supported by the provider. + - name: "moonshotai/kimi-k2:free" # The actual model name. + alias: "kimi-k2" # The alias used in the API. # Vertex API keys (Vertex-compatible endpoints, use API key + base URL) -#vertex-api-key: -# - api-key: "vk-123..." # x-goog-api-key header -# base-url: "https://example.com/api" # e.g. https://zenmux.ai/api -# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override -# headers: -# X-Custom-Header: "custom-value" -# models: # optional: map aliases to upstream model names -# - name: "gemini-2.0-flash" # upstream model name -# alias: "vertex-flash" # client-visible alias -# - name: "gemini-1.5-pro" -# alias: "vertex-pro" +vertex-api-key: + - api-key: "vk-123..." # x-goog-api-key header + base-url: "https://example.com/api" # e.g. https://zenmux.ai/api + proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override + headers: + X-Custom-Header: "custom-value" + models: # optional: map aliases to upstream model names + - name: "gemini-2.0-flash" # upstream model name + alias: "vertex-flash" # client-visible alias + - name: "gemini-1.5-pro" + alias: "vertex-pro" -#payload: # Optional payload configuration -# default: # Default rules only set parameters when they are missing in the payload. -# - models: -# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*") -# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex -# params: # JSON path (gjson/sjson syntax) -> value -# "generationConfig.thinkingConfig.thinkingBudget": 32768 -# override: # Override rules always set parameters, overwriting any existing values. -# - models: -# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*") -# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex -# params: # JSON path (gjson/sjson syntax) -> value -# "reasoning.effort": "high" +# Amp Integration +ampcode: + # Configure upstream URL for Amp CLI OAuth and management features + upstream-url: "https://ampcode.com" + # Optional: Override API key for Amp upstream (otherwise uses env or file) + upstream-api-key: "" + # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended) + restrict-management-to-localhost: true + # Amp Model Mappings + # Route unavailable Amp models to alternative models available in your local proxy. + # Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5) + # but you have a similar model available (e.g., Claude Sonnet 4). + model-mappings: + - from: "claude-opus-4.5" # Model requested by Amp CLI + to: "claude-sonnet-4" # Route to this available model instead + - from: "gpt-5" + to: "gemini-2.5-pro" + - from: "claude-3-opus-20240229" + to: "claude-3-5-sonnet-20241022" # OAuth provider excluded models -#oauth-excluded-models: -# gemini-cli: -# - "gemini-2.5-pro" # exclude specific models (exact match) -# - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) -# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview) -# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) -# vertex: -# - "gemini-3-pro-preview" -# aistudio: -# - "gemini-3-pro-preview" -# antigravity: -# - "gemini-3-pro-preview" -# claude: -# - "claude-3-5-haiku-20241022" -# codex: -# - "gpt-5-codex-mini" -# qwen: -# - "vision-model" -# iflow: -# - "tstars2.0" +oauth-excluded-models: + gemini-cli: + - "gemini-2.5-pro" # exclude specific models (exact match) + - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) + - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview) + - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) + vertex: + - "gemini-3-pro-preview" + aistudio: + - "gemini-3-pro-preview" + antigravity: + - "gemini-3-pro-preview" + claude: + - "claude-3-5-haiku-20241022" + codex: + - "gpt-5-codex-mini" + qwen: + - "vision-model" + iflow: + - "tstars2.0" + +# Optional payload configuration +payload: + default: # Default rules only set parameters when they are missing in the payload. + - models: + - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*") + protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex + params: # JSON path (gjson/sjson syntax) -> value + "generationConfig.thinkingConfig.thinkingBudget": 32768 + override: # Override rules always set parameters, overwriting any existing values. + - models: + - name: "gpt-*" # Supports wildcards (e.g., "gpt-*") + protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex + params: # JSON path (gjson/sjson syntax) -> value + "reasoning.effort": "high" From bd1678457b7d20a50cb09a5782943e5731c71199 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 3 Dec 2025 10:42:28 +0800 Subject: [PATCH 02/10] refactor(config): consolidate Amp settings into AmpCode struct --- internal/api/modules/amp/amp.go | 16 +++--- internal/api/modules/amp/amp_test.go | 28 ++++++---- internal/config/config.go | 82 +++++++++++++++------------- 3 files changed, 71 insertions(+), 55 deletions(-) diff --git a/internal/api/modules/amp/amp.go b/internal/api/modules/amp/amp.go index 281fda65..fac77bfb 100644 --- a/internal/api/modules/amp/amp.go +++ b/internal/api/modules/amp/amp.go @@ -95,7 +95,8 @@ func (m *AmpModule) Name() string { // This implements the RouteModuleV2 interface with Context. // Routes are registered only once via sync.Once for idempotent behavior. func (m *AmpModule) Register(ctx modules.Context) error { - upstreamURL := strings.TrimSpace(ctx.Config.AmpUpstreamURL) + settings := ctx.Config.AmpCode + upstreamURL := strings.TrimSpace(settings.UpstreamURL) // Determine auth middleware (from module or context) auth := m.getAuthMiddleware(ctx) @@ -104,7 +105,7 @@ func (m *AmpModule) Register(ctx modules.Context) error { var regErr error m.registerOnce.Do(func() { // Initialize model mapper from config (for routing unavailable models to alternatives) - m.modelMapper = NewModelMapper(ctx.Config.AmpModelMappings) + m.modelMapper = NewModelMapper(settings.ModelMappings) // Always register provider aliases - these work without an upstream m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth) @@ -120,7 +121,7 @@ func (m *AmpModule) Register(ctx modules.Context) error { // Create secret source with precedence: config > env > file // Cache secrets for 5 minutes to reduce file I/O if m.secretSource == nil { - m.secretSource = NewMultiSourceSecret(ctx.Config.AmpUpstreamAPIKey, 0 /* default 5min */) + m.secretSource = NewMultiSourceSecret(settings.UpstreamAPIKey, 0 /* default 5min */) } // Create reverse proxy with gzip handling via ModifyResponse @@ -136,7 +137,7 @@ func (m *AmpModule) Register(ctx modules.Context) error { // Register management proxy routes (requires upstream) // Restrict to localhost by default for security (prevents drive-by browser attacks) handler := proxyHandler(proxy) - m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler, handler, ctx.Config.AmpRestrictManagementToLocalhost) + m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler, handler, settings.RestrictManagementToLocalhost) log.Infof("Amp upstream proxy enabled for: %s", upstreamURL) log.Debug("Amp provider alias routes registered") @@ -166,8 +167,9 @@ func (m *AmpModule) getAuthMiddleware(ctx modules.Context) gin.HandlerFunc { func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error { // Update model mappings (hot-reload supported) if m.modelMapper != nil { - log.Infof("amp config updated: reloading %d model mapping(s)", len(cfg.AmpModelMappings)) - m.modelMapper.UpdateMappings(cfg.AmpModelMappings) + settings := cfg.AmpCode + log.Infof("amp config updated: reloading %d model mapping(s)", len(settings.ModelMappings)) + m.modelMapper.UpdateMappings(settings.ModelMappings) } else { log.Warnf("amp model mapper not initialized, skipping model mapping update") } @@ -177,7 +179,7 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error { return nil } - upstreamURL := strings.TrimSpace(cfg.AmpUpstreamURL) + upstreamURL := strings.TrimSpace(cfg.AmpCode.UpstreamURL) if upstreamURL == "" { log.Warn("Amp upstream URL removed from config, restart required to disable") return nil diff --git a/internal/api/modules/amp/amp_test.go b/internal/api/modules/amp/amp_test.go index 5ae16647..39db6f53 100644 --- a/internal/api/modules/amp/amp_test.go +++ b/internal/api/modules/amp/amp_test.go @@ -56,8 +56,10 @@ func TestAmpModule_Register_WithUpstream(t *testing.T) { m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() }) cfg := &config.Config{ - AmpUpstreamURL: upstream.URL, - AmpUpstreamAPIKey: "test-key", + AmpCode: config.AmpCode{ + UpstreamURL: upstream.URL, + UpstreamAPIKey: "test-key", + }, } ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }} @@ -86,7 +88,9 @@ func TestAmpModule_Register_WithoutUpstream(t *testing.T) { m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() }) cfg := &config.Config{ - AmpUpstreamURL: "", // No upstream + AmpCode: config.AmpCode{ + UpstreamURL: "", // No upstream + }, } ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }} @@ -121,7 +125,9 @@ func TestAmpModule_Register_InvalidUpstream(t *testing.T) { m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() }) cfg := &config.Config{ - AmpUpstreamURL: "://invalid-url", + AmpCode: config.AmpCode{ + UpstreamURL: "://invalid-url", + }, } ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }} @@ -151,7 +157,7 @@ func TestAmpModule_OnConfigUpdated_CacheInvalidation(t *testing.T) { } // Update config - should invalidate cache - if err := m.OnConfigUpdated(&config.Config{AmpUpstreamURL: "http://x"}); err != nil { + if err := m.OnConfigUpdated(&config.Config{AmpCode: config.AmpCode{UpstreamURL: "http://x"}}); err != nil { t.Fatal(err) } @@ -175,7 +181,7 @@ func TestAmpModule_OnConfigUpdated_URLRemoved(t *testing.T) { m.secretSource = ms // Config update with empty URL - should log warning but not error - cfg := &config.Config{AmpUpstreamURL: ""} + cfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: ""}} if err := m.OnConfigUpdated(cfg); err != nil { t.Fatalf("unexpected error: %v", err) @@ -187,7 +193,7 @@ func TestAmpModule_OnConfigUpdated_NonMultiSourceSecret(t *testing.T) { m := &AmpModule{enabled: true} m.secretSource = NewStaticSecretSource("static-key") - cfg := &config.Config{AmpUpstreamURL: "http://example.com"} + cfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: "http://example.com"}} // Should not error or panic if err := m.OnConfigUpdated(cfg); err != nil { @@ -240,8 +246,10 @@ func TestAmpModule_SecretSource_FromConfig(t *testing.T) { // Config with explicit API key cfg := &config.Config{ - AmpUpstreamURL: upstream.URL, - AmpUpstreamAPIKey: "config-key", + AmpCode: config.AmpCode{ + UpstreamURL: upstream.URL, + UpstreamAPIKey: "config-key", + }, } ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }} @@ -283,7 +291,7 @@ func TestAmpModule_ProviderAliasesAlwaysRegistered(t *testing.T) { m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() }) - cfg := &config.Config{AmpUpstreamURL: scenario.configURL} + cfg := &config.Config{AmpCode: config.AmpCode{UpstreamURL: scenario.configURL}} ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }} if err := m.Register(ctx); err != nil && scenario.configURL != "" { diff --git a/internal/config/config.go b/internal/config/config.go index 16c8b4dc..afe498de 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -26,22 +26,8 @@ type Config struct { // TLS config controls HTTPS server settings. TLS TLSConfig `yaml:"tls" json:"tls"` - // AmpUpstreamURL defines the upstream Amp control plane used for non-provider calls. - AmpUpstreamURL string `yaml:"amp-upstream-url" json:"amp-upstream-url"` - - // AmpUpstreamAPIKey optionally overrides the Authorization header when proxying Amp upstream calls. - AmpUpstreamAPIKey string `yaml:"amp-upstream-api-key" json:"amp-upstream-api-key"` - - // AmpRestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.) - // to only accept connections from localhost (127.0.0.1, ::1). When true, prevents drive-by - // browser attacks and remote access to management endpoints. Default: true (recommended). - AmpRestrictManagementToLocalhost bool `yaml:"amp-restrict-management-to-localhost" json:"amp-restrict-management-to-localhost"` - - // AmpModelMappings defines model name mappings for Amp CLI requests. - // When Amp requests a model that isn't available locally, these mappings - // allow routing to an alternative model that IS available. - // Example: Map "claude-opus-4.5" -> "claude-sonnet-4" when opus isn't available. - AmpModelMappings []AmpModelMapping `yaml:"amp-model-mappings" json:"amp-model-mappings"` + // RemoteManagement nests management-related options under 'remote-management'. + RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"` // AuthDir is the directory where authentication token files are stored. AuthDir string `yaml:"auth-dir" json:"-"` @@ -58,44 +44,44 @@ type Config struct { // DisableCooling disables quota cooldown scheduling when true. DisableCooling bool `yaml:"disable-cooling" json:"disable-cooling"` + // RequestRetry defines the retry times when the request failed. + RequestRetry int `yaml:"request-retry" json:"request-retry"` + // MaxRetryInterval defines the maximum wait time in seconds before retrying a cooled-down credential. + MaxRetryInterval int `yaml:"max-retry-interval" json:"max-retry-interval"` + // QuotaExceeded defines the behavior when a quota is exceeded. QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"` // WebsocketAuth enables or disables authentication for the WebSocket API. WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"` + // GeminiKey defines Gemini API key configurations with optional routing overrides. + GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"` + // GlAPIKey exposes the legacy generative language API key list for backward compatibility. GlAPIKey []string `yaml:"generative-language-api-key" json:"generative-language-api-key"` - // GeminiKey defines Gemini API key configurations with optional routing overrides. - GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"` + // Codex defines a list of Codex API key configurations as specified in the YAML configuration file. + CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"` + + // ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file. + ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"` + + // OpenAICompatibility defines OpenAI API compatibility configurations for external providers. + OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility" json:"openai-compatibility"` // VertexCompatAPIKey defines Vertex AI-compatible API key configurations for third-party providers. // Used for services that use Vertex AI-style paths but with simple API key authentication. VertexCompatAPIKey []VertexCompatKey `yaml:"vertex-api-key" json:"vertex-api-key"` - // RequestRetry defines the retry times when the request failed. - RequestRetry int `yaml:"request-retry" json:"request-retry"` - // MaxRetryInterval defines the maximum wait time in seconds before retrying a cooled-down credential. - MaxRetryInterval int `yaml:"max-retry-interval" json:"max-retry-interval"` - - // ClaudeKey defines a list of Claude API key configurations as specified in the YAML configuration file. - ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"` - - // Codex defines a list of Codex API key configurations as specified in the YAML configuration file. - CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"` - - // OpenAICompatibility defines OpenAI API compatibility configurations for external providers. - OpenAICompatibility []OpenAICompatibility `yaml:"openai-compatibility" json:"openai-compatibility"` - - // RemoteManagement nests management-related options under 'remote-management'. - RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"` - - // Payload defines default and override rules for provider payload parameters. - Payload PayloadConfig `yaml:"payload" json:"payload"` + // AmpCode contains Amp CLI upstream configuration, management restrictions, and model mappings. + AmpCode AmpCode `yaml:"ampcode" json:"ampcode"` // OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries. OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"` + + // Payload defines default and override rules for provider payload parameters. + Payload PayloadConfig `yaml:"payload" json:"payload"` } // TLSConfig holds HTTPS server settings. @@ -140,6 +126,26 @@ type AmpModelMapping struct { To string `yaml:"to" json:"to"` } +// AmpCode groups Amp CLI integration settings including upstream routing, +// optional overrides, management route restrictions, and model fallback mappings. +type AmpCode struct { + // UpstreamURL defines the upstream Amp control plane used for non-provider calls. + UpstreamURL string `yaml:"upstream-url" json:"upstream-url"` + + // UpstreamAPIKey optionally overrides the Authorization header when proxying Amp upstream calls. + UpstreamAPIKey string `yaml:"upstream-api-key" json:"upstream-api-key"` + + // RestrictManagementToLocalhost restricts Amp management routes (/api/user, /api/threads, etc.) + // to only accept connections from localhost (127.0.0.1, ::1). When true, prevents drive-by + // browser attacks and remote access to management endpoints. Default: true (recommended). + RestrictManagementToLocalhost bool `yaml:"restrict-management-to-localhost" json:"restrict-management-to-localhost"` + + // ModelMappings defines model name mappings for Amp CLI requests. + // When Amp requests a model that isn't available locally, these mappings + // allow routing to an alternative model that IS available. + ModelMappings []AmpModelMapping `yaml:"model-mappings" json:"model-mappings"` +} + // 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. @@ -318,7 +324,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { cfg.LoggingToFile = false cfg.UsageStatisticsEnabled = false cfg.DisableCooling = false - cfg.AmpRestrictManagementToLocalhost = true // Default to secure: only localhost access + cfg.AmpCode.RestrictManagementToLocalhost = true // Default to secure: only localhost access if err = yaml.Unmarshal(data, &cfg); err != nil { if optional { // In cloud deploy mode, if YAML parsing fails, return empty config instead of error. From 06c0d2bab29e497a0c73ed96d7851418b3f1f485 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:01:56 +0800 Subject: [PATCH 03/10] refactor(config): remove deprecated legacy API key fields --- .../api/handlers/management/config_basic.go | 1 - .../api/handlers/management/config_lists.go | 32 +---- internal/api/server.go | 6 +- internal/config/config.go | 121 +++++++++++++++--- internal/watcher/watcher.go | 119 +++++------------ 5 files changed, 136 insertions(+), 143 deletions(-) diff --git a/internal/api/handlers/management/config_basic.go b/internal/api/handlers/management/config_basic.go index 8f57171e..0f76b5a3 100644 --- a/internal/api/handlers/management/config_basic.go +++ b/internal/api/handlers/management/config_basic.go @@ -17,7 +17,6 @@ func (h *Handler) GetConfig(c *gin.Context) { return } cfgCopy := *h.cfg - cfgCopy.GlAPIKey = geminiKeyStringsFromConfig(h.cfg) c.JSON(200, &cfgCopy) } diff --git a/internal/api/handlers/management/config_lists.go b/internal/api/handlers/management/config_lists.go index 71193084..c5cbfa43 100644 --- a/internal/api/handlers/management/config_lists.go +++ b/internal/api/handlers/management/config_lists.go @@ -147,7 +147,6 @@ func (h *Handler) applyLegacyKeys(keys []string) { } } h.cfg.GeminiKey = newList - h.cfg.GlAPIKey = sanitized h.cfg.SanitizeGeminiKeys() } @@ -409,15 +408,14 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) { } arr = obj.Items } - arr = migrateLegacyOpenAICompatibilityKeys(arr) - // Filter out providers with empty base-url -> remove provider entirely filtered := make([]config.OpenAICompatibility, 0, len(arr)) for i := range arr { + normalizeOpenAICompatibilityEntry(&arr[i]) if strings.TrimSpace(arr[i].BaseURL) != "" { filtered = append(filtered, arr[i]) } } - h.cfg.OpenAICompatibility = migrateLegacyOpenAICompatibilityKeys(filtered) + h.cfg.OpenAICompatibility = filtered h.cfg.SanitizeOpenAICompatibility() h.persist(c) } @@ -431,7 +429,6 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) { c.JSON(400, gin.H{"error": "invalid body"}) return } - h.cfg.OpenAICompatibility = migrateLegacyOpenAICompatibilityKeys(h.cfg.OpenAICompatibility) normalizeOpenAICompatibilityEntry(body.Value) // If base-url becomes empty, delete the provider instead of updating if strings.TrimSpace(body.Value.BaseURL) == "" { @@ -731,28 +728,6 @@ func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) { existing[trimmed] = struct{}{} } } - if len(entry.APIKeys) == 0 { - return - } - for _, legacyKey := range entry.APIKeys { - trimmed := strings.TrimSpace(legacyKey) - if trimmed == "" { - continue - } - if _, ok := existing[trimmed]; ok { - continue - } - entry.APIKeyEntries = append(entry.APIKeyEntries, config.OpenAICompatibilityAPIKey{APIKey: trimmed}) - existing[trimmed] = struct{}{} - } - entry.APIKeys = nil -} - -func migrateLegacyOpenAICompatibilityKeys(entries []config.OpenAICompatibility) []config.OpenAICompatibility { - for i := range entries { - normalizeOpenAICompatibilityEntry(&entries[i]) - } - return entries } func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) []config.OpenAICompatibility { @@ -765,9 +740,6 @@ func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) if len(copyEntry.APIKeyEntries) > 0 { copyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...) } - if len(copyEntry.APIKeys) > 0 { - copyEntry.APIKeys = append([]string(nil), copyEntry.APIKeys...) - } normalizeOpenAICompatibilityEntry(©Entry) out[i] = copyEntry } diff --git a/internal/api/server.go b/internal/api/server.go index 119df848..6866b4cf 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -938,11 +938,7 @@ func (s *Server) UpdateClients(cfg *config.Config) { openAICompatCount := 0 for i := range cfg.OpenAICompatibility { entry := cfg.OpenAICompatibility[i] - if len(entry.APIKeyEntries) > 0 { - openAICompatCount += len(entry.APIKeyEntries) - continue - } - openAICompatCount += len(entry.APIKeys) + openAICompatCount += len(entry.APIKeyEntries) } total := authFiles + geminiAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + vertexAICompatCount + openAICompatCount diff --git a/internal/config/config.go b/internal/config/config.go index afe498de..b256cdd8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -58,9 +58,6 @@ type Config struct { // GeminiKey defines Gemini API key configurations with optional routing overrides. GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"` - // GlAPIKey exposes the legacy generative language API key list for backward compatibility. - GlAPIKey []string `yaml:"generative-language-api-key" json:"generative-language-api-key"` - // Codex defines a list of Codex API key configurations as specified in the YAML configuration file. CodexKey []CodexKey `yaml:"codex-api-key" json:"codex-api-key"` @@ -170,6 +167,17 @@ type PayloadModelRule struct { Protocol string `yaml:"protocol" json:"protocol"` } +type legacyConfigData struct { + LegacyGeminiKeys []string `yaml:"generative-language-api-key"` + OpenAICompat []legacyOpenAICompatibility `yaml:"openai-compatibility"` +} + +type legacyOpenAICompatibility struct { + Name string `yaml:"name"` + BaseURL string `yaml:"base-url"` + APIKeys []string `yaml:"api-keys"` +} + // ClaudeKey represents the configuration for a Claude API key, // including the API key itself and an optional base URL for the API endpoint. type ClaudeKey struct { @@ -250,10 +258,6 @@ type OpenAICompatibility struct { // BaseURL is the base URL for the external OpenAI-compatible API endpoint. BaseURL string `yaml:"base-url" json:"base-url"` - // APIKeys are the authentication keys for accessing the external API services. - // Deprecated: Use APIKeyEntries instead to support per-key proxy configuration. - APIKeys []string `yaml:"api-keys,omitempty" json:"api-keys,omitempty"` - // APIKeyEntries defines API keys with optional per-key proxy configuration. APIKeyEntries []OpenAICompatibilityAPIKey `yaml:"api-key-entries,omitempty" json:"api-key-entries,omitempty"` @@ -333,6 +337,12 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { return nil, fmt.Errorf("failed to parse config file: %w", err) } + var legacy legacyConfigData + if errLegacy := yaml.Unmarshal(data, &legacy); errLegacy == nil { + cfg.migrateLegacyGeminiKeys(legacy.LegacyGeminiKeys) + cfg.migrateLegacyOpenAICompatibilityKeys(legacy.OpenAICompat) + } + // Hash remote management key if plaintext is detected (nested) // We consider a value to be already hashed if it looks like a bcrypt hash ($2a$, $2b$, or $2y$ prefix). if cfg.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) { @@ -451,22 +461,94 @@ func (cfg *Config) SanitizeGeminiKeys() { out = append(out, entry) } cfg.GeminiKey = out +} - if len(cfg.GlAPIKey) > 0 { - for _, raw := range cfg.GlAPIKey { - key := strings.TrimSpace(raw) - if key == "" { - continue - } - if _, exists := seen[key]; exists { - continue - } - cfg.GeminiKey = append(cfg.GeminiKey, GeminiKey{APIKey: key}) - seen[key] = struct{}{} +func (cfg *Config) migrateLegacyGeminiKeys(legacy []string) { + if cfg == nil || len(legacy) == 0 { + return + } + seen := make(map[string]struct{}, len(cfg.GeminiKey)) + for i := range cfg.GeminiKey { + key := strings.TrimSpace(cfg.GeminiKey[i].APIKey) + if key == "" { + continue + } + seen[key] = struct{}{} + } + for _, raw := range legacy { + key := strings.TrimSpace(raw) + if key == "" { + continue + } + if _, exists := seen[key]; exists { + continue + } + cfg.GeminiKey = append(cfg.GeminiKey, GeminiKey{APIKey: key}) + seen[key] = struct{}{} + } +} + +func (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICompatibility) { + if cfg == nil || len(cfg.OpenAICompatibility) == 0 || len(legacy) == 0 { + return + } + lookup := make(map[string]*OpenAICompatibility, len(cfg.OpenAICompatibility)) + for i := range cfg.OpenAICompatibility { + if key := legacyOpenAICompatKey(cfg.OpenAICompatibility[i].Name, cfg.OpenAICompatibility[i].BaseURL); key != "" { + lookup[key] = &cfg.OpenAICompatibility[i] } } + for _, legacyEntry := range legacy { + if len(legacyEntry.APIKeys) == 0 { + continue + } + key := legacyOpenAICompatKey(legacyEntry.Name, legacyEntry.BaseURL) + if key == "" { + continue + } + target := lookup[key] + if target == nil { + continue + } + mergeLegacyOpenAICompatAPIKeys(target, legacyEntry.APIKeys) + } +} - cfg.GlAPIKey = nil +func mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) { + if entry == nil || len(keys) == 0 { + return + } + existing := make(map[string]struct{}, len(entry.APIKeyEntries)) + for i := range entry.APIKeyEntries { + key := strings.TrimSpace(entry.APIKeyEntries[i].APIKey) + if key == "" { + continue + } + existing[key] = struct{}{} + } + for _, raw := range keys { + key := strings.TrimSpace(raw) + if key == "" { + continue + } + if _, ok := existing[key]; ok { + continue + } + entry.APIKeyEntries = append(entry.APIKeyEntries, OpenAICompatibilityAPIKey{APIKey: key}) + existing[key] = struct{}{} + } +} + +func legacyOpenAICompatKey(name, baseURL string) string { + trimmedName := strings.ToLower(strings.TrimSpace(name)) + if trimmedName != "" { + return "name:" + trimmedName + } + trimmedBase := strings.ToLower(strings.TrimSpace(baseURL)) + if trimmedBase != "" { + return "base:" + trimmedBase + } + return "" } func syncInlineAccessProvider(cfg *Config) { @@ -605,6 +687,7 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error { // Remove deprecated auth block before merging to avoid persisting it again. removeMapKey(original.Content[0], "auth") removeLegacyOpenAICompatAPIKeys(original.Content[0]) + removeMapKey(original.Content[0], "generative-language-api-key") pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models") // Merge generated into original in-place, preserving comments/order of existing nodes. diff --git a/internal/watcher/watcher.go b/internal/watcher/watcher.go index 7e6c2631..c582d1e3 100644 --- a/internal/watcher/watcher.go +++ b/internal/watcher/watcher.go @@ -1162,71 +1162,37 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth { // Handle new APIKeyEntries format (preferred) createdEntries := 0 - if len(compat.APIKeyEntries) > 0 { - for j := range compat.APIKeyEntries { - entry := &compat.APIKeyEntries[j] - key := strings.TrimSpace(entry.APIKey) - proxyURL := strings.TrimSpace(entry.ProxyURL) - idKind := fmt.Sprintf("openai-compatibility:%s", providerName) - id, token := idGen.next(idKind, key, base, proxyURL) - attrs := map[string]string{ - "source": fmt.Sprintf("config:%s[%s]", providerName, token), - "base_url": base, - "compat_name": compat.Name, - "provider_key": providerName, - } - if key != "" { - attrs["api_key"] = key - } - if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { - attrs["models_hash"] = hash - } - addConfigHeadersToAttrs(compat.Headers, attrs) - a := &coreauth.Auth{ - ID: id, - Provider: providerName, - Label: compat.Name, - Status: coreauth.StatusActive, - ProxyURL: proxyURL, - Attributes: attrs, - CreatedAt: now, - UpdatedAt: now, - } - out = append(out, a) - createdEntries++ + for j := range compat.APIKeyEntries { + entry := &compat.APIKeyEntries[j] + key := strings.TrimSpace(entry.APIKey) + proxyURL := strings.TrimSpace(entry.ProxyURL) + idKind := fmt.Sprintf("openai-compatibility:%s", providerName) + id, token := idGen.next(idKind, key, base, proxyURL) + attrs := map[string]string{ + "source": fmt.Sprintf("config:%s[%s]", providerName, token), + "base_url": base, + "compat_name": compat.Name, + "provider_key": providerName, } - } else { - // Handle legacy APIKeys format for backward compatibility - for j := range compat.APIKeys { - key := strings.TrimSpace(compat.APIKeys[j]) - if key == "" { - continue - } - idKind := fmt.Sprintf("openai-compatibility:%s", providerName) - id, token := idGen.next(idKind, key, base) - attrs := map[string]string{ - "source": fmt.Sprintf("config:%s[%s]", providerName, token), - "base_url": base, - "compat_name": compat.Name, - "provider_key": providerName, - } + if key != "" { attrs["api_key"] = key - if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { - attrs["models_hash"] = hash - } - addConfigHeadersToAttrs(compat.Headers, attrs) - a := &coreauth.Auth{ - ID: id, - Provider: providerName, - Label: compat.Name, - Status: coreauth.StatusActive, - Attributes: attrs, - CreatedAt: now, - UpdatedAt: now, - } - out = append(out, a) - createdEntries++ } + if hash := computeOpenAICompatModelsHash(compat.Models); hash != "" { + attrs["models_hash"] = hash + } + addConfigHeadersToAttrs(compat.Headers, attrs) + a := &coreauth.Auth{ + ID: id, + Provider: providerName, + Label: compat.Name, + Status: coreauth.StatusActive, + ProxyURL: proxyURL, + Attributes: attrs, + CreatedAt: now, + UpdatedAt: now, + } + out = append(out, a) + createdEntries++ } if createdEntries == 0 { idKind := fmt.Sprintf("openai-compatibility:%s", providerName) @@ -1530,12 +1496,7 @@ func BuildAPIKeyClients(cfg *config.Config) (int, int, int, int, int) { if len(cfg.OpenAICompatibility) > 0 { // Do not construct legacy clients for OpenAI-compat providers; these are handled by the stateless executor. for _, compatConfig := range cfg.OpenAICompatibility { - // Count from new APIKeyEntries format if present, otherwise fall back to legacy APIKeys - if len(compatConfig.APIKeyEntries) > 0 { - openAICompatCount += len(compatConfig.APIKeyEntries) - } else { - openAICompatCount += len(compatConfig.APIKeys) - } + openAICompatCount += len(compatConfig.APIKeyEntries) } } return geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount @@ -1612,24 +1573,9 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi } func countAPIKeys(entry config.OpenAICompatibility) int { - // Prefer new APIKeyEntries format - if len(entry.APIKeyEntries) > 0 { - count := 0 - for _, keyEntry := range entry.APIKeyEntries { - if strings.TrimSpace(keyEntry.APIKey) != "" { - count++ - } - } - return count - } - // Fall back to legacy APIKeys format - return countNonEmptyStrings(entry.APIKeys) -} - -func countNonEmptyStrings(values []string) int { count := 0 - for _, value := range values { - if strings.TrimSpace(value) != "" { + for _, keyEntry := range entry.APIKeyEntries { + if strings.TrimSpace(keyEntry.APIKey) != "" { count++ } } @@ -1754,9 +1700,6 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string { changes = append(changes, fmt.Sprintf("gemini[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count)) } } - if !reflect.DeepEqual(trimStrings(oldCfg.GlAPIKey), trimStrings(newCfg.GlAPIKey)) { - changes = append(changes, "generative-language-api-key: values updated (legacy view, redacted)") - } } // Claude keys (do not print key material) From b6bdbe78ef76d61044d9c70fd94cf5ab7ffecff6 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:23:11 +0800 Subject: [PATCH 04/10] refactor(config): relocate legacy migration helpers to end of file --- internal/config/config.go | 226 +++++++++++++++++++++----------------- 1 file changed, 127 insertions(+), 99 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index b256cdd8..64dba606 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -167,17 +167,6 @@ type PayloadModelRule struct { Protocol string `yaml:"protocol" json:"protocol"` } -type legacyConfigData struct { - LegacyGeminiKeys []string `yaml:"generative-language-api-key"` - OpenAICompat []legacyOpenAICompatibility `yaml:"openai-compatibility"` -} - -type legacyOpenAICompatibility struct { - Name string `yaml:"name"` - BaseURL string `yaml:"base-url"` - APIKeys []string `yaml:"api-keys"` -} - // ClaudeKey represents the configuration for a Claude API key, // including the API key itself and an optional base URL for the API endpoint. type ClaudeKey struct { @@ -341,6 +330,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { if errLegacy := yaml.Unmarshal(data, &legacy); errLegacy == nil { cfg.migrateLegacyGeminiKeys(legacy.LegacyGeminiKeys) cfg.migrateLegacyOpenAICompatibilityKeys(legacy.OpenAICompat) + cfg.migrateLegacyAmpConfig(&legacy) } // Hash remote management key if plaintext is detected (nested) @@ -463,94 +453,6 @@ func (cfg *Config) SanitizeGeminiKeys() { cfg.GeminiKey = out } -func (cfg *Config) migrateLegacyGeminiKeys(legacy []string) { - if cfg == nil || len(legacy) == 0 { - return - } - seen := make(map[string]struct{}, len(cfg.GeminiKey)) - for i := range cfg.GeminiKey { - key := strings.TrimSpace(cfg.GeminiKey[i].APIKey) - if key == "" { - continue - } - seen[key] = struct{}{} - } - for _, raw := range legacy { - key := strings.TrimSpace(raw) - if key == "" { - continue - } - if _, exists := seen[key]; exists { - continue - } - cfg.GeminiKey = append(cfg.GeminiKey, GeminiKey{APIKey: key}) - seen[key] = struct{}{} - } -} - -func (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICompatibility) { - if cfg == nil || len(cfg.OpenAICompatibility) == 0 || len(legacy) == 0 { - return - } - lookup := make(map[string]*OpenAICompatibility, len(cfg.OpenAICompatibility)) - for i := range cfg.OpenAICompatibility { - if key := legacyOpenAICompatKey(cfg.OpenAICompatibility[i].Name, cfg.OpenAICompatibility[i].BaseURL); key != "" { - lookup[key] = &cfg.OpenAICompatibility[i] - } - } - for _, legacyEntry := range legacy { - if len(legacyEntry.APIKeys) == 0 { - continue - } - key := legacyOpenAICompatKey(legacyEntry.Name, legacyEntry.BaseURL) - if key == "" { - continue - } - target := lookup[key] - if target == nil { - continue - } - mergeLegacyOpenAICompatAPIKeys(target, legacyEntry.APIKeys) - } -} - -func mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) { - if entry == nil || len(keys) == 0 { - return - } - existing := make(map[string]struct{}, len(entry.APIKeyEntries)) - for i := range entry.APIKeyEntries { - key := strings.TrimSpace(entry.APIKeyEntries[i].APIKey) - if key == "" { - continue - } - existing[key] = struct{}{} - } - for _, raw := range keys { - key := strings.TrimSpace(raw) - if key == "" { - continue - } - if _, ok := existing[key]; ok { - continue - } - entry.APIKeyEntries = append(entry.APIKeyEntries, OpenAICompatibilityAPIKey{APIKey: key}) - existing[key] = struct{}{} - } -} - -func legacyOpenAICompatKey(name, baseURL string) string { - trimmedName := strings.ToLower(strings.TrimSpace(name)) - if trimmedName != "" { - return "name:" + trimmedName - } - trimmedBase := strings.ToLower(strings.TrimSpace(baseURL)) - if trimmedBase != "" { - return "base:" + trimmedBase - } - return "" -} - func syncInlineAccessProvider(cfg *Config) { if cfg == nil { return @@ -1262,3 +1164,129 @@ func normalizeCollectionNodeStyles(node *yaml.Node) { // Scalars keep their existing style to preserve quoting } } + +// Legacy migration helpers (move deprecated config keys into structured fields). +type legacyConfigData struct { + LegacyGeminiKeys []string `yaml:"generative-language-api-key"` + OpenAICompat []legacyOpenAICompatibility `yaml:"openai-compatibility"` + AmpUpstreamURL string `yaml:"amp-upstream-url"` + AmpUpstreamAPIKey string `yaml:"amp-upstream-api-key"` + AmpRestrictManagement *bool `yaml:"amp-restrict-management-to-localhost"` + AmpModelMappings []AmpModelMapping `yaml:"amp-model-mappings"` +} + +type legacyOpenAICompatibility struct { + Name string `yaml:"name"` + BaseURL string `yaml:"base-url"` + APIKeys []string `yaml:"api-keys"` +} + +func (cfg *Config) migrateLegacyGeminiKeys(legacy []string) { + if cfg == nil || len(legacy) == 0 { + return + } + seen := make(map[string]struct{}, len(cfg.GeminiKey)) + for i := range cfg.GeminiKey { + key := strings.TrimSpace(cfg.GeminiKey[i].APIKey) + if key == "" { + continue + } + seen[key] = struct{}{} + } + for _, raw := range legacy { + key := strings.TrimSpace(raw) + if key == "" { + continue + } + if _, exists := seen[key]; exists { + continue + } + cfg.GeminiKey = append(cfg.GeminiKey, GeminiKey{APIKey: key}) + seen[key] = struct{}{} + } +} + +func (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICompatibility) { + if cfg == nil || len(cfg.OpenAICompatibility) == 0 || len(legacy) == 0 { + return + } + lookup := make(map[string]*OpenAICompatibility, len(cfg.OpenAICompatibility)) + for i := range cfg.OpenAICompatibility { + if key := legacyOpenAICompatKey(cfg.OpenAICompatibility[i].Name, cfg.OpenAICompatibility[i].BaseURL); key != "" { + lookup[key] = &cfg.OpenAICompatibility[i] + } + } + for _, legacyEntry := range legacy { + if len(legacyEntry.APIKeys) == 0 { + continue + } + key := legacyOpenAICompatKey(legacyEntry.Name, legacyEntry.BaseURL) + if key == "" { + continue + } + target := lookup[key] + if target == nil { + continue + } + mergeLegacyOpenAICompatAPIKeys(target, legacyEntry.APIKeys) + } +} + +func mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) { + if entry == nil || len(keys) == 0 { + return + } + existing := make(map[string]struct{}, len(entry.APIKeyEntries)) + for i := range entry.APIKeyEntries { + key := strings.TrimSpace(entry.APIKeyEntries[i].APIKey) + if key == "" { + continue + } + existing[key] = struct{}{} + } + for _, raw := range keys { + key := strings.TrimSpace(raw) + if key == "" { + continue + } + if _, ok := existing[key]; ok { + continue + } + entry.APIKeyEntries = append(entry.APIKeyEntries, OpenAICompatibilityAPIKey{APIKey: key}) + existing[key] = struct{}{} + } +} + +func legacyOpenAICompatKey(name, baseURL string) string { + trimmedName := strings.ToLower(strings.TrimSpace(name)) + if trimmedName != "" { + return "name:" + trimmedName + } + trimmedBase := strings.ToLower(strings.TrimSpace(baseURL)) + if trimmedBase != "" { + return "base:" + trimmedBase + } + return "" +} + +func (cfg *Config) migrateLegacyAmpConfig(legacy *legacyConfigData) { + if cfg == nil || legacy == nil { + return + } + if cfg.AmpCode.UpstreamURL == "" { + if val := strings.TrimSpace(legacy.AmpUpstreamURL); val != "" { + cfg.AmpCode.UpstreamURL = val + } + } + if cfg.AmpCode.UpstreamAPIKey == "" { + if val := strings.TrimSpace(legacy.AmpUpstreamAPIKey); val != "" { + cfg.AmpCode.UpstreamAPIKey = val + } + } + if legacy.AmpRestrictManagement != nil { + cfg.AmpCode.RestrictManagementToLocalhost = *legacy.AmpRestrictManagement + } + if len(cfg.AmpCode.ModelMappings) == 0 && len(legacy.AmpModelMappings) > 0 { + cfg.AmpCode.ModelMappings = append([]AmpModelMapping(nil), legacy.AmpModelMappings...) + } +} From df0fd1add12cd513ab989755f3ddcdc562dc6a2e Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 3 Dec 2025 11:42:15 +0800 Subject: [PATCH 05/10] refactor(config): remove deprecated AMP configuration keys during save --- internal/config/config.go | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/internal/config/config.go b/internal/config/config.go index 64dba606..84941c81 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -588,8 +588,9 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error { // Remove deprecated auth block before merging to avoid persisting it again. removeMapKey(original.Content[0], "auth") - removeLegacyOpenAICompatAPIKeys(original.Content[0]) removeMapKey(original.Content[0], "generative-language-api-key") + removeLegacyOpenAICompatAPIKeys(original.Content[0]) + removeLegacyAmpKeys(original.Content[0]) pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models") // Merge generated into original in-place, preserving comments/order of existing nodes. @@ -1071,6 +1072,16 @@ func removeLegacyOpenAICompatAPIKeys(root *yaml.Node) { } } +func removeLegacyAmpKeys(root *yaml.Node) { + if root == nil || root.Kind != yaml.MappingNode { + return + } + removeMapKey(root, "amp-upstream-url") + removeMapKey(root, "amp-upstream-api-key") + removeMapKey(root, "amp-restrict-management-to-localhost") + removeMapKey(root, "amp-model-mappings") +} + func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, key string) { if key == "" || dstRoot == nil || srcRoot == nil { return From b5033c22d8d8a3b8d6b693081cb37fa0ad4f9c3a Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:26:04 +0800 Subject: [PATCH 06/10] refactor(config): auto-persist migrated legacy configuration fields --- internal/config/config.go | 133 +++++++++++++++++++++++++------------- 1 file changed, 89 insertions(+), 44 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 84941c81..9ab2fdc2 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -79,6 +79,8 @@ type Config struct { // Payload defines default and override rules for provider payload parameters. Payload PayloadConfig `yaml:"payload" json:"payload"` + + legacyMigrationPending bool `yaml:"-" json:"-"` } // TLSConfig holds HTTPS server settings. @@ -328,9 +330,15 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { var legacy legacyConfigData if errLegacy := yaml.Unmarshal(data, &legacy); errLegacy == nil { - cfg.migrateLegacyGeminiKeys(legacy.LegacyGeminiKeys) - cfg.migrateLegacyOpenAICompatibilityKeys(legacy.OpenAICompat) - cfg.migrateLegacyAmpConfig(&legacy) + if cfg.migrateLegacyGeminiKeys(legacy.LegacyGeminiKeys) { + cfg.legacyMigrationPending = true + } + if cfg.migrateLegacyOpenAICompatibilityKeys(legacy.OpenAICompat) { + cfg.legacyMigrationPending = true + } + if cfg.migrateLegacyAmpConfig(&legacy) { + cfg.legacyMigrationPending = true + } } // Hash remote management key if plaintext is detected (nested) @@ -368,6 +376,12 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Normalize OAuth provider model exclusion map. cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels) + if cfg.legacyMigrationPending && !optional && configFile != "" { + if err := SaveConfigPreserveComments(configFile, &cfg); err != nil { + return nil, fmt.Errorf("failed to persist migrated legacy config: %w", err) + } + } + // Return the populated configuration struct. return &cfg, nil } @@ -586,11 +600,12 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error { return fmt.Errorf("expected generated root mapping node") } - // Remove deprecated auth block before merging to avoid persisting it again. - removeMapKey(original.Content[0], "auth") - removeMapKey(original.Content[0], "generative-language-api-key") + // Remove deprecated sections before merging back the sanitized config. + removeLegacyAuthBlock(original.Content[0]) removeLegacyOpenAICompatAPIKeys(original.Content[0]) removeLegacyAmpKeys(original.Content[0]) + removeLegacyGenerativeLanguageKeys(original.Content[0]) + pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models") // Merge generated into original in-place, preserving comments/order of existing nodes. @@ -1053,35 +1068,6 @@ func removeMapKey(mapNode *yaml.Node, key string) { } } -func removeLegacyOpenAICompatAPIKeys(root *yaml.Node) { - if root == nil || root.Kind != yaml.MappingNode { - return - } - idx := findMapKeyIndex(root, "openai-compatibility") - if idx < 0 || idx+1 >= len(root.Content) { - return - } - seq := root.Content[idx+1] - if seq == nil || seq.Kind != yaml.SequenceNode { - return - } - for i := range seq.Content { - if seq.Content[i] != nil && seq.Content[i].Kind == yaml.MappingNode { - removeMapKey(seq.Content[i], "api-keys") - } - } -} - -func removeLegacyAmpKeys(root *yaml.Node) { - if root == nil || root.Kind != yaml.MappingNode { - return - } - removeMapKey(root, "amp-upstream-url") - removeMapKey(root, "amp-upstream-api-key") - removeMapKey(root, "amp-restrict-management-to-localhost") - removeMapKey(root, "amp-model-mappings") -} - func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, key string) { if key == "" || dstRoot == nil || srcRoot == nil { return @@ -1192,10 +1178,11 @@ type legacyOpenAICompatibility struct { APIKeys []string `yaml:"api-keys"` } -func (cfg *Config) migrateLegacyGeminiKeys(legacy []string) { +func (cfg *Config) migrateLegacyGeminiKeys(legacy []string) bool { if cfg == nil || len(legacy) == 0 { - return + return false } + changed := false seen := make(map[string]struct{}, len(cfg.GeminiKey)) for i := range cfg.GeminiKey { key := strings.TrimSpace(cfg.GeminiKey[i].APIKey) @@ -1214,13 +1201,16 @@ func (cfg *Config) migrateLegacyGeminiKeys(legacy []string) { } cfg.GeminiKey = append(cfg.GeminiKey, GeminiKey{APIKey: key}) seen[key] = struct{}{} + changed = true } + return changed } -func (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICompatibility) { +func (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICompatibility) bool { if cfg == nil || len(cfg.OpenAICompatibility) == 0 || len(legacy) == 0 { - return + return false } + changed := false lookup := make(map[string]*OpenAICompatibility, len(cfg.OpenAICompatibility)) for i := range cfg.OpenAICompatibility { if key := legacyOpenAICompatKey(cfg.OpenAICompatibility[i].Name, cfg.OpenAICompatibility[i].BaseURL); key != "" { @@ -1239,14 +1229,18 @@ func (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICom if target == nil { continue } - mergeLegacyOpenAICompatAPIKeys(target, legacyEntry.APIKeys) + if mergeLegacyOpenAICompatAPIKeys(target, legacyEntry.APIKeys) { + changed = true + } } + return changed } -func mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) { +func mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) bool { if entry == nil || len(keys) == 0 { - return + return false } + changed := false existing := make(map[string]struct{}, len(entry.APIKeyEntries)) for i := range entry.APIKeyEntries { key := strings.TrimSpace(entry.APIKeyEntries[i].APIKey) @@ -1265,7 +1259,9 @@ func mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) { } entry.APIKeyEntries = append(entry.APIKeyEntries, OpenAICompatibilityAPIKey{APIKey: key}) existing[key] = struct{}{} + changed = true } + return changed } func legacyOpenAICompatKey(name, baseURL string) string { @@ -1280,24 +1276,73 @@ func legacyOpenAICompatKey(name, baseURL string) string { return "" } -func (cfg *Config) migrateLegacyAmpConfig(legacy *legacyConfigData) { +func (cfg *Config) migrateLegacyAmpConfig(legacy *legacyConfigData) bool { if cfg == nil || legacy == nil { - return + return false } + changed := false if cfg.AmpCode.UpstreamURL == "" { if val := strings.TrimSpace(legacy.AmpUpstreamURL); val != "" { cfg.AmpCode.UpstreamURL = val + changed = true } } if cfg.AmpCode.UpstreamAPIKey == "" { if val := strings.TrimSpace(legacy.AmpUpstreamAPIKey); val != "" { cfg.AmpCode.UpstreamAPIKey = val + changed = true } } if legacy.AmpRestrictManagement != nil { cfg.AmpCode.RestrictManagementToLocalhost = *legacy.AmpRestrictManagement + changed = true } if len(cfg.AmpCode.ModelMappings) == 0 && len(legacy.AmpModelMappings) > 0 { cfg.AmpCode.ModelMappings = append([]AmpModelMapping(nil), legacy.AmpModelMappings...) + changed = true + } + return changed +} + +func removeLegacyOpenAICompatAPIKeys(root *yaml.Node) { + if root == nil || root.Kind != yaml.MappingNode { + return + } + idx := findMapKeyIndex(root, "openai-compatibility") + if idx < 0 || idx+1 >= len(root.Content) { + return + } + seq := root.Content[idx+1] + if seq == nil || seq.Kind != yaml.SequenceNode { + return + } + for i := range seq.Content { + if seq.Content[i] != nil && seq.Content[i].Kind == yaml.MappingNode { + removeMapKey(seq.Content[i], "api-keys") + } } } + +func removeLegacyAmpKeys(root *yaml.Node) { + if root == nil || root.Kind != yaml.MappingNode { + return + } + removeMapKey(root, "amp-upstream-url") + removeMapKey(root, "amp-upstream-api-key") + removeMapKey(root, "amp-restrict-management-to-localhost") + removeMapKey(root, "amp-model-mappings") +} + +func removeLegacyGenerativeLanguageKeys(root *yaml.Node) { + if root == nil || root.Kind != yaml.MappingNode { + return + } + removeMapKey(root, "generative-language-api-key") +} + +func removeLegacyAuthBlock(root *yaml.Node) { + if root == nil || root.Kind != yaml.MappingNode { + return + } + removeMapKey(root, "auth") +} From b693d632d2c9643561c5f6f3c12372454bab1ddd Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:31:41 +0800 Subject: [PATCH 07/10] docs(config): comment out example API key configurations --- config.example.yaml | 224 ++++++++++++++++++++++---------------------- 1 file changed, 112 insertions(+), 112 deletions(-) diff --git a/config.example.yaml b/config.example.yaml index c1636166..61f51d47 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -56,129 +56,129 @@ quota-exceeded: ws-auth: false # Gemini API keys -gemini-api-key: - - api-key: "AIzaSy...01" - base-url: "https://generativelanguage.googleapis.com" - headers: - X-Custom-Header: "custom-value" - proxy-url: "socks5://proxy.example.com:1080" - excluded-models: - - "gemini-2.5-pro" # exclude specific models from this provider (exact match) - - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) - - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview) - - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) - - api-key: "AIzaSy...02" +# gemini-api-key: +# - api-key: "AIzaSy...01" +# base-url: "https://generativelanguage.googleapis.com" +# headers: +# X-Custom-Header: "custom-value" +# proxy-url: "socks5://proxy.example.com:1080" +# excluded-models: +# - "gemini-2.5-pro" # exclude specific models from this provider (exact match) +# - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) +# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview) +# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) +# - api-key: "AIzaSy...02" # Codex API keys -codex-api-key: - - api-key: "sk-atSM..." - base-url: "https://www.example.com" # use the custom codex API endpoint - headers: - X-Custom-Header: "custom-value" - proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override - excluded-models: - - "gpt-5.1" # exclude specific models (exact match) - - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex) - - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini) - - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low) +# codex-api-key: +# - api-key: "sk-atSM..." +# base-url: "https://www.example.com" # use the custom codex API endpoint +# headers: +# X-Custom-Header: "custom-value" +# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override +# excluded-models: +# - "gpt-5.1" # exclude specific models (exact match) +# - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex) +# - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini) +# - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low) # Claude API keys -claude-api-key: - - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url - - api-key: "sk-atSM..." - base-url: "https://www.example.com" # use the custom claude API endpoint - headers: - X-Custom-Header: "custom-value" - proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override - models: - - name: "claude-3-5-sonnet-20241022" # upstream model name - alias: "claude-sonnet-latest" # client alias mapped to the upstream model - excluded-models: - - "claude-opus-4-5-20251101" # exclude specific models (exact match) - - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219) - - "*-think" # wildcard matching suffix (e.g. claude-opus-4-5-thinking) - - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022) +# claude-api-key: +# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url +# - api-key: "sk-atSM..." +# base-url: "https://www.example.com" # use the custom claude API endpoint +# headers: +# X-Custom-Header: "custom-value" +# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override +# models: +# - name: "claude-3-5-sonnet-20241022" # upstream model name +# alias: "claude-sonnet-latest" # client alias mapped to the upstream model +# excluded-models: +# - "claude-opus-4-5-20251101" # exclude specific models (exact match) +# - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219) +# - "*-think" # wildcard matching suffix (e.g. claude-opus-4-5-thinking) +# - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022) # OpenAI compatibility providers -openai-compatibility: - - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. - base-url: "https://openrouter.ai/api/v1" # The base URL of the provider. - headers: - X-Custom-Header: "custom-value" - api-key-entries: - - api-key: "sk-or-v1-...b780" - proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override - - api-key: "sk-or-v1-...b781" # without proxy-url - models: # The models supported by the provider. - - name: "moonshotai/kimi-k2:free" # The actual model name. - alias: "kimi-k2" # The alias used in the API. +# openai-compatibility: +# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places. +# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider. +# headers: +# X-Custom-Header: "custom-value" +# api-key-entries: +# - api-key: "sk-or-v1-...b780" +# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override +# - api-key: "sk-or-v1-...b781" # without proxy-url +# models: # The models supported by the provider. +# - name: "moonshotai/kimi-k2:free" # The actual model name. +# alias: "kimi-k2" # The alias used in the API. # Vertex API keys (Vertex-compatible endpoints, use API key + base URL) -vertex-api-key: - - api-key: "vk-123..." # x-goog-api-key header - base-url: "https://example.com/api" # e.g. https://zenmux.ai/api - proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override - headers: - X-Custom-Header: "custom-value" - models: # optional: map aliases to upstream model names - - name: "gemini-2.0-flash" # upstream model name - alias: "vertex-flash" # client-visible alias - - name: "gemini-1.5-pro" - alias: "vertex-pro" +# vertex-api-key: +# - api-key: "vk-123..." # x-goog-api-key header +# base-url: "https://example.com/api" # e.g. https://zenmux.ai/api +# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override +# headers: +# X-Custom-Header: "custom-value" +# models: # optional: map aliases to upstream model names +# - name: "gemini-2.0-flash" # upstream model name +# alias: "vertex-flash" # client-visible alias +# - name: "gemini-1.5-pro" +# alias: "vertex-pro" # Amp Integration -ampcode: - # Configure upstream URL for Amp CLI OAuth and management features - upstream-url: "https://ampcode.com" - # Optional: Override API key for Amp upstream (otherwise uses env or file) - upstream-api-key: "" - # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended) - restrict-management-to-localhost: true - # Amp Model Mappings - # Route unavailable Amp models to alternative models available in your local proxy. - # Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5) - # but you have a similar model available (e.g., Claude Sonnet 4). - model-mappings: - - from: "claude-opus-4.5" # Model requested by Amp CLI - to: "claude-sonnet-4" # Route to this available model instead - - from: "gpt-5" - to: "gemini-2.5-pro" - - from: "claude-3-opus-20240229" - to: "claude-3-5-sonnet-20241022" +# ampcode: +# # Configure upstream URL for Amp CLI OAuth and management features +# upstream-url: "https://ampcode.com" +# # Optional: Override API key for Amp upstream (otherwise uses env or file) +# upstream-api-key: "" +# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended) +# restrict-management-to-localhost: true +# # Amp Model Mappings +# # Route unavailable Amp models to alternative models available in your local proxy. +# # Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5) +# # but you have a similar model available (e.g., Claude Sonnet 4). +# model-mappings: +# - from: "claude-opus-4.5" # Model requested by Amp CLI +# to: "claude-sonnet-4" # Route to this available model instead +# - from: "gpt-5" +# to: "gemini-2.5-pro" +# - from: "claude-3-opus-20240229" +# to: "claude-3-5-sonnet-20241022" # OAuth provider excluded models -oauth-excluded-models: - gemini-cli: - - "gemini-2.5-pro" # exclude specific models (exact match) - - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) - - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview) - - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) - vertex: - - "gemini-3-pro-preview" - aistudio: - - "gemini-3-pro-preview" - antigravity: - - "gemini-3-pro-preview" - claude: - - "claude-3-5-haiku-20241022" - codex: - - "gpt-5-codex-mini" - qwen: - - "vision-model" - iflow: - - "tstars2.0" +# oauth-excluded-models: +# gemini-cli: +# - "gemini-2.5-pro" # exclude specific models (exact match) +# - "gemini-2.5-*" # wildcard matching prefix (e.g. gemini-2.5-flash, gemini-2.5-pro) +# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview) +# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite) +# vertex: +# - "gemini-3-pro-preview" +# aistudio: +# - "gemini-3-pro-preview" +# antigravity: +# - "gemini-3-pro-preview" +# claude: +# - "claude-3-5-haiku-20241022" +# codex: +# - "gpt-5-codex-mini" +# qwen: +# - "vision-model" +# iflow: +# - "tstars2.0" # Optional payload configuration -payload: - default: # Default rules only set parameters when they are missing in the payload. - - models: - - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*") - protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex - params: # JSON path (gjson/sjson syntax) -> value - "generationConfig.thinkingConfig.thinkingBudget": 32768 - override: # Override rules always set parameters, overwriting any existing values. - - models: - - name: "gpt-*" # Supports wildcards (e.g., "gpt-*") - protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex - params: # JSON path (gjson/sjson syntax) -> value - "reasoning.effort": "high" +# payload: +# default: # Default rules only set parameters when they are missing in the payload. +# - models: +# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*") +# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex +# params: # JSON path (gjson/sjson syntax) -> value +# "generationConfig.thinkingConfig.thinkingBudget": 32768 +# override: # Override rules always set parameters, overwriting any existing values. +# - models: +# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*") +# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex +# params: # JSON path (gjson/sjson syntax) -> value +# "reasoning.effort": "high" From 8c42b21e66f13c07a41b26fc8f77ad2e89dc9258 Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 3 Dec 2025 12:41:17 +0800 Subject: [PATCH 08/10] refactor(config): improve OpenAI compatibility target matching logic --- internal/config/config.go | 44 ++++---- test/config_migration_test.go | 195 ++++++++++++++++++++++++++++++++++ 2 files changed, 220 insertions(+), 19 deletions(-) create mode 100644 test/config_migration_test.go diff --git a/internal/config/config.go b/internal/config/config.go index 9ab2fdc2..8155231f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -1211,21 +1211,11 @@ func (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICom return false } changed := false - lookup := make(map[string]*OpenAICompatibility, len(cfg.OpenAICompatibility)) - for i := range cfg.OpenAICompatibility { - if key := legacyOpenAICompatKey(cfg.OpenAICompatibility[i].Name, cfg.OpenAICompatibility[i].BaseURL); key != "" { - lookup[key] = &cfg.OpenAICompatibility[i] - } - } for _, legacyEntry := range legacy { if len(legacyEntry.APIKeys) == 0 { continue } - key := legacyOpenAICompatKey(legacyEntry.Name, legacyEntry.BaseURL) - if key == "" { - continue - } - target := lookup[key] + target := findOpenAICompatTarget(cfg.OpenAICompatibility, legacyEntry.Name, legacyEntry.BaseURL) if target == nil { continue } @@ -1264,16 +1254,32 @@ func mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) b return changed } -func legacyOpenAICompatKey(name, baseURL string) string { - trimmedName := strings.ToLower(strings.TrimSpace(name)) - if trimmedName != "" { - return "name:" + trimmedName +func findOpenAICompatTarget(entries []OpenAICompatibility, legacyName, legacyBase string) *OpenAICompatibility { + nameKey := strings.ToLower(strings.TrimSpace(legacyName)) + baseKey := strings.ToLower(strings.TrimSpace(legacyBase)) + if nameKey != "" && baseKey != "" { + for i := range entries { + if strings.ToLower(strings.TrimSpace(entries[i].Name)) == nameKey && + strings.ToLower(strings.TrimSpace(entries[i].BaseURL)) == baseKey { + return &entries[i] + } + } } - trimmedBase := strings.ToLower(strings.TrimSpace(baseURL)) - if trimmedBase != "" { - return "base:" + trimmedBase + if baseKey != "" { + for i := range entries { + if strings.ToLower(strings.TrimSpace(entries[i].BaseURL)) == baseKey { + return &entries[i] + } + } } - return "" + if nameKey != "" { + for i := range entries { + if strings.ToLower(strings.TrimSpace(entries[i].Name)) == nameKey { + return &entries[i] + } + } + } + return nil } func (cfg *Config) migrateLegacyAmpConfig(legacy *legacyConfigData) bool { diff --git a/test/config_migration_test.go b/test/config_migration_test.go new file mode 100644 index 00000000..2ed87882 --- /dev/null +++ b/test/config_migration_test.go @@ -0,0 +1,195 @@ +package test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/router-for-me/CLIProxyAPI/v6/internal/config" +) + +func TestLegacyConfigMigration(t *testing.T) { + t.Run("onlyLegacyFields", func(t *testing.T) { + path := writeConfig(t, ` +port: 8080 +generative-language-api-key: + - "legacy-gemini-1" +openai-compatibility: + - name: "legacy-provider" + base-url: "https://example.com" + api-keys: + - "legacy-openai-1" +amp-upstream-url: "https://amp.example.com" +amp-upstream-api-key: "amp-legacy-key" +amp-restrict-management-to-localhost: false +amp-model-mappings: + - from: "old-model" + to: "new-model" +`) + cfg, err := config.LoadConfig(path) + if err != nil { + t.Fatalf("load legacy config: %v", err) + } + if got := len(cfg.GeminiKey); got != 1 || cfg.GeminiKey[0].APIKey != "legacy-gemini-1" { + t.Fatalf("gemini migration mismatch: %+v", cfg.GeminiKey) + } + if got := len(cfg.OpenAICompatibility); got != 1 { + t.Fatalf("expected 1 openai-compat provider, got %d", got) + } + if entries := cfg.OpenAICompatibility[0].APIKeyEntries; len(entries) != 1 || entries[0].APIKey != "legacy-openai-1" { + t.Fatalf("openai-compat migration mismatch: %+v", entries) + } + if cfg.AmpCode.UpstreamURL != "https://amp.example.com" || cfg.AmpCode.UpstreamAPIKey != "amp-legacy-key" { + t.Fatalf("amp migration failed: %+v", cfg.AmpCode) + } + if cfg.AmpCode.RestrictManagementToLocalhost { + t.Fatalf("expected amp restriction to be false after migration") + } + if got := len(cfg.AmpCode.ModelMappings); got != 1 || cfg.AmpCode.ModelMappings[0].From != "old-model" { + t.Fatalf("amp mappings migration mismatch: %+v", cfg.AmpCode.ModelMappings) + } + updated := readFile(t, path) + if strings.Contains(updated, "generative-language-api-key") { + t.Fatalf("legacy gemini key still present:\n%s", updated) + } + if strings.Contains(updated, "amp-upstream-url") || strings.Contains(updated, "amp-restrict-management-to-localhost") { + t.Fatalf("legacy amp keys still present:\n%s", updated) + } + if strings.Contains(updated, "\n api-keys:") { + t.Fatalf("legacy openai compat keys still present:\n%s", updated) + } + }) + + t.Run("mixedLegacyAndNewFields", func(t *testing.T) { + path := writeConfig(t, ` +gemini-api-key: + - api-key: "new-gemini" +generative-language-api-key: + - "new-gemini" + - "legacy-gemini-only" +openai-compatibility: + - name: "mixed-provider" + base-url: "https://mixed.example.com" + api-key-entries: + - api-key: "new-entry" + api-keys: + - "legacy-entry" + - "new-entry" +`) + cfg, err := config.LoadConfig(path) + if err != nil { + t.Fatalf("load mixed config: %v", err) + } + if got := len(cfg.GeminiKey); got != 2 { + t.Fatalf("expected 2 gemini entries, got %d: %+v", got, cfg.GeminiKey) + } + seen := make(map[string]struct{}, len(cfg.GeminiKey)) + for _, entry := range cfg.GeminiKey { + if _, exists := seen[entry.APIKey]; exists { + t.Fatalf("duplicate gemini key %q after migration", entry.APIKey) + } + seen[entry.APIKey] = struct{}{} + } + provider := cfg.OpenAICompatibility[0] + if got := len(provider.APIKeyEntries); got != 2 { + t.Fatalf("expected 2 openai entries, got %d: %+v", got, provider.APIKeyEntries) + } + entrySeen := make(map[string]struct{}, len(provider.APIKeyEntries)) + for _, entry := range provider.APIKeyEntries { + if _, ok := entrySeen[entry.APIKey]; ok { + t.Fatalf("duplicate openai key %q after migration", entry.APIKey) + } + entrySeen[entry.APIKey] = struct{}{} + } + }) + + t.Run("onlyNewFields", func(t *testing.T) { + path := writeConfig(t, ` +gemini-api-key: + - api-key: "new-only" +openai-compatibility: + - name: "new-only-provider" + base-url: "https://new-only.example.com" + api-key-entries: + - api-key: "new-only-entry" +ampcode: + upstream-url: "https://amp.new" + upstream-api-key: "new-amp-key" + restrict-management-to-localhost: true + model-mappings: + - from: "a" + to: "b" +`) + cfg, err := config.LoadConfig(path) + if err != nil { + t.Fatalf("load new config: %v", err) + } + if len(cfg.GeminiKey) != 1 || cfg.GeminiKey[0].APIKey != "new-only" { + t.Fatalf("unexpected gemini entries: %+v", cfg.GeminiKey) + } + if len(cfg.OpenAICompatibility) != 1 || len(cfg.OpenAICompatibility[0].APIKeyEntries) != 1 { + t.Fatalf("unexpected openai compat entries: %+v", cfg.OpenAICompatibility) + } + if cfg.AmpCode.UpstreamURL != "https://amp.new" || cfg.AmpCode.UpstreamAPIKey != "new-amp-key" { + t.Fatalf("unexpected amp config: %+v", cfg.AmpCode) + } + }) + + t.Run("duplicateNamesDifferentBase", func(t *testing.T) { + path := writeConfig(t, ` +openai-compatibility: + - name: "dup-provider" + base-url: "https://provider-a" + api-keys: + - "key-a" + - name: "dup-provider" + base-url: "https://provider-b" + api-keys: + - "key-b" +`) + cfg, err := config.LoadConfig(path) + if err != nil { + t.Fatalf("load duplicate config: %v", err) + } + if len(cfg.OpenAICompatibility) != 2 { + t.Fatalf("expected 2 providers, got %d", len(cfg.OpenAICompatibility)) + } + for _, entry := range cfg.OpenAICompatibility { + if len(entry.APIKeyEntries) != 1 { + t.Fatalf("expected 1 key entry per provider: %+v", entry) + } + switch entry.BaseURL { + case "https://provider-a": + if entry.APIKeyEntries[0].APIKey != "key-a" { + t.Fatalf("provider-a key mismatch: %+v", entry.APIKeyEntries) + } + case "https://provider-b": + if entry.APIKeyEntries[0].APIKey != "key-b" { + t.Fatalf("provider-b key mismatch: %+v", entry.APIKeyEntries) + } + default: + t.Fatalf("unexpected provider base url: %s", entry.BaseURL) + } + } + }) +} + +func writeConfig(t *testing.T, content string) string { + t.Helper() + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + if err := os.WriteFile(path, []byte(strings.TrimSpace(content)+"\n"), 0o644); err != nil { + t.Fatalf("write temp config: %v", err) + } + return path +} + +func readFile(t *testing.T, path string) string { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read temp config: %v", err) + } + return string(data) +} From 651179a6428ff5d86020e139fadb2707a764ce2b Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:39:10 +0800 Subject: [PATCH 09/10] refactor(config): add detailed logging for legacy configuration migration --- internal/config/config.go | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/config/config.go b/internal/config/config.go index 8155231f..2681d049 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -376,9 +376,15 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) { // Normalize OAuth provider model exclusion map. cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels) - if cfg.legacyMigrationPending && !optional && configFile != "" { - if err := SaveConfigPreserveComments(configFile, &cfg); err != nil { - return nil, fmt.Errorf("failed to persist migrated legacy config: %w", err) + if cfg.legacyMigrationPending { + fmt.Println("Detected legacy configuration keys, attempting to persist the normalized config...") + if !optional && configFile != "" { + if err := SaveConfigPreserveComments(configFile, &cfg); err != nil { + return nil, fmt.Errorf("failed to persist migrated legacy config: %w", err) + } + fmt.Println("Legacy configuration normalized and persisted.") + } else { + fmt.Println("Legacy configuration normalized in memory; persistence skipped.") } } From c4a5be6edf134bd0cf1a737727d072b734416fbd Mon Sep 17 00:00:00 2001 From: hkfires <10558748+hkfires@users.noreply.github.com> Date: Wed, 3 Dec 2025 13:53:18 +0800 Subject: [PATCH 10/10] style(amp): standardize log message capitalization --- internal/api/modules/amp/amp.go | 28 ++++++++++--------- internal/api/modules/amp/fallback_handlers.go | 8 +++--- internal/api/modules/amp/routes.go | 8 +++--- 3 files changed, 23 insertions(+), 21 deletions(-) diff --git a/internal/api/modules/amp/amp.go b/internal/api/modules/amp/amp.go index fac77bfb..78e7aa50 100644 --- a/internal/api/modules/amp/amp.go +++ b/internal/api/modules/amp/amp.go @@ -112,8 +112,8 @@ func (m *AmpModule) Register(ctx modules.Context) error { // If no upstream URL, skip proxy routes but provider aliases are still available if upstreamURL == "" { - log.Debug("Amp upstream proxy disabled (no upstream URL configured)") - log.Debug("Amp provider alias routes registered") + log.Debug("amp upstream proxy disabled (no upstream URL configured)") + log.Debug("amp provider alias routes registered") m.enabled = false return } @@ -139,8 +139,8 @@ func (m *AmpModule) Register(ctx modules.Context) error { handler := proxyHandler(proxy) m.registerManagementRoutes(ctx.Engine, ctx.BaseHandler, handler, settings.RestrictManagementToLocalhost) - log.Infof("Amp upstream proxy enabled for: %s", upstreamURL) - log.Debug("Amp provider alias routes registered") + log.Infof("amp upstream proxy enabled for: %s", upstreamURL) + log.Debug("amp provider alias routes registered") }) return regErr @@ -156,7 +156,7 @@ func (m *AmpModule) getAuthMiddleware(ctx modules.Context) gin.HandlerFunc { return ctx.AuthMiddleware } // Fallback: no authentication (should not happen in production) - log.Warn("Amp module: no auth middleware provided, allowing all requests") + log.Warn("amp module: no auth middleware provided, allowing all requests") return func(c *gin.Context) { c.Next() } @@ -165,23 +165,25 @@ func (m *AmpModule) getAuthMiddleware(ctx modules.Context) gin.HandlerFunc { // OnConfigUpdated handles configuration updates. // Currently requires restart for URL changes (could be enhanced for dynamic updates). func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error { + settings := cfg.AmpCode + // Update model mappings (hot-reload supported) if m.modelMapper != nil { - settings := cfg.AmpCode - log.Infof("amp config updated: reloading %d model mapping(s)", len(settings.ModelMappings)) m.modelMapper.UpdateMappings(settings.ModelMappings) - } else { + if m.enabled { + log.Infof("amp config updated: reloading %d model mapping(s)", len(settings.ModelMappings)) + } + } else if m.enabled { log.Warnf("amp model mapper not initialized, skipping model mapping update") } if !m.enabled { - log.Debug("Amp routing not enabled, skipping other config updates") return nil } - upstreamURL := strings.TrimSpace(cfg.AmpCode.UpstreamURL) + upstreamURL := strings.TrimSpace(settings.UpstreamURL) if upstreamURL == "" { - log.Warn("Amp upstream URL removed from config, restart required to disable") + log.Warn("amp upstream URL removed from config, restart required to disable") return nil } @@ -189,11 +191,11 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error { if m.secretSource != nil { if ms, ok := m.secretSource.(*MultiSourceSecret); ok { ms.InvalidateCache() - log.Debug("Amp secret cache invalidated due to config update") + log.Debug("amp secret cache invalidated due to config update") } } - log.Debug("Amp config updated (restart required for URL changes)") + log.Debug("amp config updated (restart required for URL changes)") return nil } diff --git a/internal/api/modules/amp/fallback_handlers.go b/internal/api/modules/amp/fallback_handlers.go index 17c60708..69d8ba74 100644 --- a/internal/api/modules/amp/fallback_handlers.go +++ b/internal/api/modules/amp/fallback_handlers.go @@ -48,25 +48,25 @@ func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provid case RouteTypeLocalProvider: fields["cost"] = "free" fields["source"] = "local_oauth" - log.WithFields(fields).Infof("[AMP] Using local provider for model: %s", requestedModel) + log.WithFields(fields).Infof("[amp] using local provider for model: %s", requestedModel) case RouteTypeModelMapping: fields["cost"] = "free" fields["source"] = "local_oauth" fields["mapping"] = requestedModel + " -> " + resolvedModel - log.WithFields(fields).Infof("[AMP] Model mapped: %s -> %s", requestedModel, resolvedModel) + log.WithFields(fields).Infof("[amp] model mapped: %s -> %s", requestedModel, resolvedModel) case RouteTypeAmpCredits: fields["cost"] = "amp_credits" fields["source"] = "ampcode.com" fields["model_id"] = requestedModel // Explicit model_id for easy config reference - log.WithFields(fields).Warnf("[AMP] Forwarding to ampcode.com (uses Amp credits) - model_id: %s | To use local proxy, add to config: amp-model-mappings: [{from: \"%s\", to: \"\"}]", requestedModel, requestedModel) + log.WithFields(fields).Warnf("[amp] forwarding to ampcode.com (uses amp credits) - model_id: %s | To use local proxy, add to config: amp-model-mappings: [{from: \"%s\", to: \"\"}]", requestedModel, requestedModel) case RouteTypeNoProvider: fields["cost"] = "none" fields["source"] = "error" fields["model_id"] = requestedModel // Explicit model_id for easy config reference - log.WithFields(fields).Warnf("[AMP] No provider available for model_id: %s", requestedModel) + log.WithFields(fields).Warnf("[amp] no provider available for model_id: %s", requestedModel) } } diff --git a/internal/api/modules/amp/routes.go b/internal/api/modules/amp/routes.go index 13a5d959..02f518a1 100644 --- a/internal/api/modules/amp/routes.go +++ b/internal/api/modules/amp/routes.go @@ -37,7 +37,7 @@ func localhostOnlyMiddleware() gin.HandlerFunc { // Parse the IP to handle both IPv4 and IPv6 ip := net.ParseIP(host) if ip == nil { - log.Warnf("Amp management: invalid RemoteAddr %s, denying access", remoteAddr) + log.Warnf("amp management: invalid RemoteAddr %s, denying access", remoteAddr) c.AbortWithStatusJSON(403, gin.H{ "error": "Access denied: management routes restricted to localhost", }) @@ -46,7 +46,7 @@ func localhostOnlyMiddleware() gin.HandlerFunc { // Check if IP is loopback (127.0.0.1 or ::1) if !ip.IsLoopback() { - log.Warnf("Amp management: non-localhost connection from %s attempted access, denying", remoteAddr) + log.Warnf("amp management: non-localhost connection from %s attempted access, denying", remoteAddr) c.AbortWithStatusJSON(403, gin.H{ "error": "Access denied: management routes restricted to localhost", }) @@ -89,9 +89,9 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha // Apply localhost-only restriction if configured if restrictToLocalhost { ampAPI.Use(localhostOnlyMiddleware()) - log.Info("Amp management routes restricted to localhost only (CORS disabled)") + log.Info("amp management routes restricted to localhost only (CORS disabled)") } else { - log.Warn("⚠️ Amp management routes are NOT restricted to localhost - this is insecure!") + log.Warn("amp management routes are NOT restricted to localhost - this is insecure!") } // Management routes - these are proxied directly to Amp upstream