mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-03 04:50:52 +08:00
Merge branch 'router-for-me:main' into main
This commit is contained in:
@@ -91,6 +91,10 @@ Native macOS menu bar app to use your Claude Code & ChatGPT subscriptions with A
|
|||||||
|
|
||||||
Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
|
Browser-based tool to translate SRT subtitles using your Gemini subscription via CLIProxyAPI with automatic validation/error correction - no API keys needed
|
||||||
|
|
||||||
|
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
|
||||||
|
|
||||||
|
CLI wrapper for instant switching between multiple Claude accounts and alternative models (Gemini, Codex, Antigravity) via CLIProxyAPI OAuth - no API keys needed
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
|
> If you developed a project based on CLIProxyAPI, please open a PR to add it to this list.
|
||||||
|
|
||||||
|
|||||||
@@ -89,6 +89,10 @@ CLIProxyAPI 已内置对 [Amp CLI](https://ampcode.com) 和 Amp IDE 扩展的支
|
|||||||
|
|
||||||
一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
|
一款基于浏览器的 SRT 字幕翻译工具,可通过 CLI 代理 API 使用您的 Gemini 订阅。内置自动验证与错误修正功能,无需 API 密钥。
|
||||||
|
|
||||||
|
### [CCS (Claude Code Switch)](https://github.com/kaitranntt/ccs)
|
||||||
|
|
||||||
|
CLI 封装器,用于通过 CLIProxyAPI OAuth 即时切换多个 Claude 账户和替代模型(Gemini, Codex, Antigravity),无需 API 密钥。
|
||||||
|
|
||||||
> [!NOTE]
|
> [!NOTE]
|
||||||
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
|
> 如果你开发了基于 CLIProxyAPI 的项目,请提交一个 PR(拉取请求)将其添加到此列表中。
|
||||||
|
|
||||||
|
|||||||
@@ -84,6 +84,11 @@ ws-auth: false
|
|||||||
# headers:
|
# headers:
|
||||||
# X-Custom-Header: "custom-value"
|
# X-Custom-Header: "custom-value"
|
||||||
# proxy-url: "socks5://proxy.example.com:1080"
|
# 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-key: "AIzaSy...02"
|
||||||
|
|
||||||
# API keys for official Generative Language API (legacy compatibility)
|
# API keys for official Generative Language API (legacy compatibility)
|
||||||
@@ -98,6 +103,11 @@ ws-auth: false
|
|||||||
# 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:
|
||||||
|
# - "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 keys
|
||||||
#claude-api-key:
|
#claude-api-key:
|
||||||
@@ -110,6 +120,11 @@ ws-auth: false
|
|||||||
# 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:
|
||||||
|
# - "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 providers
|
||||||
#openai-compatibility:
|
#openai-compatibility:
|
||||||
@@ -143,3 +158,25 @@ ws-auth: false
|
|||||||
# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
|
# protocol: "codex" # restricts the rule to a specific protocol, options: openai, gemini, claude, codex
|
||||||
# params: # JSON path (gjson/sjson syntax) -> value
|
# params: # JSON path (gjson/sjson syntax) -> value
|
||||||
# "reasoning.effort": "high"
|
# "reasoning.effort": "high"
|
||||||
|
|
||||||
|
# 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"
|
||||||
|
|||||||
@@ -223,6 +223,7 @@ func (h *Handler) PatchGeminiKey(c *gin.Context) {
|
|||||||
value.APIKey = strings.TrimSpace(value.APIKey)
|
value.APIKey = strings.TrimSpace(value.APIKey)
|
||||||
value.BaseURL = strings.TrimSpace(value.BaseURL)
|
value.BaseURL = strings.TrimSpace(value.BaseURL)
|
||||||
value.ProxyURL = strings.TrimSpace(value.ProxyURL)
|
value.ProxyURL = strings.TrimSpace(value.ProxyURL)
|
||||||
|
value.ExcludedModels = config.NormalizeExcludedModels(value.ExcludedModels)
|
||||||
if value.APIKey == "" {
|
if value.APIKey == "" {
|
||||||
// Treat empty API key as delete.
|
// Treat empty API key as delete.
|
||||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
|
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.GeminiKey) {
|
||||||
@@ -504,6 +505,91 @@ func (h *Handler) DeleteOpenAICompat(c *gin.Context) {
|
|||||||
c.JSON(400, gin.H{"error": "missing name or index"})
|
c.JSON(400, gin.H{"error": "missing name or index"})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// oauth-excluded-models: map[string][]string
|
||||||
|
func (h *Handler) GetOAuthExcludedModels(c *gin.Context) {
|
||||||
|
c.JSON(200, gin.H{"oauth-excluded-models": config.NormalizeOAuthExcludedModels(h.cfg.OAuthExcludedModels)})
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PutOAuthExcludedModels(c *gin.Context) {
|
||||||
|
data, err := c.GetRawData()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "failed to read body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var entries map[string][]string
|
||||||
|
if err = json.Unmarshal(data, &entries); err != nil {
|
||||||
|
var wrapper struct {
|
||||||
|
Items map[string][]string `json:"items"`
|
||||||
|
}
|
||||||
|
if err2 := json.Unmarshal(data, &wrapper); err2 != nil {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entries = wrapper.Items
|
||||||
|
}
|
||||||
|
h.cfg.OAuthExcludedModels = config.NormalizeOAuthExcludedModels(entries)
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) PatchOAuthExcludedModels(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Provider *string `json:"provider"`
|
||||||
|
Models []string `json:"models"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil || body.Provider == nil {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid body"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(*body.Provider))
|
||||||
|
if provider == "" {
|
||||||
|
c.JSON(400, gin.H{"error": "invalid provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
normalized := config.NormalizeExcludedModels(body.Models)
|
||||||
|
if len(normalized) == 0 {
|
||||||
|
if h.cfg.OAuthExcludedModels == nil {
|
||||||
|
c.JSON(404, gin.H{"error": "provider not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {
|
||||||
|
c.JSON(404, gin.H{"error": "provider not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(h.cfg.OAuthExcludedModels, provider)
|
||||||
|
if len(h.cfg.OAuthExcludedModels) == 0 {
|
||||||
|
h.cfg.OAuthExcludedModels = nil
|
||||||
|
}
|
||||||
|
h.persist(c)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.cfg.OAuthExcludedModels == nil {
|
||||||
|
h.cfg.OAuthExcludedModels = make(map[string][]string)
|
||||||
|
}
|
||||||
|
h.cfg.OAuthExcludedModels[provider] = normalized
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *Handler) DeleteOAuthExcludedModels(c *gin.Context) {
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(c.Query("provider")))
|
||||||
|
if provider == "" {
|
||||||
|
c.JSON(400, gin.H{"error": "missing provider"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.cfg.OAuthExcludedModels == nil {
|
||||||
|
c.JSON(404, gin.H{"error": "provider not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, ok := h.cfg.OAuthExcludedModels[provider]; !ok {
|
||||||
|
c.JSON(404, gin.H{"error": "provider not found"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
delete(h.cfg.OAuthExcludedModels, provider)
|
||||||
|
if len(h.cfg.OAuthExcludedModels) == 0 {
|
||||||
|
h.cfg.OAuthExcludedModels = nil
|
||||||
|
}
|
||||||
|
h.persist(c)
|
||||||
|
}
|
||||||
|
|
||||||
// codex-api-key: []CodexKey
|
// codex-api-key: []CodexKey
|
||||||
func (h *Handler) GetCodexKeys(c *gin.Context) {
|
func (h *Handler) GetCodexKeys(c *gin.Context) {
|
||||||
c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
|
c.JSON(200, gin.H{"codex-api-key": h.cfg.CodexKey})
|
||||||
@@ -533,6 +619,7 @@ func (h *Handler) PutCodexKeys(c *gin.Context) {
|
|||||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||||
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
||||||
|
entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
|
||||||
if entry.BaseURL == "" {
|
if entry.BaseURL == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -557,6 +644,7 @@ func (h *Handler) PatchCodexKey(c *gin.Context) {
|
|||||||
value.BaseURL = strings.TrimSpace(value.BaseURL)
|
value.BaseURL = strings.TrimSpace(value.BaseURL)
|
||||||
value.ProxyURL = strings.TrimSpace(value.ProxyURL)
|
value.ProxyURL = strings.TrimSpace(value.ProxyURL)
|
||||||
value.Headers = config.NormalizeHeaders(value.Headers)
|
value.Headers = config.NormalizeHeaders(value.Headers)
|
||||||
|
value.ExcludedModels = config.NormalizeExcludedModels(value.ExcludedModels)
|
||||||
// If base-url becomes empty, delete instead of update
|
// If base-url becomes empty, delete instead of update
|
||||||
if value.BaseURL == "" {
|
if value.BaseURL == "" {
|
||||||
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
if body.Index != nil && *body.Index >= 0 && *body.Index < len(h.cfg.CodexKey) {
|
||||||
@@ -694,6 +782,7 @@ func normalizeClaudeKey(entry *config.ClaudeKey) {
|
|||||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||||
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
entry.Headers = config.NormalizeHeaders(entry.Headers)
|
||||||
|
entry.ExcludedModels = config.NormalizeExcludedModels(entry.ExcludedModels)
|
||||||
if len(entry.Models) == 0 {
|
if len(entry.Models) == 0 {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,14 @@ func (m *AmpModule) registerManagementRoutes(engine *gin.Engine, baseHandler *ha
|
|||||||
ampAPI.Any("/otel", proxyHandler)
|
ampAPI.Any("/otel", proxyHandler)
|
||||||
ampAPI.Any("/otel/*path", proxyHandler)
|
ampAPI.Any("/otel/*path", proxyHandler)
|
||||||
|
|
||||||
|
// Root-level routes that AMP CLI expects without /api prefix
|
||||||
|
// These need the same security middleware as the /api/* routes
|
||||||
|
rootMiddleware := []gin.HandlerFunc{noCORSMiddleware()}
|
||||||
|
if restrictToLocalhost {
|
||||||
|
rootMiddleware = append(rootMiddleware, localhostOnlyMiddleware())
|
||||||
|
}
|
||||||
|
engine.GET("/threads.rss", 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.
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ func TestRegisterManagementRoutes(t *testing.T) {
|
|||||||
{"/api/meta", http.MethodGet},
|
{"/api/meta", http.MethodGet},
|
||||||
{"/api/telemetry", http.MethodGet},
|
{"/api/telemetry", http.MethodGet},
|
||||||
{"/api/threads", http.MethodGet},
|
{"/api/threads", http.MethodGet},
|
||||||
|
{"/threads.rss", http.MethodGet}, // Root-level route (no /api prefix)
|
||||||
{"/api/otel", http.MethodGet},
|
{"/api/otel", http.MethodGet},
|
||||||
// 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},
|
||||||
|
|||||||
@@ -543,6 +543,11 @@ func (s *Server) registerManagementRoutes() {
|
|||||||
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
|
mgmt.PATCH("/openai-compatibility", s.mgmt.PatchOpenAICompat)
|
||||||
mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat)
|
mgmt.DELETE("/openai-compatibility", s.mgmt.DeleteOpenAICompat)
|
||||||
|
|
||||||
|
mgmt.GET("/oauth-excluded-models", s.mgmt.GetOAuthExcludedModels)
|
||||||
|
mgmt.PUT("/oauth-excluded-models", s.mgmt.PutOAuthExcludedModels)
|
||||||
|
mgmt.PATCH("/oauth-excluded-models", s.mgmt.PatchOAuthExcludedModels)
|
||||||
|
mgmt.DELETE("/oauth-excluded-models", s.mgmt.DeleteOAuthExcludedModels)
|
||||||
|
|
||||||
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
|
mgmt.GET("/auth-files", s.mgmt.ListAuthFiles)
|
||||||
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
|
mgmt.GET("/auth-files/download", s.mgmt.DownloadAuthFile)
|
||||||
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
mgmt.POST("/auth-files", s.mgmt.UploadAuthFile)
|
||||||
|
|||||||
@@ -89,6 +89,9 @@ type Config struct {
|
|||||||
|
|
||||||
// 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.
|
||||||
|
OAuthExcludedModels map[string][]string `yaml:"oauth-excluded-models,omitempty" json:"oauth-excluded-models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// TLSConfig holds HTTPS server settings.
|
// TLSConfig holds HTTPS server settings.
|
||||||
@@ -175,6 +178,9 @@ type ClaudeKey struct {
|
|||||||
|
|
||||||
// Headers optionally adds extra HTTP headers for requests sent with this key.
|
// Headers optionally adds extra HTTP headers for requests sent with this key.
|
||||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||||
|
|
||||||
|
// ExcludedModels lists model IDs that should be excluded for this provider.
|
||||||
|
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClaudeModel describes a mapping between an alias and the actual upstream model name.
|
// ClaudeModel describes a mapping between an alias and the actual upstream model name.
|
||||||
@@ -201,6 +207,9 @@ type CodexKey struct {
|
|||||||
|
|
||||||
// Headers optionally adds extra HTTP headers for requests sent with this key.
|
// Headers optionally adds extra HTTP headers for requests sent with this key.
|
||||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||||
|
|
||||||
|
// ExcludedModels lists model IDs that should be excluded for this provider.
|
||||||
|
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// GeminiKey represents the configuration for a Gemini API key,
|
// GeminiKey represents the configuration for a Gemini API key,
|
||||||
@@ -217,6 +226,9 @@ type GeminiKey struct {
|
|||||||
|
|
||||||
// Headers optionally adds extra HTTP headers for requests sent with this key.
|
// Headers optionally adds extra HTTP headers for requests sent with this key.
|
||||||
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
Headers map[string]string `yaml:"headers,omitempty" json:"headers,omitempty"`
|
||||||
|
|
||||||
|
// ExcludedModels lists model IDs that should be excluded for this provider.
|
||||||
|
ExcludedModels []string `yaml:"excluded-models,omitempty" json:"excluded-models,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAICompatibility represents the configuration for OpenAI API compatibility
|
// OpenAICompatibility represents the configuration for OpenAI API compatibility
|
||||||
@@ -340,6 +352,9 @@ func LoadConfigOptional(configFile string, optional bool) (*Config, error) {
|
|||||||
// Sanitize OpenAI compatibility providers: drop entries without base-url
|
// Sanitize OpenAI compatibility providers: drop entries without base-url
|
||||||
cfg.SanitizeOpenAICompatibility()
|
cfg.SanitizeOpenAICompatibility()
|
||||||
|
|
||||||
|
// Normalize OAuth provider model exclusion map.
|
||||||
|
cfg.OAuthExcludedModels = NormalizeOAuthExcludedModels(cfg.OAuthExcludedModels)
|
||||||
|
|
||||||
// Return the populated configuration struct.
|
// Return the populated configuration struct.
|
||||||
return &cfg, nil
|
return &cfg, nil
|
||||||
}
|
}
|
||||||
@@ -377,6 +392,7 @@ func (cfg *Config) SanitizeCodexKeys() {
|
|||||||
e := cfg.CodexKey[i]
|
e := cfg.CodexKey[i]
|
||||||
e.BaseURL = strings.TrimSpace(e.BaseURL)
|
e.BaseURL = strings.TrimSpace(e.BaseURL)
|
||||||
e.Headers = NormalizeHeaders(e.Headers)
|
e.Headers = NormalizeHeaders(e.Headers)
|
||||||
|
e.ExcludedModels = NormalizeExcludedModels(e.ExcludedModels)
|
||||||
if e.BaseURL == "" {
|
if e.BaseURL == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -393,6 +409,7 @@ func (cfg *Config) SanitizeClaudeKeys() {
|
|||||||
for i := range cfg.ClaudeKey {
|
for i := range cfg.ClaudeKey {
|
||||||
entry := &cfg.ClaudeKey[i]
|
entry := &cfg.ClaudeKey[i]
|
||||||
entry.Headers = NormalizeHeaders(entry.Headers)
|
entry.Headers = NormalizeHeaders(entry.Headers)
|
||||||
|
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,6 +430,7 @@ func (cfg *Config) SanitizeGeminiKeys() {
|
|||||||
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
entry.BaseURL = strings.TrimSpace(entry.BaseURL)
|
||||||
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
entry.ProxyURL = strings.TrimSpace(entry.ProxyURL)
|
||||||
entry.Headers = NormalizeHeaders(entry.Headers)
|
entry.Headers = NormalizeHeaders(entry.Headers)
|
||||||
|
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
|
||||||
if _, exists := seen[entry.APIKey]; exists {
|
if _, exists := seen[entry.APIKey]; exists {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -475,6 +493,55 @@ func NormalizeHeaders(headers map[string]string) map[string]string {
|
|||||||
return clean
|
return clean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// NormalizeExcludedModels trims, lowercases, and deduplicates model exclusion patterns.
|
||||||
|
// It preserves the order of first occurrences and drops empty entries.
|
||||||
|
func NormalizeExcludedModels(models []string) []string {
|
||||||
|
if len(models) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(models))
|
||||||
|
out := make([]string, 0, len(models))
|
||||||
|
for _, raw := range models {
|
||||||
|
trimmed := strings.ToLower(strings.TrimSpace(raw))
|
||||||
|
if trimmed == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[trimmed]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
out = append(out, trimmed)
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// NormalizeOAuthExcludedModels cleans provider -> excluded models mappings by normalizing provider keys
|
||||||
|
// and applying model exclusion normalization to each entry.
|
||||||
|
func NormalizeOAuthExcludedModels(entries map[string][]string) map[string][]string {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string][]string, len(entries))
|
||||||
|
for provider, models := range entries {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
normalized := NormalizeExcludedModels(models)
|
||||||
|
if len(normalized) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[key] = normalized
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
// hashSecret hashes the given secret using bcrypt.
|
// hashSecret hashes the given secret using bcrypt.
|
||||||
func hashSecret(secret string) (string, error) {
|
func hashSecret(secret string) (string, error) {
|
||||||
// Use default cost for simplicity.
|
// Use default cost for simplicity.
|
||||||
|
|||||||
@@ -222,6 +222,7 @@ func GetGeminiModels() []*ModelInfo {
|
|||||||
InputTokenLimit: 1048576,
|
InputTokenLimit: 1048576,
|
||||||
OutputTokenLimit: 65536,
|
OutputTokenLimit: 65536,
|
||||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||||
|
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -301,6 +302,7 @@ func GetGeminiVertexModels() []*ModelInfo {
|
|||||||
InputTokenLimit: 1048576,
|
InputTokenLimit: 1048576,
|
||||||
OutputTokenLimit: 65536,
|
OutputTokenLimit: 65536,
|
||||||
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
SupportedGenerationMethods: []string{"generateContent", "countTokens", "createCachedContent", "batchGenerateContent"},
|
||||||
|
Thinking: &ThinkingSupport{Min: 128, Max: 32768, ZeroAllowed: false, DynamicAllowed: true},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -308,13 +308,7 @@ func (e *AIStudioExecutor) translateRequest(req cliproxyexecutor.Request, opts c
|
|||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream)
|
payload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), stream)
|
||||||
if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok && util.ModelSupportsThinking(req.Model) {
|
payload = applyThinkingMetadata(payload, req.Metadata, req.Model)
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride)
|
|
||||||
budgetOverride = &norm
|
|
||||||
}
|
|
||||||
payload = util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride)
|
|
||||||
}
|
|
||||||
payload = util.ConvertThinkingLevelToBudget(payload)
|
payload = util.ConvertThinkingLevelToBudget(payload)
|
||||||
payload = util.StripThinkingConfigIfUnsupported(req.Model, payload)
|
payload = util.StripThinkingConfigIfUnsupported(req.Model, payload)
|
||||||
payload = fixGeminiImageAspectRatio(req.Model, payload)
|
payload = fixGeminiImageAspectRatio(req.Model, payload)
|
||||||
|
|||||||
@@ -75,6 +75,8 @@ func (e *AntigravityExecutor) Execute(ctx context.Context, auth *cliproxyauth.Au
|
|||||||
to := sdktranslator.FromString("antigravity")
|
to := sdktranslator.FromString("antigravity")
|
||||||
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||||
|
|
||||||
|
translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
|
||||||
@@ -166,6 +168,8 @@ func (e *AntigravityExecutor) ExecuteStream(ctx context.Context, auth *cliproxya
|
|||||||
to := sdktranslator.FromString("antigravity")
|
to := sdktranslator.FromString("antigravity")
|
||||||
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
translated := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
||||||
|
|
||||||
|
translated = applyThinkingMetadataCLI(translated, req.Metadata, req.Model)
|
||||||
|
|
||||||
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
baseURLs := antigravityBaseURLFallbackOrder(auth)
|
||||||
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
httpClient := newProxyAwareHTTPClient(ctx, e.cfg, auth, 0)
|
||||||
|
|
||||||
|
|||||||
@@ -62,15 +62,8 @@ func (e *GeminiCLIExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth
|
|||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini-cli")
|
to := sdktranslator.FromString("gemini-cli")
|
||||||
budgetOverride, includeOverride, hasOverride := util.GeminiThinkingFromMetadata(req.Metadata)
|
|
||||||
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||||
if hasOverride && util.ModelSupportsThinking(req.Model) {
|
basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model)
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride)
|
|
||||||
budgetOverride = &norm
|
|
||||||
}
|
|
||||||
basePayload = util.ApplyGeminiCLIThinkingConfig(basePayload, budgetOverride, includeOverride)
|
|
||||||
}
|
|
||||||
basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
|
basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
|
||||||
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
||||||
basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload)
|
basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload)
|
||||||
@@ -204,15 +197,8 @@ func (e *GeminiCLIExecutor) ExecuteStream(ctx context.Context, auth *cliproxyaut
|
|||||||
|
|
||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini-cli")
|
to := sdktranslator.FromString("gemini-cli")
|
||||||
budgetOverride, includeOverride, hasOverride := util.GeminiThinkingFromMetadata(req.Metadata)
|
|
||||||
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
basePayload := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
||||||
if hasOverride && util.ModelSupportsThinking(req.Model) {
|
basePayload = applyThinkingMetadataCLI(basePayload, req.Metadata, req.Model)
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride)
|
|
||||||
budgetOverride = &norm
|
|
||||||
}
|
|
||||||
basePayload = util.ApplyGeminiCLIThinkingConfig(basePayload, budgetOverride, includeOverride)
|
|
||||||
}
|
|
||||||
basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
|
basePayload = util.StripThinkingConfigIfUnsupported(req.Model, basePayload)
|
||||||
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
basePayload = fixGeminiCLIImageAspectRatio(req.Model, basePayload)
|
||||||
basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload)
|
basePayload = applyPayloadConfigWithRoot(e.cfg, req.Model, "gemini", "request", basePayload)
|
||||||
@@ -408,16 +394,9 @@ func (e *GeminiCLIExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.
|
|||||||
var lastStatus int
|
var lastStatus int
|
||||||
var lastBody []byte
|
var lastBody []byte
|
||||||
|
|
||||||
budgetOverride, includeOverride, hasOverride := util.GeminiThinkingFromMetadata(req.Metadata)
|
|
||||||
for _, attemptModel := range models {
|
for _, attemptModel := range models {
|
||||||
payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false)
|
payload := sdktranslator.TranslateRequest(from, to, attemptModel, bytes.Clone(req.Payload), false)
|
||||||
if hasOverride && util.ModelSupportsThinking(req.Model) {
|
payload = applyThinkingMetadataCLI(payload, req.Metadata, req.Model)
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride)
|
|
||||||
budgetOverride = &norm
|
|
||||||
}
|
|
||||||
payload = util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride)
|
|
||||||
}
|
|
||||||
payload = deleteJSONField(payload, "project")
|
payload = deleteJSONField(payload, "project")
|
||||||
payload = deleteJSONField(payload, "model")
|
payload = deleteJSONField(payload, "model")
|
||||||
payload = deleteJSONField(payload, "request.safetySettings")
|
payload = deleteJSONField(payload, "request.safetySettings")
|
||||||
|
|||||||
@@ -79,13 +79,7 @@ func (e *GeminiExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, r
|
|||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||||
if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok && util.ModelSupportsThinking(req.Model) {
|
body = applyThinkingMetadata(body, req.Metadata, req.Model)
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride)
|
|
||||||
budgetOverride = &norm
|
|
||||||
}
|
|
||||||
body = util.ApplyGeminiThinkingConfig(body, budgetOverride, includeOverride)
|
|
||||||
}
|
|
||||||
body = util.StripThinkingConfigIfUnsupported(req.Model, body)
|
body = util.StripThinkingConfigIfUnsupported(req.Model, body)
|
||||||
body = fixGeminiImageAspectRatio(req.Model, body)
|
body = fixGeminiImageAspectRatio(req.Model, body)
|
||||||
body = applyPayloadConfig(e.cfg, req.Model, body)
|
body = applyPayloadConfig(e.cfg, req.Model, body)
|
||||||
@@ -174,13 +168,7 @@ func (e *GeminiExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.A
|
|||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
body := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), true)
|
||||||
if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok && util.ModelSupportsThinking(req.Model) {
|
body = applyThinkingMetadata(body, req.Metadata, req.Model)
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride)
|
|
||||||
budgetOverride = &norm
|
|
||||||
}
|
|
||||||
body = util.ApplyGeminiThinkingConfig(body, budgetOverride, includeOverride)
|
|
||||||
}
|
|
||||||
body = util.StripThinkingConfigIfUnsupported(req.Model, body)
|
body = util.StripThinkingConfigIfUnsupported(req.Model, body)
|
||||||
body = fixGeminiImageAspectRatio(req.Model, body)
|
body = fixGeminiImageAspectRatio(req.Model, body)
|
||||||
body = applyPayloadConfig(e.cfg, req.Model, body)
|
body = applyPayloadConfig(e.cfg, req.Model, body)
|
||||||
@@ -288,13 +276,7 @@ func (e *GeminiExecutor) CountTokens(ctx context.Context, auth *cliproxyauth.Aut
|
|||||||
from := opts.SourceFormat
|
from := opts.SourceFormat
|
||||||
to := sdktranslator.FromString("gemini")
|
to := sdktranslator.FromString("gemini")
|
||||||
translatedReq := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
translatedReq := sdktranslator.TranslateRequest(from, to, req.Model, bytes.Clone(req.Payload), false)
|
||||||
if budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(req.Metadata); ok && util.ModelSupportsThinking(req.Model) {
|
translatedReq = applyThinkingMetadata(translatedReq, req.Metadata, req.Model)
|
||||||
if budgetOverride != nil {
|
|
||||||
norm := util.NormalizeThinkingBudget(req.Model, *budgetOverride)
|
|
||||||
budgetOverride = &norm
|
|
||||||
}
|
|
||||||
translatedReq = util.ApplyGeminiThinkingConfig(translatedReq, budgetOverride, includeOverride)
|
|
||||||
}
|
|
||||||
translatedReq = util.StripThinkingConfigIfUnsupported(req.Model, translatedReq)
|
translatedReq = util.StripThinkingConfigIfUnsupported(req.Model, translatedReq)
|
||||||
translatedReq = fixGeminiImageAspectRatio(req.Model, translatedReq)
|
translatedReq = fixGeminiImageAspectRatio(req.Model, translatedReq)
|
||||||
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
respCtx := context.WithValue(ctx, "alt", opts.Alt)
|
||||||
|
|||||||
@@ -4,10 +4,42 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/config"
|
||||||
|
"github.com/router-for-me/CLIProxyAPI/v6/internal/util"
|
||||||
"github.com/tidwall/gjson"
|
"github.com/tidwall/gjson"
|
||||||
"github.com/tidwall/sjson"
|
"github.com/tidwall/sjson"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// applyThinkingMetadata applies thinking config from model suffix metadata (e.g., -reasoning, -thinking-N)
|
||||||
|
// for standard Gemini format payloads. It normalizes the budget when the model supports thinking.
|
||||||
|
func applyThinkingMetadata(payload []byte, metadata map[string]any, model string) []byte {
|
||||||
|
budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(metadata)
|
||||||
|
if !ok {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if !util.ModelSupportsThinking(model) {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if budgetOverride != nil {
|
||||||
|
norm := util.NormalizeThinkingBudget(model, *budgetOverride)
|
||||||
|
budgetOverride = &norm
|
||||||
|
}
|
||||||
|
return util.ApplyGeminiThinkingConfig(payload, budgetOverride, includeOverride)
|
||||||
|
}
|
||||||
|
|
||||||
|
// applyThinkingMetadataCLI applies thinking config from model suffix metadata (e.g., -reasoning, -thinking-N)
|
||||||
|
// for Gemini CLI format payloads (nested under "request"). It normalizes the budget when the model supports thinking.
|
||||||
|
func applyThinkingMetadataCLI(payload []byte, metadata map[string]any, model string) []byte {
|
||||||
|
budgetOverride, includeOverride, ok := util.GeminiThinkingFromMetadata(metadata)
|
||||||
|
if !ok {
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
if budgetOverride != nil && util.ModelSupportsThinking(model) {
|
||||||
|
norm := util.NormalizeThinkingBudget(model, *budgetOverride)
|
||||||
|
budgetOverride = &norm
|
||||||
|
}
|
||||||
|
return util.ApplyGeminiCLIThinkingConfig(payload, budgetOverride, includeOverride)
|
||||||
|
}
|
||||||
|
|
||||||
// applyPayloadConfig applies payload default and override rules from configuration
|
// applyPayloadConfig applies payload default and override rules from configuration
|
||||||
// to the given JSON payload for the specified model.
|
// to the given JSON payload for the specified model.
|
||||||
// Defaults only fill missing fields, while overrides always overwrite existing values.
|
// Defaults only fill missing fields, while overrides always overwrite existing values.
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ func newUsageReporter(ctx context.Context, provider, model string, auth *cliprox
|
|||||||
}
|
}
|
||||||
if auth != nil {
|
if auth != nil {
|
||||||
reporter.authID = auth.ID
|
reporter.authID = auth.ID
|
||||||
reporter.authIndex = auth.Index
|
reporter.authIndex = auth.EnsureIndex()
|
||||||
}
|
}
|
||||||
return reporter
|
return reporter
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,6 +34,15 @@ func ParseGeminiThinkingSuffix(model string) (string, *int, *bool, bool) {
|
|||||||
return base, &budgetValue, &include, true
|
return base, &budgetValue, &include, true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Handle "-reasoning" suffix: enables thinking with dynamic budget (-1)
|
||||||
|
// Maps: gemini-2.5-flash-reasoning -> gemini-2.5-flash with thinkingBudget=-1
|
||||||
|
if strings.HasSuffix(lower, "-reasoning") {
|
||||||
|
base := model[:len(model)-len("-reasoning")]
|
||||||
|
budgetValue := -1 // Dynamic budget
|
||||||
|
include := true
|
||||||
|
return base, &budgetValue, &include, true
|
||||||
|
}
|
||||||
|
|
||||||
idx := strings.LastIndex(lower, "-thinking-")
|
idx := strings.LastIndex(lower, "-thinking-")
|
||||||
if idx == -1 {
|
if idx == -1 {
|
||||||
return model, nil, nil, false
|
return model, nil, nil, false
|
||||||
|
|||||||
@@ -30,6 +30,16 @@ import (
|
|||||||
log "github.com/sirupsen/logrus"
|
log "github.com/sirupsen/logrus"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func matchProvider(provider string, targets []string) (string, bool) {
|
||||||
|
p := strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
for _, t := range targets {
|
||||||
|
if strings.EqualFold(p, strings.TrimSpace(t)) {
|
||||||
|
return p, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return p, false
|
||||||
|
}
|
||||||
|
|
||||||
// storePersister captures persistence-capable token store methods used by the watcher.
|
// storePersister captures persistence-capable token store methods used by the watcher.
|
||||||
type storePersister interface {
|
type storePersister interface {
|
||||||
PersistConfig(ctx context.Context) error
|
PersistConfig(ctx context.Context) error
|
||||||
@@ -54,6 +64,7 @@ type Watcher struct {
|
|||||||
lastConfigHash string
|
lastConfigHash string
|
||||||
authQueue chan<- AuthUpdate
|
authQueue chan<- AuthUpdate
|
||||||
currentAuths map[string]*coreauth.Auth
|
currentAuths map[string]*coreauth.Auth
|
||||||
|
runtimeAuths map[string]*coreauth.Auth
|
||||||
dispatchMu sync.Mutex
|
dispatchMu sync.Mutex
|
||||||
dispatchCond *sync.Cond
|
dispatchCond *sync.Cond
|
||||||
pendingUpdates map[string]AuthUpdate
|
pendingUpdates map[string]AuthUpdate
|
||||||
@@ -169,7 +180,7 @@ func (w *Watcher) Start(ctx context.Context) error {
|
|||||||
go w.processEvents(ctx)
|
go w.processEvents(ctx)
|
||||||
|
|
||||||
// Perform an initial full reload based on current config and auth dir
|
// Perform an initial full reload based on current config and auth dir
|
||||||
w.reloadClients(true)
|
w.reloadClients(true, nil)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -221,9 +232,57 @@ func (w *Watcher) SetAuthUpdateQueue(queue chan<- AuthUpdate) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DispatchRuntimeAuthUpdate allows external runtime providers (e.g., websocket-driven auths)
|
||||||
|
// to push auth updates through the same queue used by file/config watchers.
|
||||||
|
// Returns true if the update was enqueued; false if no queue is configured.
|
||||||
|
func (w *Watcher) DispatchRuntimeAuthUpdate(update AuthUpdate) bool {
|
||||||
|
if w == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
w.clientsMutex.Lock()
|
||||||
|
if w.runtimeAuths == nil {
|
||||||
|
w.runtimeAuths = make(map[string]*coreauth.Auth)
|
||||||
|
}
|
||||||
|
switch update.Action {
|
||||||
|
case AuthUpdateActionAdd, AuthUpdateActionModify:
|
||||||
|
if update.Auth != nil && update.Auth.ID != "" {
|
||||||
|
clone := update.Auth.Clone()
|
||||||
|
w.runtimeAuths[clone.ID] = clone
|
||||||
|
if w.currentAuths == nil {
|
||||||
|
w.currentAuths = make(map[string]*coreauth.Auth)
|
||||||
|
}
|
||||||
|
w.currentAuths[clone.ID] = clone.Clone()
|
||||||
|
}
|
||||||
|
case AuthUpdateActionDelete:
|
||||||
|
id := update.ID
|
||||||
|
if id == "" && update.Auth != nil {
|
||||||
|
id = update.Auth.ID
|
||||||
|
}
|
||||||
|
if id != "" {
|
||||||
|
delete(w.runtimeAuths, id)
|
||||||
|
if w.currentAuths != nil {
|
||||||
|
delete(w.currentAuths, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
if w.getAuthQueue() == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
w.dispatchAuthUpdates([]AuthUpdate{update})
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func (w *Watcher) refreshAuthState() {
|
func (w *Watcher) refreshAuthState() {
|
||||||
auths := w.SnapshotCoreAuths()
|
auths := w.SnapshotCoreAuths()
|
||||||
w.clientsMutex.Lock()
|
w.clientsMutex.Lock()
|
||||||
|
if len(w.runtimeAuths) > 0 {
|
||||||
|
for _, a := range w.runtimeAuths {
|
||||||
|
if a != nil {
|
||||||
|
auths = append(auths, a.Clone())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
updates := w.prepareAuthUpdatesLocked(auths)
|
updates := w.prepareAuthUpdatesLocked(auths)
|
||||||
w.clientsMutex.Unlock()
|
w.clientsMutex.Unlock()
|
||||||
w.dispatchAuthUpdates(updates)
|
w.dispatchAuthUpdates(updates)
|
||||||
@@ -450,6 +509,142 @@ func computeClaudeModelsHash(models []config.ClaudeModel) string {
|
|||||||
return hex.EncodeToString(sum[:])
|
return hex.EncodeToString(sum[:])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func computeExcludedModelsHash(excluded []string) string {
|
||||||
|
if len(excluded) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
normalized := make([]string, 0, len(excluded))
|
||||||
|
for _, entry := range excluded {
|
||||||
|
if trimmed := strings.TrimSpace(entry); trimmed != "" {
|
||||||
|
normalized = append(normalized, strings.ToLower(trimmed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(normalized) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sort.Strings(normalized)
|
||||||
|
data, err := json.Marshal(normalized)
|
||||||
|
if err != nil || len(data) == 0 {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
sum := sha256.Sum256(data)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
type excludedModelsSummary struct {
|
||||||
|
hash string
|
||||||
|
count int
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeExcludedModels(list []string) excludedModelsSummary {
|
||||||
|
if len(list) == 0 {
|
||||||
|
return excludedModelsSummary{}
|
||||||
|
}
|
||||||
|
seen := make(map[string]struct{}, len(list))
|
||||||
|
normalized := make([]string, 0, len(list))
|
||||||
|
for _, entry := range list {
|
||||||
|
if trimmed := strings.ToLower(strings.TrimSpace(entry)); trimmed != "" {
|
||||||
|
if _, exists := seen[trimmed]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[trimmed] = struct{}{}
|
||||||
|
normalized = append(normalized, trimmed)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(normalized)
|
||||||
|
return excludedModelsSummary{
|
||||||
|
hash: computeExcludedModelsHash(normalized),
|
||||||
|
count: len(normalized),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func summarizeOAuthExcludedModels(entries map[string][]string) map[string]excludedModelsSummary {
|
||||||
|
if len(entries) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(map[string]excludedModelsSummary, len(entries))
|
||||||
|
for k, v := range entries {
|
||||||
|
key := strings.ToLower(strings.TrimSpace(k))
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out[key] = summarizeExcludedModels(v)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func diffOAuthExcludedModelChanges(oldMap, newMap map[string][]string) ([]string, []string) {
|
||||||
|
oldSummary := summarizeOAuthExcludedModels(oldMap)
|
||||||
|
newSummary := summarizeOAuthExcludedModels(newMap)
|
||||||
|
keys := make(map[string]struct{}, len(oldSummary)+len(newSummary))
|
||||||
|
for k := range oldSummary {
|
||||||
|
keys[k] = struct{}{}
|
||||||
|
}
|
||||||
|
for k := range newSummary {
|
||||||
|
keys[k] = struct{}{}
|
||||||
|
}
|
||||||
|
changes := make([]string, 0, len(keys))
|
||||||
|
affected := make([]string, 0, len(keys))
|
||||||
|
for key := range keys {
|
||||||
|
oldInfo, okOld := oldSummary[key]
|
||||||
|
newInfo, okNew := newSummary[key]
|
||||||
|
switch {
|
||||||
|
case okOld && !okNew:
|
||||||
|
changes = append(changes, fmt.Sprintf("oauth-excluded-models[%s]: removed", key))
|
||||||
|
affected = append(affected, key)
|
||||||
|
case !okOld && okNew:
|
||||||
|
changes = append(changes, fmt.Sprintf("oauth-excluded-models[%s]: added (%d entries)", key, newInfo.count))
|
||||||
|
affected = append(affected, key)
|
||||||
|
case okOld && okNew && oldInfo.hash != newInfo.hash:
|
||||||
|
changes = append(changes, fmt.Sprintf("oauth-excluded-models[%s]: updated (%d -> %d entries)", key, oldInfo.count, newInfo.count))
|
||||||
|
affected = append(affected, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(changes)
|
||||||
|
sort.Strings(affected)
|
||||||
|
return changes, affected
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyAuthExcludedModelsMeta(auth *coreauth.Auth, cfg *config.Config, perKey []string, authKind string) {
|
||||||
|
if auth == nil || cfg == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
authKindKey := strings.ToLower(strings.TrimSpace(authKind))
|
||||||
|
seen := make(map[string]struct{})
|
||||||
|
add := func(list []string) {
|
||||||
|
for _, entry := range list {
|
||||||
|
if trimmed := strings.TrimSpace(entry); trimmed != "" {
|
||||||
|
key := strings.ToLower(trimmed)
|
||||||
|
if _, exists := seen[key]; exists {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[key] = struct{}{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if authKindKey == "apikey" {
|
||||||
|
add(perKey)
|
||||||
|
} else if cfg.OAuthExcludedModels != nil {
|
||||||
|
providerKey := strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||||
|
add(cfg.OAuthExcludedModels[providerKey])
|
||||||
|
}
|
||||||
|
combined := make([]string, 0, len(seen))
|
||||||
|
for k := range seen {
|
||||||
|
combined = append(combined, k)
|
||||||
|
}
|
||||||
|
sort.Strings(combined)
|
||||||
|
hash := computeExcludedModelsHash(combined)
|
||||||
|
if auth.Attributes == nil {
|
||||||
|
auth.Attributes = make(map[string]string)
|
||||||
|
}
|
||||||
|
if hash != "" {
|
||||||
|
auth.Attributes["excluded_models_hash"] = hash
|
||||||
|
}
|
||||||
|
if authKind != "" {
|
||||||
|
auth.Attributes["auth_kind"] = authKind
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// SetClients sets the file-based clients.
|
// SetClients sets the file-based clients.
|
||||||
// SetClients removed
|
// SetClients removed
|
||||||
// SetAPIKeyClients removed
|
// SetAPIKeyClients removed
|
||||||
@@ -634,6 +829,11 @@ func (w *Watcher) reloadConfig() bool {
|
|||||||
w.config = newConfig
|
w.config = newConfig
|
||||||
w.clientsMutex.Unlock()
|
w.clientsMutex.Unlock()
|
||||||
|
|
||||||
|
var affectedOAuthProviders []string
|
||||||
|
if oldConfig != nil {
|
||||||
|
_, affectedOAuthProviders = diffOAuthExcludedModelChanges(oldConfig.OAuthExcludedModels, newConfig.OAuthExcludedModels)
|
||||||
|
}
|
||||||
|
|
||||||
// Always apply the current log level based on the latest config.
|
// Always apply the current log level based on the latest config.
|
||||||
// This ensures logrus reflects the desired level even if change detection misses.
|
// This ensures logrus reflects the desired level even if change detection misses.
|
||||||
util.SetLogLevel(newConfig)
|
util.SetLogLevel(newConfig)
|
||||||
@@ -659,12 +859,12 @@ func (w *Watcher) reloadConfig() bool {
|
|||||||
|
|
||||||
log.Infof("config successfully reloaded, triggering client reload")
|
log.Infof("config successfully reloaded, triggering client reload")
|
||||||
// Reload clients with new config
|
// Reload clients with new config
|
||||||
w.reloadClients(authDirChanged)
|
w.reloadClients(authDirChanged, affectedOAuthProviders)
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
// reloadClients performs a full scan and reload of all clients.
|
// reloadClients performs a full scan and reload of all clients.
|
||||||
func (w *Watcher) reloadClients(rescanAuth bool) {
|
func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string) {
|
||||||
log.Debugf("starting full client load process")
|
log.Debugf("starting full client load process")
|
||||||
|
|
||||||
w.clientsMutex.RLock()
|
w.clientsMutex.RLock()
|
||||||
@@ -676,6 +876,28 @@ func (w *Watcher) reloadClients(rescanAuth bool) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if len(affectedOAuthProviders) > 0 {
|
||||||
|
w.clientsMutex.Lock()
|
||||||
|
if w.currentAuths != nil {
|
||||||
|
filtered := make(map[string]*coreauth.Auth, len(w.currentAuths))
|
||||||
|
for id, auth := range w.currentAuths {
|
||||||
|
if auth == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
provider := strings.ToLower(strings.TrimSpace(auth.Provider))
|
||||||
|
if _, match := matchProvider(provider, affectedOAuthProviders); match {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
filtered[id] = auth
|
||||||
|
}
|
||||||
|
w.currentAuths = filtered
|
||||||
|
log.Debugf("applying oauth-excluded-models to providers %v", affectedOAuthProviders)
|
||||||
|
} else {
|
||||||
|
w.currentAuths = nil
|
||||||
|
}
|
||||||
|
w.clientsMutex.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// Unregister all old API key clients before creating new ones
|
// Unregister all old API key clients before creating new ones
|
||||||
// no legacy clients to unregister
|
// no legacy clients to unregister
|
||||||
|
|
||||||
@@ -849,6 +1071,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
applyAuthExcludedModelsMeta(a, cfg, entry.ExcludedModels, "apikey")
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
}
|
}
|
||||||
// Claude API keys -> synthesize auths
|
// Claude API keys -> synthesize auths
|
||||||
@@ -882,6 +1105,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
applyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey")
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
}
|
}
|
||||||
// Codex API keys -> synthesize auths
|
// Codex API keys -> synthesize auths
|
||||||
@@ -911,6 +1135,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
applyAuthExcludedModelsMeta(a, cfg, ck.ExcludedModels, "apikey")
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
}
|
}
|
||||||
for i := range cfg.OpenAICompatibility {
|
for i := range cfg.OpenAICompatibility {
|
||||||
@@ -1071,8 +1296,12 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
|
|||||||
CreatedAt: now,
|
CreatedAt: now,
|
||||||
UpdatedAt: now,
|
UpdatedAt: now,
|
||||||
}
|
}
|
||||||
|
applyAuthExcludedModelsMeta(a, cfg, nil, "oauth")
|
||||||
if provider == "gemini-cli" {
|
if provider == "gemini-cli" {
|
||||||
if virtuals := synthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 {
|
if virtuals := synthesizeGeminiVirtualAuths(a, metadata, now); len(virtuals) > 0 {
|
||||||
|
for _, v := range virtuals {
|
||||||
|
applyAuthExcludedModelsMeta(v, cfg, nil, "oauth")
|
||||||
|
}
|
||||||
out = append(out, a)
|
out = append(out, a)
|
||||||
out = append(out, virtuals...)
|
out = append(out, virtuals...)
|
||||||
continue
|
continue
|
||||||
@@ -1464,6 +1693,11 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
|||||||
if !equalStringMap(o.Headers, n.Headers) {
|
if !equalStringMap(o.Headers, n.Headers) {
|
||||||
changes = append(changes, fmt.Sprintf("gemini[%d].headers: updated", i))
|
changes = append(changes, fmt.Sprintf("gemini[%d].headers: updated", i))
|
||||||
}
|
}
|
||||||
|
oldExcluded := summarizeExcludedModels(o.ExcludedModels)
|
||||||
|
newExcluded := summarizeExcludedModels(n.ExcludedModels)
|
||||||
|
if oldExcluded.hash != newExcluded.hash {
|
||||||
|
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)) {
|
if !reflect.DeepEqual(trimStrings(oldCfg.GlAPIKey), trimStrings(newCfg.GlAPIKey)) {
|
||||||
changes = append(changes, "generative-language-api-key: values updated (legacy view, redacted)")
|
changes = append(changes, "generative-language-api-key: values updated (legacy view, redacted)")
|
||||||
@@ -1492,6 +1726,11 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
|||||||
if !equalStringMap(o.Headers, n.Headers) {
|
if !equalStringMap(o.Headers, n.Headers) {
|
||||||
changes = append(changes, fmt.Sprintf("claude[%d].headers: updated", i))
|
changes = append(changes, fmt.Sprintf("claude[%d].headers: updated", i))
|
||||||
}
|
}
|
||||||
|
oldExcluded := summarizeExcludedModels(o.ExcludedModels)
|
||||||
|
newExcluded := summarizeExcludedModels(n.ExcludedModels)
|
||||||
|
if oldExcluded.hash != newExcluded.hash {
|
||||||
|
changes = append(changes, fmt.Sprintf("claude[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1517,8 +1756,17 @@ func buildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
|
|||||||
if !equalStringMap(o.Headers, n.Headers) {
|
if !equalStringMap(o.Headers, n.Headers) {
|
||||||
changes = append(changes, fmt.Sprintf("codex[%d].headers: updated", i))
|
changes = append(changes, fmt.Sprintf("codex[%d].headers: updated", i))
|
||||||
}
|
}
|
||||||
|
oldExcluded := summarizeExcludedModels(o.ExcludedModels)
|
||||||
|
newExcluded := summarizeExcludedModels(n.ExcludedModels)
|
||||||
|
if oldExcluded.hash != newExcluded.hash {
|
||||||
|
changes = append(changes, fmt.Sprintf("codex[%d].excluded-models: updated (%d -> %d entries)", i, oldExcluded.count, newExcluded.count))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if entries, _ := diffOAuthExcludedModelChanges(oldCfg.OAuthExcludedModels, newCfg.OAuthExcludedModels); len(entries) > 0 {
|
||||||
|
changes = append(changes, entries...)
|
||||||
|
}
|
||||||
|
|
||||||
// Remote management (never print the key)
|
// Remote management (never print the key)
|
||||||
if oldCfg.RemoteManagement.AllowRemote != newCfg.RemoteManagement.AllowRemote {
|
if oldCfg.RemoteManagement.AllowRemote != newCfg.RemoteManagement.AllowRemote {
|
||||||
|
|||||||
@@ -1118,6 +1118,14 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli
|
|||||||
}
|
}
|
||||||
authCopy := selected.Clone()
|
authCopy := selected.Clone()
|
||||||
m.mu.RUnlock()
|
m.mu.RUnlock()
|
||||||
|
if !selected.indexAssigned {
|
||||||
|
m.mu.Lock()
|
||||||
|
if current := m.auths[authCopy.ID]; current != nil && !current.indexAssigned {
|
||||||
|
current.EnsureIndex()
|
||||||
|
authCopy = current.Clone()
|
||||||
|
}
|
||||||
|
m.mu.Unlock()
|
||||||
|
}
|
||||||
return authCopy, executor, nil
|
return authCopy, executor, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -146,6 +146,27 @@ func (s *Service) consumeAuthUpdates(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) emitAuthUpdate(ctx context.Context, update watcher.AuthUpdate) {
|
||||||
|
if s == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if ctx == nil {
|
||||||
|
ctx = context.Background()
|
||||||
|
}
|
||||||
|
if s.watcher != nil && s.watcher.DispatchRuntimeAuthUpdate(update) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if s.authUpdates != nil {
|
||||||
|
select {
|
||||||
|
case s.authUpdates <- update:
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
log.Debugf("auth update queue saturated, applying inline action=%v id=%s", update.Action, update.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.handleAuthUpdate(ctx, update)
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) handleAuthUpdate(ctx context.Context, update watcher.AuthUpdate) {
|
func (s *Service) handleAuthUpdate(ctx context.Context, update watcher.AuthUpdate) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return
|
return
|
||||||
@@ -220,7 +241,11 @@ func (s *Service) wsOnConnected(channelID string) {
|
|||||||
Metadata: map[string]any{"email": channelID}, // metadata drives logging and usage tracking
|
Metadata: map[string]any{"email": channelID}, // metadata drives logging and usage tracking
|
||||||
}
|
}
|
||||||
log.Infof("websocket provider connected: %s", channelID)
|
log.Infof("websocket provider connected: %s", channelID)
|
||||||
s.applyCoreAuthAddOrUpdate(context.Background(), auth)
|
s.emitAuthUpdate(context.Background(), watcher.AuthUpdate{
|
||||||
|
Action: watcher.AuthUpdateActionAdd,
|
||||||
|
ID: auth.ID,
|
||||||
|
Auth: auth,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) wsOnDisconnected(channelID string, reason error) {
|
func (s *Service) wsOnDisconnected(channelID string, reason error) {
|
||||||
@@ -237,7 +262,10 @@ func (s *Service) wsOnDisconnected(channelID string, reason error) {
|
|||||||
log.Infof("websocket provider disconnected: %s", channelID)
|
log.Infof("websocket provider disconnected: %s", channelID)
|
||||||
}
|
}
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
s.applyCoreAuthRemoval(ctx, channelID)
|
s.emitAuthUpdate(ctx, watcher.AuthUpdate{
|
||||||
|
Action: watcher.AuthUpdateActionDelete,
|
||||||
|
ID: channelID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.Auth) {
|
func (s *Service) applyCoreAuthAddOrUpdate(ctx context.Context, auth *coreauth.Auth) {
|
||||||
@@ -617,6 +645,7 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
if a == nil || a.ID == "" {
|
if a == nil || a.ID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
authKind := strings.ToLower(strings.TrimSpace(a.Attributes["auth_kind"]))
|
||||||
if a.Attributes != nil {
|
if a.Attributes != nil {
|
||||||
if v := strings.TrimSpace(a.Attributes["gemini_virtual_primary"]); strings.EqualFold(v, "true") {
|
if v := strings.TrimSpace(a.Attributes["gemini_virtual_primary"]); strings.EqualFold(v, "true") {
|
||||||
GlobalModelRegistry().UnregisterClient(a.ID)
|
GlobalModelRegistry().UnregisterClient(a.ID)
|
||||||
@@ -636,32 +665,57 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
if compatDetected {
|
if compatDetected {
|
||||||
provider = "openai-compatibility"
|
provider = "openai-compatibility"
|
||||||
}
|
}
|
||||||
|
excluded := s.oauthExcludedModels(provider, authKind)
|
||||||
var models []*ModelInfo
|
var models []*ModelInfo
|
||||||
switch provider {
|
switch provider {
|
||||||
case "gemini":
|
case "gemini":
|
||||||
models = registry.GetGeminiModels()
|
models = registry.GetGeminiModels()
|
||||||
|
if entry := s.resolveConfigGeminiKey(a); entry != nil {
|
||||||
|
if authKind == "apikey" {
|
||||||
|
excluded = entry.ExcludedModels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
case "vertex":
|
case "vertex":
|
||||||
// Vertex AI Gemini supports the same model identifiers as Gemini.
|
// Vertex AI Gemini supports the same model identifiers as Gemini.
|
||||||
models = registry.GetGeminiVertexModels()
|
models = registry.GetGeminiVertexModels()
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
case "gemini-cli":
|
case "gemini-cli":
|
||||||
models = registry.GetGeminiCLIModels()
|
models = registry.GetGeminiCLIModels()
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
case "aistudio":
|
case "aistudio":
|
||||||
models = registry.GetAIStudioModels()
|
models = registry.GetAIStudioModels()
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
case "antigravity":
|
case "antigravity":
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
||||||
models = executor.FetchAntigravityModels(ctx, a, s.cfg)
|
models = executor.FetchAntigravityModels(ctx, a, s.cfg)
|
||||||
cancel()
|
cancel()
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
case "claude":
|
case "claude":
|
||||||
models = registry.GetClaudeModels()
|
models = registry.GetClaudeModels()
|
||||||
if entry := s.resolveConfigClaudeKey(a); entry != nil && len(entry.Models) > 0 {
|
if entry := s.resolveConfigClaudeKey(a); entry != nil {
|
||||||
|
if len(entry.Models) > 0 {
|
||||||
models = buildClaudeConfigModels(entry)
|
models = buildClaudeConfigModels(entry)
|
||||||
}
|
}
|
||||||
|
if authKind == "apikey" {
|
||||||
|
excluded = entry.ExcludedModels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
case "codex":
|
case "codex":
|
||||||
models = registry.GetOpenAIModels()
|
models = registry.GetOpenAIModels()
|
||||||
|
if entry := s.resolveConfigCodexKey(a); entry != nil {
|
||||||
|
if authKind == "apikey" {
|
||||||
|
excluded = entry.ExcludedModels
|
||||||
|
}
|
||||||
|
}
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
case "qwen":
|
case "qwen":
|
||||||
models = registry.GetQwenModels()
|
models = registry.GetQwenModels()
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
case "iflow":
|
case "iflow":
|
||||||
models = registry.GetIFlowModels()
|
models = registry.GetIFlowModels()
|
||||||
|
models = applyExcludedModels(models, excluded)
|
||||||
default:
|
default:
|
||||||
// Handle OpenAI-compatibility providers by name using config
|
// Handle OpenAI-compatibility providers by name using config
|
||||||
if s.cfg != nil {
|
if s.cfg != nil {
|
||||||
@@ -749,7 +803,10 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
|
|||||||
key = strings.ToLower(strings.TrimSpace(a.Provider))
|
key = strings.ToLower(strings.TrimSpace(a.Provider))
|
||||||
}
|
}
|
||||||
GlobalModelRegistry().RegisterClient(a.ID, key, models)
|
GlobalModelRegistry().RegisterClient(a.ID, key, models)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
GlobalModelRegistry().UnregisterClient(a.ID)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey {
|
func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey {
|
||||||
@@ -791,6 +848,150 @@ func (s *Service) resolveConfigClaudeKey(auth *coreauth.Auth) *config.ClaudeKey
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveConfigGeminiKey(auth *coreauth.Auth) *config.GeminiKey {
|
||||||
|
if auth == nil || s.cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var attrKey, attrBase string
|
||||||
|
if auth.Attributes != nil {
|
||||||
|
attrKey = strings.TrimSpace(auth.Attributes["api_key"])
|
||||||
|
attrBase = strings.TrimSpace(auth.Attributes["base_url"])
|
||||||
|
}
|
||||||
|
for i := range s.cfg.GeminiKey {
|
||||||
|
entry := &s.cfg.GeminiKey[i]
|
||||||
|
cfgKey := strings.TrimSpace(entry.APIKey)
|
||||||
|
cfgBase := strings.TrimSpace(entry.BaseURL)
|
||||||
|
if attrKey != "" && strings.EqualFold(cfgKey, attrKey) {
|
||||||
|
if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) resolveConfigCodexKey(auth *coreauth.Auth) *config.CodexKey {
|
||||||
|
if auth == nil || s.cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var attrKey, attrBase string
|
||||||
|
if auth.Attributes != nil {
|
||||||
|
attrKey = strings.TrimSpace(auth.Attributes["api_key"])
|
||||||
|
attrBase = strings.TrimSpace(auth.Attributes["base_url"])
|
||||||
|
}
|
||||||
|
for i := range s.cfg.CodexKey {
|
||||||
|
entry := &s.cfg.CodexKey[i]
|
||||||
|
cfgKey := strings.TrimSpace(entry.APIKey)
|
||||||
|
cfgBase := strings.TrimSpace(entry.BaseURL)
|
||||||
|
if attrKey != "" && strings.EqualFold(cfgKey, attrKey) {
|
||||||
|
if cfgBase == "" || strings.EqualFold(cfgBase, attrBase) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if attrKey == "" && attrBase != "" && strings.EqualFold(cfgBase, attrBase) {
|
||||||
|
return entry
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) oauthExcludedModels(provider, authKind string) []string {
|
||||||
|
cfg := s.cfg
|
||||||
|
if cfg == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
authKindKey := strings.ToLower(strings.TrimSpace(authKind))
|
||||||
|
providerKey := strings.ToLower(strings.TrimSpace(provider))
|
||||||
|
if authKindKey == "apikey" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return cfg.OAuthExcludedModels[providerKey]
|
||||||
|
}
|
||||||
|
|
||||||
|
func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo {
|
||||||
|
if len(models) == 0 || len(excluded) == 0 {
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
patterns := make([]string, 0, len(excluded))
|
||||||
|
for _, item := range excluded {
|
||||||
|
if trimmed := strings.TrimSpace(item); trimmed != "" {
|
||||||
|
patterns = append(patterns, strings.ToLower(trimmed))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(patterns) == 0 {
|
||||||
|
return models
|
||||||
|
}
|
||||||
|
|
||||||
|
filtered := make([]*ModelInfo, 0, len(models))
|
||||||
|
for _, model := range models {
|
||||||
|
if model == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
modelID := strings.ToLower(strings.TrimSpace(model.ID))
|
||||||
|
blocked := false
|
||||||
|
for _, pattern := range patterns {
|
||||||
|
if matchWildcard(pattern, modelID) {
|
||||||
|
blocked = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !blocked {
|
||||||
|
filtered = append(filtered, model)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filtered
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchWildcard performs case-insensitive wildcard matching where '*' matches any substring.
|
||||||
|
func matchWildcard(pattern, value string) bool {
|
||||||
|
if pattern == "" {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast path for exact match (no wildcard present).
|
||||||
|
if !strings.Contains(pattern, "*") {
|
||||||
|
return pattern == value
|
||||||
|
}
|
||||||
|
|
||||||
|
parts := strings.Split(pattern, "*")
|
||||||
|
// Handle prefix.
|
||||||
|
if prefix := parts[0]; prefix != "" {
|
||||||
|
if !strings.HasPrefix(value, prefix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
value = value[len(prefix):]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle suffix.
|
||||||
|
if suffix := parts[len(parts)-1]; suffix != "" {
|
||||||
|
if !strings.HasSuffix(value, suffix) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
value = value[:len(value)-len(suffix)]
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle middle segments in order.
|
||||||
|
for i := 1; i < len(parts)-1; i++ {
|
||||||
|
segment := parts[i]
|
||||||
|
if segment == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
idx := strings.Index(value, segment)
|
||||||
|
if idx < 0 {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
value = value[idx+len(segment):]
|
||||||
|
}
|
||||||
|
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func buildClaudeConfigModels(entry *config.ClaudeKey) []*ModelInfo {
|
func buildClaudeConfigModels(entry *config.ClaudeKey) []*ModelInfo {
|
||||||
if entry == nil || len(entry.Models) == 0 {
|
if entry == nil || len(entry.Models) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -86,6 +86,7 @@ type WatcherWrapper struct {
|
|||||||
setConfig func(cfg *config.Config)
|
setConfig func(cfg *config.Config)
|
||||||
snapshotAuths func() []*coreauth.Auth
|
snapshotAuths func() []*coreauth.Auth
|
||||||
setUpdateQueue func(queue chan<- watcher.AuthUpdate)
|
setUpdateQueue func(queue chan<- watcher.AuthUpdate)
|
||||||
|
dispatchRuntimeUpdate func(update watcher.AuthUpdate) bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start proxies to the underlying watcher Start implementation.
|
// Start proxies to the underlying watcher Start implementation.
|
||||||
@@ -112,6 +113,16 @@ func (w *WatcherWrapper) SetConfig(cfg *config.Config) {
|
|||||||
w.setConfig(cfg)
|
w.setConfig(cfg)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// DispatchRuntimeAuthUpdate forwards runtime auth updates (e.g., websocket providers)
|
||||||
|
// into the watcher-managed auth update queue when available.
|
||||||
|
// Returns true if the update was enqueued successfully.
|
||||||
|
func (w *WatcherWrapper) DispatchRuntimeAuthUpdate(update watcher.AuthUpdate) bool {
|
||||||
|
if w == nil || w.dispatchRuntimeUpdate == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return w.dispatchRuntimeUpdate(update)
|
||||||
|
}
|
||||||
|
|
||||||
// SetClients updates the watcher file-backed clients registry.
|
// SetClients updates the watcher file-backed clients registry.
|
||||||
// SetClients and SetAPIKeyClients removed; watcher manages its own caches
|
// SetClients and SetAPIKeyClients removed; watcher manages its own caches
|
||||||
|
|
||||||
|
|||||||
@@ -28,5 +28,8 @@ func defaultWatcherFactory(configPath, authDir string, reload func(*config.Confi
|
|||||||
setUpdateQueue: func(queue chan<- watcher.AuthUpdate) {
|
setUpdateQueue: func(queue chan<- watcher.AuthUpdate) {
|
||||||
w.SetAuthUpdateQueue(queue)
|
w.SetAuthUpdateQueue(queue)
|
||||||
},
|
},
|
||||||
|
dispatchRuntimeUpdate: func(update watcher.AuthUpdate) bool {
|
||||||
|
return w.DispatchRuntimeAuthUpdate(update)
|
||||||
|
},
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user