mirror of
https://github.com/router-for-me/CLIProxyAPIPlus.git
synced 2026-04-24 07:20:31 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
48dd987867 | ||
|
|
6a2906e3e5 | ||
|
|
d72886c801 | ||
|
|
6efba3d829 | ||
|
|
6b60bdd139 | ||
|
|
897c40bed8 | ||
|
|
373ea8d7e4 | ||
|
|
b5de004c01 | ||
|
|
94ec772521 | ||
|
|
e216d26731 | ||
|
|
8a98506a15 | ||
|
|
6eb94dac33 | ||
|
|
c4a5be6edf | ||
|
|
651179a642 | ||
|
|
8c42b21e66 | ||
|
|
b693d632d2 | ||
|
|
b5033c22d8 | ||
|
|
df0fd1add1 | ||
|
|
b6bdbe78ef | ||
|
|
06c0d2bab2 | ||
|
|
bd1678457b | ||
|
|
559b7df404 |
@@ -55,141 +55,130 @@ quota-exceeded:
|
|||||||
# When true, enable authentication for the WebSocket API (/v1/ws).
|
# When true, enable authentication for the WebSocket API (/v1/ws).
|
||||||
ws-auth: false
|
ws-auth: false
|
||||||
|
|
||||||
# Amp CLI Integration
|
# Gemini API keys
|
||||||
# Configure upstream URL for Amp CLI OAuth and management features
|
# gemini-api-key:
|
||||||
#amp-upstream-url: "https://ampcode.com"
|
# - api-key: "AIzaSy...01"
|
||||||
|
# base-url: "https://generativelanguage.googleapis.com"
|
||||||
# Optional: Override API key for Amp upstream (otherwise uses env or file)
|
# headers:
|
||||||
#amp-upstream-api-key: ""
|
# X-Custom-Header: "custom-value"
|
||||||
|
# proxy-url: "socks5://proxy.example.com:1080"
|
||||||
# Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended)
|
# excluded-models:
|
||||||
#amp-restrict-management-to-localhost: true
|
# - "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)
|
||||||
# Amp Model Mappings
|
# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview)
|
||||||
# Route unavailable Amp models to alternative models available in your local proxy.
|
# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite)
|
||||||
# Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5)
|
# - api-key: "AIzaSy...02"
|
||||||
# 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"
|
|
||||||
|
|
||||||
# Codex API keys
|
# Codex API keys
|
||||||
#codex-api-key:
|
# codex-api-key:
|
||||||
# - api-key: "sk-atSM..."
|
# - api-key: "sk-atSM..."
|
||||||
# base-url: "https://www.example.com" # use the custom codex API endpoint
|
# base-url: "https://www.example.com" # use the custom codex API endpoint
|
||||||
# headers:
|
# headers:
|
||||||
# X-Custom-Header: "custom-value"
|
# X-Custom-Header: "custom-value"
|
||||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||||
# excluded-models:
|
# excluded-models:
|
||||||
# - "gpt-5.1" # exclude specific models (exact match)
|
# - "gpt-5.1" # exclude specific models (exact match)
|
||||||
# - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex)
|
# - "gpt-5-*" # wildcard matching prefix (e.g. gpt-5-medium, gpt-5-codex)
|
||||||
# - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini)
|
# - "*-mini" # wildcard matching suffix (e.g. gpt-5-codex-mini)
|
||||||
# - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low)
|
# - "*codex*" # wildcard matching substring (e.g. gpt-5-codex-low)
|
||||||
|
|
||||||
# Claude API keys
|
# Claude API keys
|
||||||
#claude-api-key:
|
# claude-api-key:
|
||||||
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
# - api-key: "sk-atSM..." # use the official claude API key, no need to set the base url
|
||||||
# - api-key: "sk-atSM..."
|
# - api-key: "sk-atSM..."
|
||||||
# base-url: "https://www.example.com" # use the custom claude API endpoint
|
# base-url: "https://www.example.com" # use the custom claude API endpoint
|
||||||
# headers:
|
# headers:
|
||||||
# X-Custom-Header: "custom-value"
|
# X-Custom-Header: "custom-value"
|
||||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||||
# models:
|
# models:
|
||||||
# - name: "claude-3-5-sonnet-20241022" # upstream model name
|
# - name: "claude-3-5-sonnet-20241022" # upstream model name
|
||||||
# alias: "claude-sonnet-latest" # client alias mapped to the upstream model
|
# alias: "claude-sonnet-latest" # client alias mapped to the upstream model
|
||||||
# excluded-models:
|
# excluded-models:
|
||||||
# - "claude-opus-4-5-20251101" # exclude specific models (exact match)
|
# - "claude-opus-4-5-20251101" # exclude specific models (exact match)
|
||||||
# - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219)
|
# - "claude-3-*" # wildcard matching prefix (e.g. claude-3-7-sonnet-20250219)
|
||||||
# - "*-think" # wildcard matching suffix (e.g. claude-opus-4-5-thinking)
|
# - "*-think" # wildcard matching suffix (e.g. claude-opus-4-5-thinking)
|
||||||
# - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022)
|
# - "*haiku*" # wildcard matching substring (e.g. claude-3-5-haiku-20241022)
|
||||||
|
|
||||||
# OpenAI compatibility providers
|
# OpenAI compatibility providers
|
||||||
#openai-compatibility:
|
# openai-compatibility:
|
||||||
# - name: "openrouter" # The name of the provider; it will be used in the user agent and other places.
|
# - 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.
|
# base-url: "https://openrouter.ai/api/v1" # The base URL of the provider.
|
||||||
# headers:
|
# headers:
|
||||||
# X-Custom-Header: "custom-value"
|
# X-Custom-Header: "custom-value"
|
||||||
# # New format with per-key proxy support (recommended):
|
# api-key-entries:
|
||||||
# api-key-entries:
|
# - api-key: "sk-or-v1-...b780"
|
||||||
# - api-key: "sk-or-v1-...b780"
|
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
||||||
# proxy-url: "socks5://proxy.example.com:1080" # optional: per-key proxy override
|
# - api-key: "sk-or-v1-...b781" # without proxy-url
|
||||||
# - api-key: "sk-or-v1-...b781" # without proxy-url
|
# models: # The models supported by the provider.
|
||||||
# # Legacy format (still supported, but cannot specify proxy per key):
|
# - name: "moonshotai/kimi-k2:free" # The actual model name.
|
||||||
# # api-keys:
|
# alias: "kimi-k2" # The alias used in the API.
|
||||||
# # - "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.
|
|
||||||
|
|
||||||
# Vertex API keys (Vertex-compatible endpoints, use API key + base URL)
|
# Vertex API keys (Vertex-compatible endpoints, use API key + base URL)
|
||||||
#vertex-api-key:
|
# vertex-api-key:
|
||||||
# - api-key: "vk-123..." # x-goog-api-key header
|
# - api-key: "vk-123..." # x-goog-api-key header
|
||||||
# base-url: "https://example.com/api" # e.g. https://zenmux.ai/api
|
# base-url: "https://example.com/api" # e.g. https://zenmux.ai/api
|
||||||
# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override
|
# proxy-url: "socks5://proxy.example.com:1080" # optional per-key proxy override
|
||||||
# headers:
|
# headers:
|
||||||
# X-Custom-Header: "custom-value"
|
# X-Custom-Header: "custom-value"
|
||||||
# models: # optional: map aliases to upstream model names
|
# models: # optional: map aliases to upstream model names
|
||||||
# - name: "gemini-2.0-flash" # upstream model name
|
# - name: "gemini-2.0-flash" # upstream model name
|
||||||
# alias: "vertex-flash" # client-visible alias
|
# alias: "vertex-flash" # client-visible alias
|
||||||
# - name: "gemini-1.5-pro"
|
# - name: "gemini-1.5-pro"
|
||||||
# alias: "vertex-pro"
|
# alias: "vertex-pro"
|
||||||
|
|
||||||
#payload: # Optional payload configuration
|
# Amp Integration
|
||||||
# default: # Default rules only set parameters when they are missing in the payload.
|
# ampcode:
|
||||||
# - models:
|
# # Configure upstream URL for Amp CLI OAuth and management features
|
||||||
# - name: "gemini-2.5-pro" # Supports wildcards (e.g., "gemini-*")
|
# upstream-url: "https://ampcode.com"
|
||||||
# protocol: "gemini" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
|
# # Optional: Override API key for Amp upstream (otherwise uses env or file)
|
||||||
# params: # JSON path (gjson/sjson syntax) -> value
|
# upstream-api-key: ""
|
||||||
# "generationConfig.thinkingConfig.thinkingBudget": 32768
|
# # Restrict Amp management routes (/api/auth, /api/user, etc.) to localhost only (recommended)
|
||||||
# override: # Override rules always set parameters, overwriting any existing values.
|
# restrict-management-to-localhost: true
|
||||||
# - models:
|
# # Amp Model Mappings
|
||||||
# - name: "gpt-*" # Supports wildcards (e.g., "gpt-*")
|
# # Route unavailable Amp models to alternative models available in your local proxy.
|
||||||
# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
|
# # Useful when Amp CLI requests models you don't have access to (e.g., Claude Opus 4.5)
|
||||||
# params: # JSON path (gjson/sjson syntax) -> value
|
# # but you have a similar model available (e.g., Claude Sonnet 4).
|
||||||
# "reasoning.effort": "high"
|
# 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 provider excluded models
|
||||||
#oauth-excluded-models:
|
# oauth-excluded-models:
|
||||||
# gemini-cli:
|
# gemini-cli:
|
||||||
# - "gemini-2.5-pro" # exclude specific models (exact match)
|
# - "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)
|
# - "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)
|
# - "*-preview" # wildcard matching suffix (e.g. gemini-3-pro-preview)
|
||||||
# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite)
|
# - "*flash*" # wildcard matching substring (e.g. gemini-2.5-flash-lite)
|
||||||
# vertex:
|
# vertex:
|
||||||
# - "gemini-3-pro-preview"
|
# - "gemini-3-pro-preview"
|
||||||
# aistudio:
|
# aistudio:
|
||||||
# - "gemini-3-pro-preview"
|
# - "gemini-3-pro-preview"
|
||||||
# antigravity:
|
# antigravity:
|
||||||
# - "gemini-3-pro-preview"
|
# - "gemini-3-pro-preview"
|
||||||
# claude:
|
# claude:
|
||||||
# - "claude-3-5-haiku-20241022"
|
# - "claude-3-5-haiku-20241022"
|
||||||
# codex:
|
# codex:
|
||||||
# - "gpt-5-codex-mini"
|
# - "gpt-5-codex-mini"
|
||||||
# qwen:
|
# qwen:
|
||||||
# - "vision-model"
|
# - "vision-model"
|
||||||
# iflow:
|
# iflow:
|
||||||
# - "tstars2.0"
|
# - "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"
|
||||||
|
|||||||
@@ -17,29 +17,9 @@ func (h *Handler) GetConfig(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
cfgCopy := *h.cfg
|
cfgCopy := *h.cfg
|
||||||
cfgCopy.GlAPIKey = geminiKeyStringsFromConfig(h.cfg)
|
|
||||||
c.JSON(200, &cfgCopy)
|
c.JSON(200, &cfgCopy)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) GetConfigYAML(c *gin.Context) {
|
|
||||||
data, err := os.ReadFile(h.configFilePath)
|
|
||||||
if err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "read_failed", "message": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
var node yaml.Node
|
|
||||||
if err = yaml.Unmarshal(data, &node); err != nil {
|
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": "parse_failed", "message": err.Error()})
|
|
||||||
return
|
|
||||||
}
|
|
||||||
c.Header("Content-Type", "application/yaml; charset=utf-8")
|
|
||||||
c.Header("Vary", "format, Accept")
|
|
||||||
enc := yaml.NewEncoder(c.Writer)
|
|
||||||
enc.SetIndent(2)
|
|
||||||
_ = enc.Encode(&node)
|
|
||||||
_ = enc.Close()
|
|
||||||
}
|
|
||||||
|
|
||||||
func WriteConfig(path string, data []byte) error {
|
func WriteConfig(path string, data []byte) error {
|
||||||
data = config.NormalizeCommentIndentation(data)
|
data = config.NormalizeCommentIndentation(data)
|
||||||
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
f, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
|
||||||
@@ -111,9 +91,9 @@ func (h *Handler) PutConfigYAML(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, gin.H{"ok": true, "changed": []string{"config"}})
|
c.JSON(http.StatusOK, gin.H{"ok": true, "changed": []string{"config"}})
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetConfigFile returns the raw config.yaml file bytes without re-encoding.
|
// GetConfigYAML returns the raw config.yaml file bytes without re-encoding.
|
||||||
// It preserves comments and original formatting/styles.
|
// It preserves comments and original formatting/styles.
|
||||||
func (h *Handler) GetConfigFile(c *gin.Context) {
|
func (h *Handler) GetConfigYAML(c *gin.Context) {
|
||||||
data, err := os.ReadFile(h.configFilePath)
|
data, err := os.ReadFile(h.configFilePath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if os.IsNotExist(err) {
|
if os.IsNotExist(err) {
|
||||||
|
|||||||
@@ -104,53 +104,6 @@ func (h *Handler) deleteFromStringList(c *gin.Context, target *[]string, after f
|
|||||||
c.JSON(400, gin.H{"error": "missing index or value"})
|
c.JSON(400, gin.H{"error": "missing index or value"})
|
||||||
}
|
}
|
||||||
|
|
||||||
func sanitizeStringSlice(in []string) []string {
|
|
||||||
out := make([]string, 0, len(in))
|
|
||||||
for i := range in {
|
|
||||||
if trimmed := strings.TrimSpace(in[i]); trimmed != "" {
|
|
||||||
out = append(out, trimmed)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func geminiKeyStringsFromConfig(cfg *config.Config) []string {
|
|
||||||
if cfg == nil || len(cfg.GeminiKey) == 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
out := make([]string, 0, len(cfg.GeminiKey))
|
|
||||||
for i := range cfg.GeminiKey {
|
|
||||||
if key := strings.TrimSpace(cfg.GeminiKey[i].APIKey); key != "" {
|
|
||||||
out = append(out, key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return out
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *Handler) applyLegacyKeys(keys []string) {
|
|
||||||
if h == nil || h.cfg == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
sanitized := sanitizeStringSlice(keys)
|
|
||||||
existing := make(map[string]config.GeminiKey, len(h.cfg.GeminiKey))
|
|
||||||
for _, entry := range h.cfg.GeminiKey {
|
|
||||||
if key := strings.TrimSpace(entry.APIKey); key != "" {
|
|
||||||
existing[key] = entry
|
|
||||||
}
|
|
||||||
}
|
|
||||||
newList := make([]config.GeminiKey, 0, len(sanitized))
|
|
||||||
for _, key := range sanitized {
|
|
||||||
if entry, ok := existing[key]; ok {
|
|
||||||
newList = append(newList, entry)
|
|
||||||
} else {
|
|
||||||
newList = append(newList, config.GeminiKey{APIKey: key})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
h.cfg.GeminiKey = newList
|
|
||||||
h.cfg.GlAPIKey = sanitized
|
|
||||||
h.cfg.SanitizeGeminiKeys()
|
|
||||||
}
|
|
||||||
|
|
||||||
// api-keys
|
// api-keys
|
||||||
func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
|
func (h *Handler) GetAPIKeys(c *gin.Context) { c.JSON(200, gin.H{"api-keys": h.cfg.APIKeys}) }
|
||||||
func (h *Handler) PutAPIKeys(c *gin.Context) {
|
func (h *Handler) PutAPIKeys(c *gin.Context) {
|
||||||
@@ -166,24 +119,6 @@ func (h *Handler) DeleteAPIKeys(c *gin.Context) {
|
|||||||
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
|
h.deleteFromStringList(c, &h.cfg.APIKeys, func() { h.cfg.Access.Providers = nil })
|
||||||
}
|
}
|
||||||
|
|
||||||
// generative-language-api-key
|
|
||||||
func (h *Handler) GetGlKeys(c *gin.Context) {
|
|
||||||
c.JSON(200, gin.H{"generative-language-api-key": geminiKeyStringsFromConfig(h.cfg)})
|
|
||||||
}
|
|
||||||
func (h *Handler) PutGlKeys(c *gin.Context) {
|
|
||||||
h.putStringList(c, func(v []string) {
|
|
||||||
h.applyLegacyKeys(v)
|
|
||||||
}, nil)
|
|
||||||
}
|
|
||||||
func (h *Handler) PatchGlKeys(c *gin.Context) {
|
|
||||||
target := append([]string(nil), geminiKeyStringsFromConfig(h.cfg)...)
|
|
||||||
h.patchStringList(c, &target, func() { h.applyLegacyKeys(target) })
|
|
||||||
}
|
|
||||||
func (h *Handler) DeleteGlKeys(c *gin.Context) {
|
|
||||||
target := append([]string(nil), geminiKeyStringsFromConfig(h.cfg)...)
|
|
||||||
h.deleteFromStringList(c, &target, func() { h.applyLegacyKeys(target) })
|
|
||||||
}
|
|
||||||
|
|
||||||
// gemini-api-key: []GeminiKey
|
// gemini-api-key: []GeminiKey
|
||||||
func (h *Handler) GetGeminiKeys(c *gin.Context) {
|
func (h *Handler) GetGeminiKeys(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey})
|
c.JSON(200, gin.H{"gemini-api-key": h.cfg.GeminiKey})
|
||||||
@@ -409,15 +344,14 @@ func (h *Handler) PutOpenAICompat(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
arr = obj.Items
|
arr = obj.Items
|
||||||
}
|
}
|
||||||
arr = migrateLegacyOpenAICompatibilityKeys(arr)
|
|
||||||
// Filter out providers with empty base-url -> remove provider entirely
|
|
||||||
filtered := make([]config.OpenAICompatibility, 0, len(arr))
|
filtered := make([]config.OpenAICompatibility, 0, len(arr))
|
||||||
for i := range arr {
|
for i := range arr {
|
||||||
|
normalizeOpenAICompatibilityEntry(&arr[i])
|
||||||
if strings.TrimSpace(arr[i].BaseURL) != "" {
|
if strings.TrimSpace(arr[i].BaseURL) != "" {
|
||||||
filtered = append(filtered, arr[i])
|
filtered = append(filtered, arr[i])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
h.cfg.OpenAICompatibility = migrateLegacyOpenAICompatibilityKeys(filtered)
|
h.cfg.OpenAICompatibility = filtered
|
||||||
h.cfg.SanitizeOpenAICompatibility()
|
h.cfg.SanitizeOpenAICompatibility()
|
||||||
h.persist(c)
|
h.persist(c)
|
||||||
}
|
}
|
||||||
@@ -431,7 +365,6 @@ func (h *Handler) PatchOpenAICompat(c *gin.Context) {
|
|||||||
c.JSON(400, gin.H{"error": "invalid body"})
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
h.cfg.OpenAICompatibility = migrateLegacyOpenAICompatibilityKeys(h.cfg.OpenAICompatibility)
|
|
||||||
normalizeOpenAICompatibilityEntry(body.Value)
|
normalizeOpenAICompatibilityEntry(body.Value)
|
||||||
// If base-url becomes empty, delete the provider instead of updating
|
// If base-url becomes empty, delete the provider instead of updating
|
||||||
if strings.TrimSpace(body.Value.BaseURL) == "" {
|
if strings.TrimSpace(body.Value.BaseURL) == "" {
|
||||||
@@ -731,28 +664,6 @@ func normalizeOpenAICompatibilityEntry(entry *config.OpenAICompatibility) {
|
|||||||
existing[trimmed] = struct{}{}
|
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 {
|
func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility) []config.OpenAICompatibility {
|
||||||
@@ -765,9 +676,6 @@ func normalizedOpenAICompatibilityEntries(entries []config.OpenAICompatibility)
|
|||||||
if len(copyEntry.APIKeyEntries) > 0 {
|
if len(copyEntry.APIKeyEntries) > 0 {
|
||||||
copyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...)
|
copyEntry.APIKeyEntries = append([]config.OpenAICompatibilityAPIKey(nil), copyEntry.APIKeyEntries...)
|
||||||
}
|
}
|
||||||
if len(copyEntry.APIKeys) > 0 {
|
|
||||||
copyEntry.APIKeys = append([]string(nil), copyEntry.APIKeys...)
|
|
||||||
}
|
|
||||||
normalizeOpenAICompatibilityEntry(©Entry)
|
normalizeOpenAICompatibilityEntry(©Entry)
|
||||||
out[i] = copyEntry
|
out[i] = copyEntry
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,8 @@ func (m *AmpModule) Name() string {
|
|||||||
// This implements the RouteModuleV2 interface with Context.
|
// This implements the RouteModuleV2 interface with Context.
|
||||||
// Routes are registered only once via sync.Once for idempotent behavior.
|
// Routes are registered only once via sync.Once for idempotent behavior.
|
||||||
func (m *AmpModule) Register(ctx modules.Context) error {
|
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)
|
// Determine auth middleware (from module or context)
|
||||||
auth := m.getAuthMiddleware(ctx)
|
auth := m.getAuthMiddleware(ctx)
|
||||||
@@ -104,15 +105,15 @@ func (m *AmpModule) Register(ctx modules.Context) error {
|
|||||||
var regErr error
|
var regErr error
|
||||||
m.registerOnce.Do(func() {
|
m.registerOnce.Do(func() {
|
||||||
// Initialize model mapper from config (for routing unavailable models to alternatives)
|
// 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
|
// Always register provider aliases - these work without an upstream
|
||||||
m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth)
|
m.registerProviderAliases(ctx.Engine, ctx.BaseHandler, auth)
|
||||||
|
|
||||||
// If no upstream URL, skip proxy routes but provider aliases are still available
|
// If no upstream URL, skip proxy routes but provider aliases are still available
|
||||||
if upstreamURL == "" {
|
if upstreamURL == "" {
|
||||||
log.Debug("Amp upstream proxy disabled (no upstream URL configured)")
|
log.Debug("amp upstream proxy disabled (no upstream URL configured)")
|
||||||
log.Debug("Amp provider alias routes registered")
|
log.Debug("amp provider alias routes registered")
|
||||||
m.enabled = false
|
m.enabled = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -120,7 +121,7 @@ func (m *AmpModule) Register(ctx modules.Context) error {
|
|||||||
// Create secret source with precedence: config > env > file
|
// Create secret source with precedence: config > env > file
|
||||||
// Cache secrets for 5 minutes to reduce file I/O
|
// Cache secrets for 5 minutes to reduce file I/O
|
||||||
if m.secretSource == nil {
|
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
|
// Create reverse proxy with gzip handling via ModifyResponse
|
||||||
@@ -136,10 +137,10 @@ func (m *AmpModule) Register(ctx modules.Context) error {
|
|||||||
// Register management proxy routes (requires upstream)
|
// Register management proxy routes (requires upstream)
|
||||||
// Restrict to localhost by default for security (prevents drive-by browser attacks)
|
// Restrict to localhost by default for security (prevents drive-by browser attacks)
|
||||||
handler := proxyHandler(proxy)
|
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.Infof("amp upstream proxy enabled for: %s", upstreamURL)
|
||||||
log.Debug("Amp provider alias routes registered")
|
log.Debug("amp provider alias routes registered")
|
||||||
})
|
})
|
||||||
|
|
||||||
return regErr
|
return regErr
|
||||||
@@ -155,7 +156,7 @@ func (m *AmpModule) getAuthMiddleware(ctx modules.Context) gin.HandlerFunc {
|
|||||||
return ctx.AuthMiddleware
|
return ctx.AuthMiddleware
|
||||||
}
|
}
|
||||||
// Fallback: no authentication (should not happen in production)
|
// 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) {
|
return func(c *gin.Context) {
|
||||||
c.Next()
|
c.Next()
|
||||||
}
|
}
|
||||||
@@ -164,22 +165,25 @@ func (m *AmpModule) getAuthMiddleware(ctx modules.Context) gin.HandlerFunc {
|
|||||||
// OnConfigUpdated handles configuration updates.
|
// OnConfigUpdated handles configuration updates.
|
||||||
// Currently requires restart for URL changes (could be enhanced for dynamic updates).
|
// Currently requires restart for URL changes (could be enhanced for dynamic updates).
|
||||||
func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
|
func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
|
||||||
|
settings := cfg.AmpCode
|
||||||
|
|
||||||
// Update model mappings (hot-reload supported)
|
// Update model mappings (hot-reload supported)
|
||||||
if m.modelMapper != nil {
|
if m.modelMapper != nil {
|
||||||
log.Infof("amp config updated: reloading %d model mapping(s)", len(cfg.AmpModelMappings))
|
m.modelMapper.UpdateMappings(settings.ModelMappings)
|
||||||
m.modelMapper.UpdateMappings(cfg.AmpModelMappings)
|
if m.enabled {
|
||||||
} else {
|
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")
|
log.Warnf("amp model mapper not initialized, skipping model mapping update")
|
||||||
}
|
}
|
||||||
|
|
||||||
if !m.enabled {
|
if !m.enabled {
|
||||||
log.Debug("Amp routing not enabled, skipping other config updates")
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
upstreamURL := strings.TrimSpace(cfg.AmpUpstreamURL)
|
upstreamURL := strings.TrimSpace(settings.UpstreamURL)
|
||||||
if 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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -187,11 +191,11 @@ func (m *AmpModule) OnConfigUpdated(cfg *config.Config) error {
|
|||||||
if m.secretSource != nil {
|
if m.secretSource != nil {
|
||||||
if ms, ok := m.secretSource.(*MultiSourceSecret); ok {
|
if ms, ok := m.secretSource.(*MultiSourceSecret); ok {
|
||||||
ms.InvalidateCache()
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -56,8 +56,10 @@ func TestAmpModule_Register_WithUpstream(t *testing.T) {
|
|||||||
m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
|
m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
|
||||||
|
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
AmpUpstreamURL: upstream.URL,
|
AmpCode: config.AmpCode{
|
||||||
AmpUpstreamAPIKey: "test-key",
|
UpstreamURL: upstream.URL,
|
||||||
|
UpstreamAPIKey: "test-key",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
|
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() })
|
m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
|
||||||
|
|
||||||
cfg := &config.Config{
|
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() }}
|
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() })
|
m := NewLegacy(accessManager, func(c *gin.Context) { c.Next() })
|
||||||
|
|
||||||
cfg := &config.Config{
|
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() }}
|
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
|
// 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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -175,7 +181,7 @@ func TestAmpModule_OnConfigUpdated_URLRemoved(t *testing.T) {
|
|||||||
m.secretSource = ms
|
m.secretSource = ms
|
||||||
|
|
||||||
// Config update with empty URL - should log warning but not error
|
// 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 {
|
if err := m.OnConfigUpdated(cfg); err != nil {
|
||||||
t.Fatalf("unexpected error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
@@ -187,7 +193,7 @@ func TestAmpModule_OnConfigUpdated_NonMultiSourceSecret(t *testing.T) {
|
|||||||
m := &AmpModule{enabled: true}
|
m := &AmpModule{enabled: true}
|
||||||
m.secretSource = NewStaticSecretSource("static-key")
|
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
|
// Should not error or panic
|
||||||
if err := m.OnConfigUpdated(cfg); err != nil {
|
if err := m.OnConfigUpdated(cfg); err != nil {
|
||||||
@@ -240,8 +246,10 @@ func TestAmpModule_SecretSource_FromConfig(t *testing.T) {
|
|||||||
|
|
||||||
// Config with explicit API key
|
// Config with explicit API key
|
||||||
cfg := &config.Config{
|
cfg := &config.Config{
|
||||||
AmpUpstreamURL: upstream.URL,
|
AmpCode: config.AmpCode{
|
||||||
AmpUpstreamAPIKey: "config-key",
|
UpstreamURL: upstream.URL,
|
||||||
|
UpstreamAPIKey: "config-key",
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
ctx := modules.Context{Engine: r, BaseHandler: base, Config: cfg, AuthMiddleware: func(c *gin.Context) { c.Next() }}
|
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() })
|
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() }}
|
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 != "" {
|
if err := m.Register(ctx); err != nil && scenario.configURL != "" {
|
||||||
|
|||||||
@@ -48,25 +48,25 @@ func logAmpRouting(routeType AmpRouteType, requestedModel, resolvedModel, provid
|
|||||||
case RouteTypeLocalProvider:
|
case RouteTypeLocalProvider:
|
||||||
fields["cost"] = "free"
|
fields["cost"] = "free"
|
||||||
fields["source"] = "local_oauth"
|
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:
|
case RouteTypeModelMapping:
|
||||||
fields["cost"] = "free"
|
fields["cost"] = "free"
|
||||||
fields["source"] = "local_oauth"
|
fields["source"] = "local_oauth"
|
||||||
fields["mapping"] = requestedModel + " -> " + resolvedModel
|
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:
|
case RouteTypeAmpCredits:
|
||||||
fields["cost"] = "amp_credits"
|
fields["cost"] = "amp_credits"
|
||||||
fields["source"] = "ampcode.com"
|
fields["source"] = "ampcode.com"
|
||||||
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
|
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: \"<your-local-model>\"}]", 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: \"<your-local-model>\"}]", requestedModel, requestedModel)
|
||||||
|
|
||||||
case RouteTypeNoProvider:
|
case RouteTypeNoProvider:
|
||||||
fields["cost"] = "none"
|
fields["cost"] = "none"
|
||||||
fields["source"] = "error"
|
fields["source"] = "error"
|
||||||
fields["model_id"] = requestedModel // Explicit model_id for easy config reference
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func localhostOnlyMiddleware() gin.HandlerFunc {
|
|||||||
// Parse the IP to handle both IPv4 and IPv6
|
// Parse the IP to handle both IPv4 and IPv6
|
||||||
ip := net.ParseIP(host)
|
ip := net.ParseIP(host)
|
||||||
if ip == nil {
|
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{
|
c.AbortWithStatusJSON(403, gin.H{
|
||||||
"error": "Access denied: management routes restricted to localhost",
|
"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)
|
// Check if IP is loopback (127.0.0.1 or ::1)
|
||||||
if !ip.IsLoopback() {
|
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{
|
c.AbortWithStatusJSON(403, gin.H{
|
||||||
"error": "Access denied: management routes restricted to localhost",
|
"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
|
// Apply localhost-only restriction if configured
|
||||||
if restrictToLocalhost {
|
if restrictToLocalhost {
|
||||||
ampAPI.Use(localhostOnlyMiddleware())
|
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 {
|
} 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
|
// Management routes - these are proxied directly to Amp upstream
|
||||||
@@ -110,6 +110,8 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
|
|||||||
ampAPI.Any("/threads/*path", proxyHandler)
|
ampAPI.Any("/threads/*path", proxyHandler)
|
||||||
ampAPI.Any("/otel", proxyHandler)
|
ampAPI.Any("/otel", proxyHandler)
|
||||||
ampAPI.Any("/otel/*path", proxyHandler)
|
ampAPI.Any("/otel/*path", proxyHandler)
|
||||||
|
ampAPI.Any("/tab", proxyHandler)
|
||||||
|
ampAPI.Any("/tab/*path", proxyHandler)
|
||||||
|
|
||||||
// Root-level routes that AMP CLI expects without /api prefix
|
// Root-level routes that AMP CLI expects without /api prefix
|
||||||
// These need the same security middleware as the /api/* routes
|
// These need the same security middleware as the /api/* routes
|
||||||
@@ -119,6 +121,12 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
|
|||||||
}
|
}
|
||||||
engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...)
|
engine.GET("/threads.rss", append(rootMiddleware, proxyHandler)...)
|
||||||
|
|
||||||
|
// Root-level auth routes for CLI login flow
|
||||||
|
// Amp uses multiple auth routes: /auth/cli-login, /auth/callback, /auth/sign-in, /auth/logout
|
||||||
|
// We proxy all /auth/* to support the complete OAuth flow
|
||||||
|
engine.Any("/auth", append(rootMiddleware, proxyHandler)...)
|
||||||
|
engine.Any("/auth/*path", append(rootMiddleware, proxyHandler)...)
|
||||||
|
|
||||||
// Google v1beta1 passthrough with OAuth fallback
|
// Google v1beta1 passthrough with OAuth fallback
|
||||||
// AMP CLI uses non-standard paths like /publishers/google/models/...
|
// AMP CLI uses non-standard paths like /publishers/google/models/...
|
||||||
// We bridge these to our standard Gemini handler to enable local OAuth.
|
// We bridge these to our standard Gemini handler to enable local OAuth.
|
||||||
|
|||||||
@@ -39,6 +39,11 @@ func TestRegisterManagementRoutes(t *testing.T) {
|
|||||||
{"/api/threads", http.MethodGet},
|
{"/api/threads", http.MethodGet},
|
||||||
{"/threads.rss", http.MethodGet}, // Root-level route (no /api prefix)
|
{"/threads.rss", http.MethodGet}, // Root-level route (no /api prefix)
|
||||||
{"/api/otel", http.MethodGet},
|
{"/api/otel", http.MethodGet},
|
||||||
|
{"/api/tab", http.MethodGet},
|
||||||
|
{"/api/tab/some/path", http.MethodGet},
|
||||||
|
{"/auth", http.MethodGet}, // Root-level auth route
|
||||||
|
{"/auth/cli-login", http.MethodGet}, // CLI login flow
|
||||||
|
{"/auth/callback", http.MethodGet}, // OAuth callback
|
||||||
// Google v1beta1 bridge should still proxy non-model requests (GET) and allow POST
|
// Google v1beta1 bridge should still proxy non-model requests (GET) and allow POST
|
||||||
{"/api/provider/google/v1beta1/models", http.MethodGet},
|
{"/api/provider/google/v1beta1/models", http.MethodGet},
|
||||||
{"/api/provider/google/v1beta1/models", http.MethodPost},
|
{"/api/provider/google/v1beta1/models", http.MethodPost},
|
||||||
|
|||||||
@@ -470,8 +470,8 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
{
|
{
|
||||||
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
|
mgmt.GET("/usage", s.mgmt.GetUsageStatistics)
|
||||||
mgmt.GET("/config", s.mgmt.GetConfig)
|
mgmt.GET("/config", s.mgmt.GetConfig)
|
||||||
|
mgmt.GET("/config.yaml", s.mgmt.GetConfigYAML)
|
||||||
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
|
mgmt.PUT("/config.yaml", s.mgmt.PutConfigYAML)
|
||||||
mgmt.GET("/config.yaml", s.mgmt.GetConfigFile)
|
|
||||||
|
|
||||||
mgmt.GET("/debug", s.mgmt.GetDebug)
|
mgmt.GET("/debug", s.mgmt.GetDebug)
|
||||||
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
mgmt.PUT("/debug", s.mgmt.PutDebug)
|
||||||
@@ -503,11 +503,6 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
|
mgmt.PATCH("/api-keys", s.mgmt.PatchAPIKeys)
|
||||||
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
|
mgmt.DELETE("/api-keys", s.mgmt.DeleteAPIKeys)
|
||||||
|
|
||||||
mgmt.GET("/generative-language-api-key", s.mgmt.GetGlKeys)
|
|
||||||
mgmt.PUT("/generative-language-api-key", s.mgmt.PutGlKeys)
|
|
||||||
mgmt.PATCH("/generative-language-api-key", s.mgmt.PatchGlKeys)
|
|
||||||
mgmt.DELETE("/generative-language-api-key", s.mgmt.DeleteGlKeys)
|
|
||||||
|
|
||||||
mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys)
|
mgmt.GET("/gemini-api-key", s.mgmt.GetGeminiKeys)
|
||||||
mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys)
|
mgmt.PUT("/gemini-api-key", s.mgmt.PutGeminiKeys)
|
||||||
mgmt.PATCH("/gemini-api-key", s.mgmt.PatchGeminiKey)
|
mgmt.PATCH("/gemini-api-key", s.mgmt.PatchGeminiKey)
|
||||||
@@ -938,11 +933,7 @@ func (s *Server) UpdateClients(cfg *config.Config) {
|
|||||||
openAICompatCount := 0
|
openAICompatCount := 0
|
||||||
for i := range cfg.OpenAICompatibility {
|
for i := range cfg.OpenAICompatibility {
|
||||||
entry := cfg.OpenAICompatibility[i]
|
entry := cfg.OpenAICompatibility[i]
|
||||||
if len(entry.APIKeyEntries) > 0 {
|
openAICompatCount += len(entry.APIKeyEntries)
|
||||||
openAICompatCount += len(entry.APIKeyEntries)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
openAICompatCount += len(entry.APIKeys)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
total := authFiles + geminiAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + vertexAICompatCount + openAICompatCount
|
total := authFiles + geminiAPIKeyCount + claudeAPIKeyCount + codexAPIKeyCount + vertexAICompatCount + openAICompatCount
|
||||||
|
|||||||
@@ -26,22 +26,8 @@ type Config struct {
|
|||||||
// TLS config controls HTTPS server settings.
|
// TLS config controls HTTPS server settings.
|
||||||
TLS TLSConfig `yaml:"tls" json:"tls"`
|
TLS TLSConfig `yaml:"tls" json:"tls"`
|
||||||
|
|
||||||
// AmpUpstreamURL defines the upstream Amp control plane used for non-provider calls.
|
// RemoteManagement nests management-related options under 'remote-management'.
|
||||||
AmpUpstreamURL string `yaml:"amp-upstream-url" json:"amp-upstream-url"`
|
RemoteManagement RemoteManagement `yaml:"remote-management" json:"-"`
|
||||||
|
|
||||||
// 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"`
|
|
||||||
|
|
||||||
// AuthDir is the directory where authentication token files are stored.
|
// AuthDir is the directory where authentication token files are stored.
|
||||||
AuthDir string `yaml:"auth-dir" json:"-"`
|
AuthDir string `yaml:"auth-dir" json:"-"`
|
||||||
@@ -58,44 +44,43 @@ type Config struct {
|
|||||||
// DisableCooling disables quota cooldown scheduling when true.
|
// DisableCooling disables quota cooldown scheduling when true.
|
||||||
DisableCooling bool `yaml:"disable-cooling" json:"disable-cooling"`
|
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 defines the behavior when a quota is exceeded.
|
||||||
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
|
QuotaExceeded QuotaExceeded `yaml:"quota-exceeded" json:"quota-exceeded"`
|
||||||
|
|
||||||
// WebsocketAuth enables or disables authentication for the WebSocket API.
|
// WebsocketAuth enables or disables authentication for the WebSocket API.
|
||||||
WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"`
|
WebsocketAuth bool `yaml:"ws-auth" json:"ws-auth"`
|
||||||
|
|
||||||
// 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 defines Gemini API key configurations with optional routing overrides.
|
||||||
GeminiKey []GeminiKey `yaml:"gemini-api-key" json:"gemini-api-key"`
|
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.
|
// 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.
|
// 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"`
|
VertexCompatAPIKey []VertexCompatKey `yaml:"vertex-api-key" json:"vertex-api-key"`
|
||||||
|
|
||||||
// RequestRetry defines the retry times when the request failed.
|
// AmpCode contains Amp CLI upstream configuration, management restrictions, and model mappings.
|
||||||
RequestRetry int `yaml:"request-retry" json:"request-retry"`
|
AmpCode AmpCode `yaml:"ampcode" json:"ampcode"`
|
||||||
// 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.
|
// OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries.
|
||||||
ClaudeKey []ClaudeKey `yaml:"claude-api-key" json:"claude-api-key"`
|
OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"`
|
||||||
|
|
||||||
// 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 defines default and override rules for provider payload parameters.
|
||||||
Payload PayloadConfig `yaml:"payload" json:"payload"`
|
Payload PayloadConfig `yaml:"payload" json:"payload"`
|
||||||
|
|
||||||
// OAuthExcludedModels defines per-provider global model exclusions applied to OAuth/file-backed auth entries.
|
legacyMigrationPending bool `yaml:"-" json:"-"`
|
||||||
OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLSConfig holds HTTPS server settings.
|
// TLSConfig holds HTTPS server settings.
|
||||||
@@ -140,6 +125,26 @@ type AmpModelMapping struct {
|
|||||||
To string `yaml:"to" json:"to"`
|
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.
|
// PayloadConfig defines default and override parameter rules applied to provider payloads.
|
||||||
type PayloadConfig struct {
|
type PayloadConfig struct {
|
||||||
// Default defines rules that only set parameters when they are missing in the payload.
|
// Default defines rules that only set parameters when they are missing in the payload.
|
||||||
@@ -244,10 +249,6 @@ type OpenAICompatibility struct {
|
|||||||
// BaseURL is the base URL for the external OpenAI-compatible API endpoint.
|
// BaseURL is the base URL for the external OpenAI-compatible API endpoint.
|
||||||
BaseURL string `yaml:"base-url" json:"base-url"`
|
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 defines API keys with optional per-key proxy configuration.
|
||||||
APIKeyEntries []OpenAICompatibilityAPIKey `yaml:"api-key-entries,omitempty" json:"api-key-entries,omitempty"`
|
APIKeyEntries []OpenAICompatibilityAPIKey `yaml:"api-key-entries,omitempty" json:"api-key-entries,omitempty"`
|
||||||
|
|
||||||
@@ -318,7 +319,7 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
cfg.LoggingToFile = false
|
cfg.LoggingToFile = false
|
||||||
cfg.UsageStatisticsEnabled = false
|
cfg.UsageStatisticsEnabled = false
|
||||||
cfg.DisableCooling = 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 err = yaml.Unmarshal(data, &cfg); err != nil {
|
||||||
if optional {
|
if optional {
|
||||||
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
|
// In cloud deploy mode, if YAML parsing fails, return empty config instead of error.
|
||||||
@@ -327,6 +328,19 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
return nil, fmt.Errorf("failed to parse config file: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var legacy legacyConfigData
|
||||||
|
if errLegacy := yaml.Unmarshal(data, &legacy); errLegacy == nil {
|
||||||
|
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)
|
// 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).
|
// 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) {
|
if cfg.RemoteManagement.SecretKey != "" && !looksLikeBcrypt(cfg.RemoteManagement.SecretKey) {
|
||||||
@@ -362,6 +376,18 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
// Normalize OAuth provider model exclusion map.
|
// Normalize OAuth provider model exclusion map.
|
||||||
cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)
|
cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)
|
||||||
|
|
||||||
|
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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Return the populated configuration struct.
|
// Return the populated configuration struct.
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
@@ -445,22 +471,6 @@ func (cfg *Config) SanitizeGeminiKeys() {
|
|||||||
out = append(out, entry)
|
out = append(out, entry)
|
||||||
}
|
}
|
||||||
cfg.GeminiKey = out
|
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{}{}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
cfg.GlAPIKey = nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func syncInlineAccessProvider(cfg *Config) {
|
func syncInlineAccessProvider(cfg *Config) {
|
||||||
@@ -596,9 +606,12 @@ func SaveConfigPreserveComments(configFile string, cfg *Config) error {
|
|||||||
return fmt.Errorf("expected generated root mapping node")
|
return fmt.Errorf("expected generated root mapping node")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Remove deprecated auth block before merging to avoid persisting it again.
|
// Remove deprecated sections before merging back the sanitized config.
|
||||||
removeMapKey(original.Content[0], "auth")
|
removeLegacyAuthBlock(original.Content[0])
|
||||||
removeLegacyOpenAICompatAPIKeys(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")
|
pruneMappingToGeneratedKeys(original.Content[0], generated.Content[0], "oauth-excluded-models")
|
||||||
|
|
||||||
// Merge generated into original in-place, preserving comments/order of existing nodes.
|
// Merge generated into original in-place, preserving comments/order of existing nodes.
|
||||||
@@ -1061,25 +1074,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 pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, key string) {
|
func pruneMappingToGeneratedKeys(dstRoot, srcRoot *yaml.Node, key string) {
|
||||||
if key == "" || dstRoot == nil || srcRoot == nil {
|
if key == "" || dstRoot == nil || srcRoot == nil {
|
||||||
return
|
return
|
||||||
@@ -1173,3 +1167,194 @@ func normalizeCollectionNodeStyles(node *yaml.Node) {
|
|||||||
// Scalars keep their existing style to preserve quoting
|
// 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) bool {
|
||||||
|
if cfg == nil || len(legacy) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
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{}{}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func (cfg *Config) migrateLegacyOpenAICompatibilityKeys(legacy []legacyOpenAICompatibility) bool {
|
||||||
|
if cfg == nil || len(cfg.OpenAICompatibility) == 0 || len(legacy) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
for _, legacyEntry := range legacy {
|
||||||
|
if len(legacyEntry.APIKeys) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
target := findOpenAICompatTarget(cfg.OpenAICompatibility, legacyEntry.Name, legacyEntry.BaseURL)
|
||||||
|
if target == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if mergeLegacyOpenAICompatAPIKeys(target, legacyEntry.APIKeys) {
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
func mergeLegacyOpenAICompatAPIKeys(entry *OpenAICompatibility, keys []string) bool {
|
||||||
|
if entry == nil || len(keys) == 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
changed := false
|
||||||
|
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{}{}
|
||||||
|
changed = true
|
||||||
|
}
|
||||||
|
return changed
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if baseKey != "" {
|
||||||
|
for i := range entries {
|
||||||
|
if strings.ToLower(strings.TrimSpace(entries[i].BaseURL)) == baseKey {
|
||||||
|
return &entries[i]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 {
|
||||||
|
if cfg == nil || legacy == nil {
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -38,7 +38,13 @@ func (m *LogFormatter) Format(entry *log.Entry) ([]byte, error) {
|
|||||||
|
|
||||||
timestamp := entry.Time.Format("2006-01-02 15:04:05")
|
timestamp := entry.Time.Format("2006-01-02 15:04:05")
|
||||||
message := strings.TrimRight(entry.Message, "\r\n")
|
message := strings.TrimRight(entry.Message, "\r\n")
|
||||||
formatted := fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, filepath.Base(entry.Caller.File), entry.Caller.Line, message)
|
|
||||||
|
var formatted string
|
||||||
|
if entry.Caller != nil {
|
||||||
|
formatted = fmt.Sprintf("[%s] [%s] [%s:%d] %s\n", timestamp, entry.Level, filepath.Base(entry.Caller.File), entry.Caller.Line, message)
|
||||||
|
} else {
|
||||||
|
formatted = fmt.Sprintf("[%s] [%s] %s\n", timestamp, entry.Level, message)
|
||||||
|
}
|
||||||
buffer.WriteString(formatted)
|
buffer.WriteString(formatted)
|
||||||
|
|
||||||
return buffer.Bytes(), nil
|
return buffer.Bytes(), nil
|
||||||
|
|||||||
@@ -961,6 +961,7 @@ func GetIFlowModels() []*ModelInfo {
|
|||||||
{ID: "glm-4.6", DisplayName: "GLM-4.6", Description: "Zhipu GLM 4.6 general model", Created: 1759190400},
|
{ID: "glm-4.6", DisplayName: "GLM-4.6", Description: "Zhipu GLM 4.6 general model", Created: 1759190400},
|
||||||
{ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model", Created: 1752192000},
|
{ID: "kimi-k2", DisplayName: "Kimi-K2", Description: "Moonshot Kimi K2 general model", Created: 1752192000},
|
||||||
{ID: "kimi-k2-thinking", DisplayName: "Kimi-K2-Thinking", Description: "Moonshot Kimi K2 general model", Created: 1762387200},
|
{ID: "kimi-k2-thinking", DisplayName: "Kimi-K2-Thinking", Description: "Moonshot Kimi K2 general model", Created: 1762387200},
|
||||||
|
{ID: "deepseek-v3.2-chat", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2", Created: 1764576000},
|
||||||
{ID: "deepseek-v3.2", DisplayName: "DeepSeek-V3.2-Exp", Description: "DeepSeek V3.2 experimental", Created: 1759104000},
|
{ID: "deepseek-v3.2", DisplayName: "DeepSeek-V3.2-Exp", Description: "DeepSeek V3.2 experimental", Created: 1759104000},
|
||||||
{ID: "deepseek-v3.1", DisplayName: "DeepSeek-V3.1-Terminus", Description: "DeepSeek V3.1 Terminus", Created: 1756339200},
|
{ID: "deepseek-v3.1", DisplayName: "DeepSeek-V3.1-Terminus", Description: "DeepSeek V3.1 Terminus", Created: 1756339200},
|
||||||
{ID: "deepseek-r1", DisplayName: "DeepSeek-R1", Description: "DeepSeek reasoning model R1", Created: 1737331200},
|
{ID: "deepseek-r1", DisplayName: "DeepSeek-R1", Description: "DeepSeek reasoning model R1", Created: 1737331200},
|
||||||
|
|||||||
@@ -734,6 +734,8 @@ func modelName2Alias(modelName string) string {
|
|||||||
return "gemini-claude-sonnet-4-5"
|
return "gemini-claude-sonnet-4-5"
|
||||||
case "claude-sonnet-4-5-thinking":
|
case "claude-sonnet-4-5-thinking":
|
||||||
return "gemini-claude-sonnet-4-5-thinking"
|
return "gemini-claude-sonnet-4-5-thinking"
|
||||||
|
case "claude-opus-4-5-thinking":
|
||||||
|
return "gemini-claude-opus-4-5-thinking"
|
||||||
case "chat_20706", "chat_23310", "gemini-2.5-flash-thinking", "gemini-3-pro-low", "gemini-2.5-pro":
|
case "chat_20706", "chat_23310", "gemini-2.5-flash-thinking", "gemini-3-pro-low", "gemini-2.5-pro":
|
||||||
return ""
|
return ""
|
||||||
default:
|
default:
|
||||||
@@ -753,6 +755,8 @@ func alias2ModelName(modelName string) string {
|
|||||||
return "claude-sonnet-4-5"
|
return "claude-sonnet-4-5"
|
||||||
case "gemini-claude-sonnet-4-5-thinking":
|
case "gemini-claude-sonnet-4-5-thinking":
|
||||||
return "claude-sonnet-4-5-thinking"
|
return "claude-sonnet-4-5-thinking"
|
||||||
|
case "gemini-claude-opus-4-5-thinking":
|
||||||
|
return "claude-opus-4-5-thinking"
|
||||||
default:
|
default:
|
||||||
return modelName
|
return modelName
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1162,71 +1162,37 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
|||||||
|
|
||||||
// Handle new APIKeyEntries format (preferred)
|
// Handle new APIKeyEntries format (preferred)
|
||||||
createdEntries := 0
|
createdEntries := 0
|
||||||
if len(compat.APIKeyEntries) > 0 {
|
for j := range compat.APIKeyEntries {
|
||||||
for j := range compat.APIKeyEntries {
|
entry := &compat.APIKeyEntries[j]
|
||||||
entry := &compat.APIKeyEntries[j]
|
key := strings.TrimSpace(entry.APIKey)
|
||||||
key := strings.TrimSpace(entry.APIKey)
|
proxyURL := strings.TrimSpace(entry.ProxyURL)
|
||||||
proxyURL := strings.TrimSpace(entry.ProxyURL)
|
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
|
||||||
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
|
id, token := idGen.next(idKind, key, base, proxyURL)
|
||||||
id, token := idGen.next(idKind, key, base, proxyURL)
|
attrs := map[string]string{
|
||||||
attrs := map[string]string{
|
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
|
||||||
"source": fmt.Sprintf("config:%s[%s]", providerName, token),
|
"base_url": base,
|
||||||
"base_url": base,
|
"compat_name": compat.Name,
|
||||||
"compat_name": compat.Name,
|
"provider_key": providerName,
|
||||||
"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++
|
|
||||||
}
|
}
|
||||||
} else {
|
if key != "" {
|
||||||
// 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,
|
|
||||||
}
|
|
||||||
attrs["api_key"] = 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 {
|
if createdEntries == 0 {
|
||||||
idKind := fmt.Sprintf("openai-compatibility:%s", providerName)
|
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 {
|
if len(cfg.OpenAICompatibility) > 0 {
|
||||||
// Do not construct legacy clients for OpenAI-compat providers; these are handled by the stateless executor.
|
// Do not construct legacy clients for OpenAI-compat providers; these are handled by the stateless executor.
|
||||||
for _, compatConfig := range cfg.OpenAICompatibility {
|
for _, compatConfig := range cfg.OpenAICompatibility {
|
||||||
// Count from new APIKeyEntries format if present, otherwise fall back to legacy APIKeys
|
openAICompatCount += len(compatConfig.APIKeyEntries)
|
||||||
if len(compatConfig.APIKeyEntries) > 0 {
|
|
||||||
openAICompatCount += len(compatConfig.APIKeyEntries)
|
|
||||||
} else {
|
|
||||||
openAICompatCount += len(compatConfig.APIKeys)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
|
return geminiAPIKeyCount, vertexCompatAPIKeyCount, claudeAPIKeyCount, codexAPIKeyCount, openAICompatCount
|
||||||
@@ -1612,24 +1573,9 @@ func describeOpenAICompatibilityUpdate(oldEntry, newEntry config.OpenAICompatibi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func countAPIKeys(entry config.OpenAICompatibility) int {
|
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
|
count := 0
|
||||||
for _, value := range values {
|
for _, keyEntry := range entry.APIKeyEntries {
|
||||||
if strings.TrimSpace(value) != "" {
|
if strings.TrimSpace(keyEntry.APIKey) != "" {
|
||||||
count++
|
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))
|
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)
|
// Claude keys (do not print key material)
|
||||||
|
|||||||
195
test/config_migration_test.go
Normal file
195
test/config_migration_test.go
Normal file
@@ -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)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user