Merge branch 'dev' into watcher

This commit is contained in:
Luis Pater
2025-12-17 01:48:11 +08:00
27 changed files with 422 additions and 198 deletions

View File

@@ -48,6 +48,9 @@ usage-statistics-enabled: false
# Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/ # Proxy URL. Supports socks5/http/https protocols. Example: socks5://user:pass@192.168.1.1:1080/
proxy-url: "" proxy-url: ""
# When true, unprefixed model requests only use credentials without a prefix (except when prefix == model name).
force-model-prefix: false
# Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504. # Number of times to retry a request. Retries will occur if the HTTP response code is 403, 408, 500, 502, 503, or 504.
request-retry: 3 request-retry: 3
@@ -65,6 +68,7 @@ ws-auth: false
# Gemini API keys # Gemini API keys
# gemini-api-key: # gemini-api-key:
# - api-key: "AIzaSy...01" # - api-key: "AIzaSy...01"
# prefix: "test" # optional: require calls like "test/gemini-3-pro-preview" to target this credential
# base-url: "https://generativelanguage.googleapis.com" # base-url: "https://generativelanguage.googleapis.com"
# headers: # headers:
# X-Custom-Header: "custom-value" # X-Custom-Header: "custom-value"
@@ -79,6 +83,7 @@ ws-auth: false
# Codex API keys # Codex API keys
# codex-api-key: # codex-api-key:
# - api-key: "sk-atSM..." # - api-key: "sk-atSM..."
# prefix: "test" # optional: require calls like "test/gpt-5-codex" to target this credential
# 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"
@@ -93,6 +98,7 @@ ws-auth: false
# 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..."
# prefix: "test" # optional: require calls like "test/claude-sonnet-latest" to target this credential
# 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"
@@ -109,6 +115,7 @@ ws-auth: false
# 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.
# prefix: "test" # optional: require calls like "test/kimi-k2" to target this provider's credentials
# 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"
@@ -123,6 +130,7 @@ ws-auth: false
# 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
# prefix: "test" # optional: require calls like "test/vertex-pro" to target this credential
# 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:

View File

@@ -146,6 +146,9 @@ func TestAmpModule_OnConfigUpdated_CacheInvalidation(t *testing.T) {
m := &AmpModule{enabled: true} m := &AmpModule{enabled: true}
ms := NewMultiSourceSecretWithPath("", p, time.Minute) ms := NewMultiSourceSecretWithPath("", p, time.Minute)
m.secretSource = ms m.secretSource = ms
m.lastConfig = &config.AmpCode{
UpstreamAPIKey: "old-key",
}
// Warm the cache // Warm the cache
if _, err := ms.Get(context.Background()); err != nil { if _, err := ms.Get(context.Background()); err != nil {
@@ -157,7 +160,7 @@ func TestAmpModule_OnConfigUpdated_CacheInvalidation(t *testing.T) {
} }
// Update config - should invalidate cache // Update config - should invalidate cache
if err := m.OnConfigUpdated(&config.Config{AmpCode: config.AmpCode{UpstreamURL: "http://x"}}); err != nil { if err := m.OnConfigUpdated(&config.Config{AmpCode: config.AmpCode{UpstreamURL: "http://x", UpstreamAPIKey: "new-key"}}); err != nil {
t.Fatal(err) t.Fatal(err)
} }

View File

@@ -267,7 +267,7 @@ func (m *AmpModule) registerProviderAliases(engine *gin.Engine, baseHandler *han
v1betaAmp := provider.Group("/v1beta") v1betaAmp := provider.Group("/v1beta")
{ {
v1betaAmp.GET("/models", geminiHandlers.GeminiModels) v1betaAmp.GET("/models", geminiHandlers.GeminiModels)
v1betaAmp.POST("/models/:action", fallbackHandler.WrapHandler(geminiHandlers.GeminiHandler)) v1betaAmp.POST("/models/*action", fallbackHandler.WrapHandler(geminiHandlers.GeminiHandler))
v1betaAmp.GET("/models/:action", geminiHandlers.GeminiGetHandler) v1betaAmp.GET("/models/*action", geminiHandlers.GeminiGetHandler)
} }
} }

View File

@@ -32,7 +32,9 @@ func TestRegisterManagementRoutes(t *testing.T) {
m.setProxy(proxy) m.setProxy(proxy)
base := &handlers.BaseAPIHandler{} base := &handlers.BaseAPIHandler{}
m.registerManagementRoutes(r, base) m.registerManagementRoutes(r, base, nil)
srv := httptest.NewServer(r)
defer srv.Close()
managementPaths := []struct { managementPaths := []struct {
path string path string
@@ -63,11 +65,17 @@ func TestRegisterManagementRoutes(t *testing.T) {
for _, path := range managementPaths { for _, path := range managementPaths {
t.Run(path.path, func(t *testing.T) { t.Run(path.path, func(t *testing.T) {
proxyCalled = false proxyCalled = false
req := httptest.NewRequest(path.method, path.path, nil) req, err := http.NewRequest(path.method, srv.URL+path.path, nil)
w := httptest.NewRecorder() if err != nil {
r.ServeHTTP(w, req) t.Fatalf("failed to build request: %v", err)
}
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatalf("request failed: %v", err)
}
defer resp.Body.Close()
if w.Code == http.StatusNotFound { if resp.StatusCode == http.StatusNotFound {
t.Fatalf("route %s not registered", path.path) t.Fatalf("route %s not registered", path.path)
} }
if !proxyCalled { if !proxyCalled {

View File

@@ -230,13 +230,9 @@ func NewServer(cfg *config.Config, authManager *auth.Manager, accessManager *sdk
envManagementSecret := envAdminPasswordSet && envAdminPassword != "" envManagementSecret := envAdminPasswordSet && envAdminPassword != ""
// Create server instance // Create server instance
providerNames := make([]string, 0, len(cfg.OpenAICompatibility))
for _, p := range cfg.OpenAICompatibility {
providerNames = append(providerNames, p.Name)
}
s := &Server{ s := &Server{
engine: engine, engine: engine,
handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager, providerNames), handlers: handlers.NewBaseAPIHandlers(&cfg.SDKConfig, authManager),
cfg: cfg, cfg: cfg,
accessManager: accessManager, accessManager: accessManager,
requestLogger: requestLogger, requestLogger: requestLogger,
@@ -334,8 +330,8 @@ func (s *Server) setupRoutes() {
v1beta.Use(AuthMiddleware(s.accessManager)) v1beta.Use(AuthMiddleware(s.accessManager))
{ {
v1beta.GET("/models", geminiHandlers.GeminiModels) v1beta.GET("/models", geminiHandlers.GeminiModels)
v1beta.POST("/models/:action", geminiHandlers.GeminiHandler) v1beta.POST("/models/*action", geminiHandlers.GeminiHandler)
v1beta.GET("/models/:action", geminiHandlers.GeminiGetHandler) v1beta.GET("/models/*action", geminiHandlers.GeminiGetHandler)
} }
// Root endpoint // Root endpoint
@@ -919,12 +915,6 @@ func (s *Server) UpdateClients(cfg *config.Config) {
// Save YAML snapshot for next comparison // Save YAML snapshot for next comparison
s.oldConfigYaml, _ = yaml.Marshal(cfg) s.oldConfigYaml, _ = yaml.Marshal(cfg)
providerNames := make([]string, 0, len(cfg.OpenAICompatibility))
for _, p := range cfg.OpenAICompatibility {
providerNames = append(providerNames, p.Name)
}
s.handlers.OpenAICompatProviders = providerNames
s.handlers.UpdateClients(&cfg.SDKConfig) s.handlers.UpdateClients(&cfg.SDKConfig)
if !cfg.RemoteManagement.DisableControlPanel { if !cfg.RemoteManagement.DisableControlPanel {

View File

@@ -187,6 +187,9 @@ type ClaudeKey struct {
// APIKey is the authentication key for accessing Claude API services. // APIKey is the authentication key for accessing Claude API services.
APIKey string `yaml:"api-key" json:"api-key"` APIKey string `yaml:"api-key" json:"api-key"`
// Prefix optionally namespaces models for this credential (e.g., "teamA/claude-sonnet-4").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the Claude API endpoint. // BaseURL is the base URL for the Claude API endpoint.
// If empty, the default Claude API URL will be used. // If empty, the default Claude API URL will be used.
BaseURL string `yaml:"base-url" json:"base-url"` BaseURL string `yaml:"base-url" json:"base-url"`
@@ -219,6 +222,9 @@ type CodexKey struct {
// APIKey is the authentication key for accessing Codex API services. // APIKey is the authentication key for accessing Codex API services.
APIKey string `yaml:"api-key" json:"api-key"` APIKey string `yaml:"api-key" json:"api-key"`
// Prefix optionally namespaces models for this credential (e.g., "teamA/gpt-5-codex").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the Codex API endpoint. // BaseURL is the base URL for the Codex API endpoint.
// If empty, the default Codex API URL will be used. // If empty, the default Codex API URL will be used.
BaseURL string `yaml:"base-url" json:"base-url"` BaseURL string `yaml:"base-url" json:"base-url"`
@@ -239,6 +245,9 @@ type GeminiKey struct {
// APIKey is the authentication key for accessing Gemini API services. // APIKey is the authentication key for accessing Gemini API services.
APIKey string `yaml:"api-key" json:"api-key"` APIKey string `yaml:"api-key" json:"api-key"`
// Prefix optionally namespaces models for this credential (e.g., "teamA/gemini-3-pro-preview").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL optionally overrides the Gemini API endpoint. // BaseURL optionally overrides the Gemini API endpoint.
BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"` BaseURL string `yaml:"base-url,omitempty" json:"base-url,omitempty"`
@@ -258,6 +267,9 @@ type OpenAICompatibility struct {
// Name is the identifier for this OpenAI compatibility configuration. // Name is the identifier for this OpenAI compatibility configuration.
Name string `yaml:"name" json:"name"` Name string `yaml:"name" json:"name"`
// Prefix optionally namespaces model aliases for this provider (e.g., "teamA/kimi-k2").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// 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"`
@@ -422,6 +434,7 @@ func (cfg *Config) SanitizeOpenAICompatibility() {
for i := range cfg.OpenAICompatibility { for i := range cfg.OpenAICompatibility {
e := cfg.OpenAICompatibility[i] e := cfg.OpenAICompatibility[i]
e.Name = strings.TrimSpace(e.Name) e.Name = strings.TrimSpace(e.Name)
e.Prefix = normalizeModelPrefix(e.Prefix)
e.BaseURL = strings.TrimSpace(e.BaseURL) e.BaseURL = strings.TrimSpace(e.BaseURL)
e.Headers = NormalizeHeaders(e.Headers) e.Headers = NormalizeHeaders(e.Headers)
if e.BaseURL == "" { if e.BaseURL == "" {
@@ -442,6 +455,7 @@ func (cfg *Config) SanitizeCodexKeys() {
out := make([]CodexKey, 0, len(cfg.CodexKey)) out := make([]CodexKey, 0, len(cfg.CodexKey))
for i := range cfg.CodexKey { for i := range cfg.CodexKey {
e := cfg.CodexKey[i] e := cfg.CodexKey[i]
e.Prefix = normalizeModelPrefix(e.Prefix)
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) e.ExcludedModels = NormalizeExcludedModels(e.ExcludedModels)
@@ -460,6 +474,7 @@ func (cfg *Config) SanitizeClaudeKeys() {
} }
for i := range cfg.ClaudeKey { for i := range cfg.ClaudeKey {
entry := &cfg.ClaudeKey[i] entry := &cfg.ClaudeKey[i]
entry.Prefix = normalizeModelPrefix(entry.Prefix)
entry.Headers = NormalizeHeaders(entry.Headers) entry.Headers = NormalizeHeaders(entry.Headers)
entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels) entry.ExcludedModels = NormalizeExcludedModels(entry.ExcludedModels)
} }
@@ -479,6 +494,7 @@ func (cfg *Config) SanitizeGeminiKeys() {
if entry.APIKey == "" { if entry.APIKey == "" {
continue continue
} }
entry.Prefix = normalizeModelPrefix(entry.Prefix)
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)
@@ -492,6 +508,18 @@ func (cfg *Config) SanitizeGeminiKeys() {
cfg.GeminiKey = out cfg.GeminiKey = out
} }
func normalizeModelPrefix(prefix string) string {
trimmed := strings.TrimSpace(prefix)
trimmed = strings.Trim(trimmed, "/")
if trimmed == "" {
return ""
}
if strings.Contains(trimmed, "/") {
return ""
}
return trimmed
}
func syncInlineAccessProvider(cfg *Config) { func syncInlineAccessProvider(cfg *Config) {
if cfg == nil { if cfg == nil {
return return

View File

@@ -13,6 +13,9 @@ type VertexCompatKey struct {
// Maps to the x-goog-api-key header. // Maps to the x-goog-api-key header.
APIKey string `yaml:"api-key" json:"api-key"` APIKey string `yaml:"api-key" json:"api-key"`
// Prefix optionally namespaces model aliases for this credential (e.g., "teamA/vertex-pro").
Prefix string `yaml:"prefix,omitempty" json:"prefix,omitempty"`
// BaseURL is the base URL for the Vertex-compatible API endpoint. // BaseURL is the base URL for the Vertex-compatible API endpoint.
// The executor will append "/v1/publishers/google/models/{model}:action" to this. // The executor will append "/v1/publishers/google/models/{model}:action" to this.
// Example: "https://zenmux.ai/api" becomes "https://zenmux.ai/api/v1/publishers/google/models/..." // Example: "https://zenmux.ai/api" becomes "https://zenmux.ai/api/v1/publishers/google/models/..."
@@ -53,6 +56,7 @@ func (cfg *Config) SanitizeVertexCompatKeys() {
if entry.APIKey == "" { if entry.APIKey == "" {
continue continue
} }
entry.Prefix = normalizeModelPrefix(entry.Prefix)
entry.BaseURL = strings.TrimSpace(entry.BaseURL) entry.BaseURL = strings.TrimSpace(entry.BaseURL)
if entry.BaseURL == "" { if entry.BaseURL == "" {
// BaseURL is required for Vertex API key entries // BaseURL is required for Vertex API key entries

View File

@@ -630,6 +630,13 @@ func GetQwenModels() []*ModelInfo {
} }
} }
// iFlowThinkingSupport is a shared ThinkingSupport configuration for iFlow models
// that support thinking mode via chat_template_kwargs.enable_thinking (boolean toggle).
// Uses level-based configuration so standard normalization flows apply before conversion.
var iFlowThinkingSupport = &ThinkingSupport{
Levels: []string{"none", "auto", "minimal", "low", "medium", "high", "xhigh"},
}
// GetIFlowModels returns supported models for iFlow OAuth accounts. // GetIFlowModels returns supported models for iFlow OAuth accounts.
func GetIFlowModels() []*ModelInfo { func GetIFlowModels() []*ModelInfo {
entries := []struct { entries := []struct {
@@ -645,9 +652,9 @@ func GetIFlowModels() []*ModelInfo {
{ID: "qwen3-vl-plus", DisplayName: "Qwen3-VL-Plus", Description: "Qwen3 multimodal vision-language", Created: 1758672000}, {ID: "qwen3-vl-plus", DisplayName: "Qwen3-VL-Plus", Description: "Qwen3 multimodal vision-language", Created: 1758672000},
{ID: "qwen3-max-preview", DisplayName: "Qwen3-Max-Preview", Description: "Qwen3 Max preview build", Created: 1757030400}, {ID: "qwen3-max-preview", DisplayName: "Qwen3-Max-Preview", Description: "Qwen3 Max preview build", Created: 1757030400},
{ID: "kimi-k2-0905", DisplayName: "Kimi-K2-Instruct-0905", Description: "Moonshot Kimi K2 instruct 0905", Created: 1757030400}, {ID: "kimi-k2-0905", DisplayName: "Kimi-K2-Instruct-0905", Description: "Moonshot Kimi K2 instruct 0905", Created: 1757030400},
{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, Thinking: iFlowThinkingSupport},
{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 thinking model", Created: 1762387200, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}}, {ID: "kimi-k2-thinking", DisplayName: "Kimi-K2-Thinking", Description: "Moonshot Kimi K2 thinking model", Created: 1762387200},
{ID: "deepseek-v3.2-chat", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Chat", Created: 1764576000}, {ID: "deepseek-v3.2-chat", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Chat", Created: 1764576000},
{ID: "deepseek-v3.2-reasoner", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Reasoner", Created: 1764576000}, {ID: "deepseek-v3.2-reasoner", DisplayName: "DeepSeek-V3.2", Description: "DeepSeek V3.2 Reasoner", 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},
@@ -655,10 +662,10 @@ func GetIFlowModels() []*ModelInfo {
{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},
{ID: "deepseek-v3", DisplayName: "DeepSeek-V3-671B", Description: "DeepSeek V3 671B", Created: 1734307200}, {ID: "deepseek-v3", DisplayName: "DeepSeek-V3-671B", Description: "DeepSeek V3 671B", Created: 1734307200},
{ID: "qwen3-32b", DisplayName: "Qwen3-32B", Description: "Qwen3 32B", Created: 1747094400}, {ID: "qwen3-32b", DisplayName: "Qwen3-32B", Description: "Qwen3 32B", Created: 1747094400},
{ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}}, {ID: "qwen3-235b-a22b-thinking-2507", DisplayName: "Qwen3-235B-A22B-Thinking", Description: "Qwen3 235B A22B Thinking (2507)", Created: 1753401600},
{ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600}, {ID: "qwen3-235b-a22b-instruct", DisplayName: "Qwen3-235B-A22B-Instruct", Description: "Qwen3 235B A22B Instruct", Created: 1753401600},
{ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600}, {ID: "qwen3-235b", DisplayName: "Qwen3-235B-A22B", Description: "Qwen3 235B A22B", Created: 1753401600},
{ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000, Thinking: &ThinkingSupport{Levels: []string{"low", "medium", "high"}}}, {ID: "minimax-m2", DisplayName: "MiniMax-M2", Description: "MiniMax M2", Created: 1758672000},
} }
models := make([]*ModelInfo, 0, len(entries)) models := make([]*ModelInfo, 0, len(entries))
for _, entry := range entries { for _, entry := range entries {

View File

@@ -66,6 +66,7 @@ func (e *IFlowExecutor) Execute(ctx context.Context, auth *cliproxyauth.Auth, re
if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil { if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil {
return resp, errValidate return resp, errValidate
} }
body = applyIFlowThinkingConfig(body)
body = applyPayloadConfig(e.cfg, req.Model, body) body = applyPayloadConfig(e.cfg, req.Model, body)
endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint endpoint := strings.TrimSuffix(baseURL, "/") + iflowDefaultEndpoint
@@ -157,6 +158,7 @@ func (e *IFlowExecutor) ExecuteStream(ctx context.Context, auth *cliproxyauth.Au
if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil { if errValidate := ValidateThinkingConfig(body, upstreamModel); errValidate != nil {
return nil, errValidate return nil, errValidate
} }
body = applyIFlowThinkingConfig(body)
// Ensure tools array exists to avoid provider quirks similar to Qwen's behaviour. // Ensure tools array exists to avoid provider quirks similar to Qwen's behaviour.
toolsResult := gjson.GetBytes(body, "tools") toolsResult := gjson.GetBytes(body, "tools")
if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 { if toolsResult.Exists() && toolsResult.IsArray() && len(toolsResult.Array()) == 0 {
@@ -442,3 +444,21 @@ func ensureToolsArray(body []byte) []byte {
} }
return updated return updated
} }
// applyIFlowThinkingConfig converts normalized reasoning_effort to iFlow chat_template_kwargs.enable_thinking.
// This should be called after NormalizeThinkingConfig has processed the payload.
// iFlow only supports boolean enable_thinking, so any non-"none" effort enables thinking.
func applyIFlowThinkingConfig(body []byte) []byte {
effort := gjson.GetBytes(body, "reasoning_effort")
if !effort.Exists() {
return body
}
val := strings.ToLower(strings.TrimSpace(effort.String()))
enableThinking := val != "none" && val != ""
body, _ = sjson.DeleteBytes(body, "reasoning_effort")
body, _ = sjson.SetBytes(body, "chat_template_kwargs.enable_thinking", enableThinking)
return body
}

View File

@@ -72,13 +72,7 @@ func ApplyReasoningEffortMetadata(payload []byte, metadata map[string]any, model
// Fallback: numeric thinking_budget suffix for level-based (OpenAI-style) models. // Fallback: numeric thinking_budget suffix for level-based (OpenAI-style) models.
if util.ModelUsesThinkingLevels(baseModel) || allowCompat { if util.ModelUsesThinkingLevels(baseModel) || allowCompat {
if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil { if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {
if effort, ok := util.OpenAIThinkingBudgetToEffort(baseModel, *budget); ok && effort != "" { if effort, ok := util.ThinkingBudgetToEffort(baseModel, *budget); ok && effort != "" {
if *budget == 0 && effort == "none" && util.ModelUsesThinkingLevels(baseModel) {
if _, supported := util.NormalizeReasoningEffortLevel(baseModel, effort); !supported {
return StripThinkingFields(payload, false)
}
}
if updated, err := sjson.SetBytes(payload, field, effort); err == nil { if updated, err := sjson.SetBytes(payload, field, effort); err == nil {
return updated return updated
} }
@@ -273,7 +267,7 @@ func StripThinkingFields(payload []byte, effortOnly bool) []byte {
"reasoning.effort", "reasoning.effort",
} }
if !effortOnly { if !effortOnly {
fieldsToRemove = append([]string{"reasoning"}, fieldsToRemove...) fieldsToRemove = append([]string{"reasoning", "thinking"}, fieldsToRemove...)
} }
out := payload out := payload
for _, field := range fieldsToRemove { for _, field := range fieldsToRemove {

View File

@@ -219,15 +219,20 @@ func ConvertClaudeRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
// Convert thinking.budget_tokens to reasoning.effort for level-based models // Convert thinking.budget_tokens to reasoning.effort for level-based models
reasoningEffort := "medium" // default reasoningEffort := "medium" // default
if thinking := rootResult.Get("thinking"); thinking.Exists() && thinking.IsObject() { if thinking := rootResult.Get("thinking"); thinking.Exists() && thinking.IsObject() {
if thinking.Get("type").String() == "enabled" { switch thinking.Get("type").String() {
case "enabled":
if util.ModelUsesThinkingLevels(modelName) { if util.ModelUsesThinkingLevels(modelName) {
if budgetTokens := thinking.Get("budget_tokens"); budgetTokens.Exists() { if budgetTokens := thinking.Get("budget_tokens"); budgetTokens.Exists() {
budget := int(budgetTokens.Int()) budget := int(budgetTokens.Int())
if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" { if effort, ok := util.ThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
reasoningEffort = effort reasoningEffort = effort
} }
} }
} }
case "disabled":
if effort, ok := util.ThinkingBudgetToEffort(modelName, 0); ok && effort != "" {
reasoningEffort = effort
}
} }
} }
template, _ = sjson.Set(template, "reasoning.effort", reasoningEffort) template, _ = sjson.Set(template, "reasoning.effort", reasoningEffort)

View File

@@ -253,7 +253,7 @@ func ConvertGeminiRequestToCodex(modelName string, inputRawJSON []byte, _ bool)
if util.ModelUsesThinkingLevels(modelName) { if util.ModelUsesThinkingLevels(modelName) {
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
budget := int(thinkingBudget.Int()) budget := int(thinkingBudget.Int())
if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" { if effort, ok := util.ThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
reasoningEffort = effort reasoningEffort = effort
} }
} }

View File

@@ -63,10 +63,22 @@ func ConvertClaudeRequestToOpenAI(modelName string, inputRawJSON []byte, stream
// Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort // Thinking: Convert Claude thinking.budget_tokens to OpenAI reasoning_effort
if thinking := root.Get("thinking"); thinking.Exists() && thinking.IsObject() { if thinking := root.Get("thinking"); thinking.Exists() && thinking.IsObject() {
if thinkingType := thinking.Get("type"); thinkingType.Exists() && thinkingType.String() == "enabled" { if thinkingType := thinking.Get("type"); thinkingType.Exists() {
if budgetTokens := thinking.Get("budget_tokens"); budgetTokens.Exists() { switch thinkingType.String() {
budget := int(budgetTokens.Int()) case "enabled":
if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" { if budgetTokens := thinking.Get("budget_tokens"); budgetTokens.Exists() {
budget := int(budgetTokens.Int())
if effort, ok := util.ThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
} else {
// No budget_tokens specified, default to "auto" for enabled thinking
if effort, ok := util.ThinkingBudgetToEffort(modelName, -1); ok && effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort)
}
}
case "disabled":
if effort, ok := util.ThinkingBudgetToEffort(modelName, 0); ok && effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort) out, _ = sjson.Set(out, "reasoning_effort", effort)
} }
} }

View File

@@ -83,7 +83,7 @@ func ConvertGeminiRequestToOpenAI(modelName string, inputRawJSON []byte, stream
if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() { if thinkingConfig := genConfig.Get("thinkingConfig"); thinkingConfig.Exists() && thinkingConfig.IsObject() {
if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() { if thinkingBudget := thinkingConfig.Get("thinkingBudget"); thinkingBudget.Exists() {
budget := int(thinkingBudget.Int()) budget := int(thinkingBudget.Int())
if effort, ok := util.OpenAIThinkingBudgetToEffort(modelName, budget); ok && effort != "" { if effort, ok := util.ThinkingBudgetToEffort(modelName, budget); ok && effort != "" {
out, _ = sjson.Set(out, "reasoning_effort", effort) out, _ = sjson.Set(out, "reasoning_effort", effort)
} }
} }

View File

@@ -1,37 +0,0 @@
package util
// OpenAIThinkingBudgetToEffort maps a numeric thinking budget (tokens)
// into an OpenAI-style reasoning effort level for level-based models.
//
// Ranges:
// - 0 -> "none"
// - -1 -> "auto"
// - 1..1024 -> "low"
// - 1025..8192 -> "medium"
// - 8193..24576 -> "high"
// - 24577.. -> highest supported level for the model (defaults to "xhigh")
//
// Negative values other than -1 are treated as unsupported.
func OpenAIThinkingBudgetToEffort(model string, budget int) (string, bool) {
switch {
case budget == -1:
return "auto", true
case budget < -1:
return "", false
case budget == 0:
return "none", true
case budget > 0 && budget <= 1024:
return "low", true
case budget <= 8192:
return "medium", true
case budget <= 24576:
return "high", true
case budget > 24576:
if levels := GetModelThinkingLevels(model); len(levels) > 0 {
return levels[len(levels)-1], true
}
return "xhigh", true
default:
return "", false
}
}

View File

@@ -118,3 +118,83 @@ func IsOpenAICompatibilityModel(model string) bool {
} }
return strings.EqualFold(strings.TrimSpace(info.Type), "openai-compatibility") return strings.EqualFold(strings.TrimSpace(info.Type), "openai-compatibility")
} }
// ThinkingEffortToBudget maps a reasoning effort level to a numeric thinking budget (tokens),
// clamping the result to the model's supported range.
//
// Mappings (values are normalized to model's supported range):
// - "none" -> 0
// - "auto" -> -1
// - "minimal" -> 512
// - "low" -> 1024
// - "medium" -> 8192
// - "high" -> 24576
// - "xhigh" -> 32768
//
// Returns false when the effort level is empty or unsupported.
func ThinkingEffortToBudget(model, effort string) (int, bool) {
if effort == "" {
return 0, false
}
normalized, ok := NormalizeReasoningEffortLevel(model, effort)
if !ok {
normalized = strings.ToLower(strings.TrimSpace(effort))
}
switch normalized {
case "none":
return 0, true
case "auto":
return NormalizeThinkingBudget(model, -1), true
case "minimal":
return NormalizeThinkingBudget(model, 512), true
case "low":
return NormalizeThinkingBudget(model, 1024), true
case "medium":
return NormalizeThinkingBudget(model, 8192), true
case "high":
return NormalizeThinkingBudget(model, 24576), true
case "xhigh":
return NormalizeThinkingBudget(model, 32768), true
default:
return 0, false
}
}
// ThinkingBudgetToEffort maps a numeric thinking budget (tokens)
// to a reasoning effort level for level-based models.
//
// Mappings:
// - 0 -> "none" (or lowest supported level if model doesn't support "none")
// - -1 -> "auto"
// - 1..1024 -> "low"
// - 1025..8192 -> "medium"
// - 8193..24576 -> "high"
// - 24577.. -> highest supported level for the model (defaults to "xhigh")
//
// Returns false when the budget is unsupported (negative values other than -1).
func ThinkingBudgetToEffort(model string, budget int) (string, bool) {
switch {
case budget == -1:
return "auto", true
case budget < -1:
return "", false
case budget == 0:
if levels := GetModelThinkingLevels(model); len(levels) > 0 {
return levels[0], true
}
return "none", true
case budget > 0 && budget <= 1024:
return "low", true
case budget <= 8192:
return "medium", true
case budget <= 24576:
return "high", true
case budget > 24576:
if levels := GetModelThinkingLevels(model); len(levels) > 0 {
return levels[len(levels)-1], true
}
return "xhigh", true
default:
return "", false
}
}

View File

@@ -201,36 +201,6 @@ func ReasoningEffortFromMetadata(metadata map[string]any) (string, bool) {
return "", true return "", true
} }
// ThinkingEffortToBudget maps reasoning effort levels to approximate budgets,
// clamping the result to the model's supported range.
func ThinkingEffortToBudget(model, effort string) (int, bool) {
if effort == "" {
return 0, false
}
normalized, ok := NormalizeReasoningEffortLevel(model, effort)
if !ok {
normalized = strings.ToLower(strings.TrimSpace(effort))
}
switch normalized {
case "none":
return 0, true
case "auto":
return NormalizeThinkingBudget(model, -1), true
case "minimal":
return NormalizeThinkingBudget(model, 512), true
case "low":
return NormalizeThinkingBudget(model, 1024), true
case "medium":
return NormalizeThinkingBudget(model, 8192), true
case "high":
return NormalizeThinkingBudget(model, 24576), true
case "xhigh":
return NormalizeThinkingBudget(model, 32768), true
default:
return 0, false
}
}
// ResolveOriginalModel returns the original model name stored in metadata (if present), // ResolveOriginalModel returns the original model name stored in metadata (if present),
// otherwise falls back to the provided model. // otherwise falls back to the provided model.
func ResolveOriginalModel(model string, metadata map[string]any) string { func ResolveOriginalModel(model string, metadata map[string]any) string {

View File

@@ -51,6 +51,9 @@ func BuildConfigChangeDetails(oldCfg, newCfg *config.Config) []string {
if oldCfg.WebsocketAuth != newCfg.WebsocketAuth { if oldCfg.WebsocketAuth != newCfg.WebsocketAuth {
changes = append(changes, fmt.Sprintf("ws-auth: %t -> %t", oldCfg.WebsocketAuth, newCfg.WebsocketAuth)) changes = append(changes, fmt.Sprintf("ws-auth: %t -> %t", oldCfg.WebsocketAuth, newCfg.WebsocketAuth))
} }
if oldCfg.ForceModelPrefix != newCfg.ForceModelPrefix {
changes = append(changes, fmt.Sprintf("force-model-prefix: %t -> %t", oldCfg.ForceModelPrefix, newCfg.ForceModelPrefix))
}
// Quota-exceeded behavior // Quota-exceeded behavior
if oldCfg.QuotaExceeded.SwitchProject != newCfg.QuotaExceeded.SwitchProject { if oldCfg.QuotaExceeded.SwitchProject != newCfg.QuotaExceeded.SwitchProject {

View File

@@ -184,7 +184,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, nil) w.reloadClients(true, nil, false)
return nil return nil
} }
@@ -277,7 +277,7 @@ func (w *Watcher) DispatchRuntimeAuthUpdate(update AuthUpdate) bool {
return true return true
} }
func (w *Watcher) refreshAuthState() { func (w *Watcher) refreshAuthState(force bool) {
auths := w.SnapshotCoreAuths() auths := w.SnapshotCoreAuths()
w.clientsMutex.Lock() w.clientsMutex.Lock()
if len(w.runtimeAuths) > 0 { if len(w.runtimeAuths) > 0 {
@@ -287,12 +287,12 @@ func (w *Watcher) refreshAuthState() {
} }
} }
} }
updates := w.prepareAuthUpdatesLocked(auths) updates := w.prepareAuthUpdatesLocked(auths, force)
w.clientsMutex.Unlock() w.clientsMutex.Unlock()
w.dispatchAuthUpdates(updates) w.dispatchAuthUpdates(updates)
} }
func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth) []AuthUpdate { func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth, force bool) []AuthUpdate {
newState := make(map[string]*coreauth.Auth, len(auths)) newState := make(map[string]*coreauth.Auth, len(auths))
for _, auth := range auths { for _, auth := range auths {
if auth == nil || auth.ID == "" { if auth == nil || auth.ID == "" {
@@ -319,7 +319,7 @@ func (w *Watcher) prepareAuthUpdatesLocked(auths []*coreauth.Auth) []AuthUpdate
for id, auth := range newState { for id, auth := range newState {
if existing, ok := w.currentAuths[id]; !ok { if existing, ok := w.currentAuths[id]; !ok {
updates = append(updates, AuthUpdate{Action: AuthUpdateActionAdd, ID: id, Auth: auth.Clone()}) updates = append(updates, AuthUpdate{Action: AuthUpdateActionAdd, ID: id, Auth: auth.Clone()})
} else if !authEqual(existing, auth) { } else if force || !authEqual(existing, auth) {
updates = append(updates, AuthUpdate{Action: AuthUpdateActionModify, ID: id, Auth: auth.Clone()}) updates = append(updates, AuthUpdate{Action: AuthUpdateActionModify, ID: id, Auth: auth.Clone()})
} }
} }
@@ -786,15 +786,16 @@ func (w *Watcher) reloadConfig() bool {
} }
authDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir authDirChanged := oldConfig == nil || oldConfig.AuthDir != newConfig.AuthDir
forceAuthRefresh := oldConfig != nil && oldConfig.ForceModelPrefix != newConfig.ForceModelPrefix
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, affectedOAuthProviders) w.reloadClients(authDirChanged, affectedOAuthProviders, forceAuthRefresh)
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, affectedOAuthProviders []string) { func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string, forceAuthRefresh bool) {
log.Debugf("starting full client load process") log.Debugf("starting full client load process")
w.clientsMutex.RLock() w.clientsMutex.RLock()
@@ -885,7 +886,7 @@ func (w *Watcher) reloadClients(rescanAuth bool, affectedOAuthProviders []string
w.reloadCallback(cfg) w.reloadCallback(cfg)
} }
w.refreshAuthState() w.refreshAuthState(forceAuthRefresh)
log.Infof("full client load complete - %d clients (%d auth files + %d Gemini API keys + %d Vertex API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)", log.Infof("full client load complete - %d clients (%d auth files + %d Gemini API keys + %d Vertex API keys + %d Claude API keys + %d Codex keys + %d OpenAI-compat)",
totalNewClients, totalNewClients,
@@ -936,7 +937,7 @@ func (w *Watcher) addOrUpdateClient(path string) {
w.clientsMutex.Unlock() // Unlock before the callback w.clientsMutex.Unlock() // Unlock before the callback
w.refreshAuthState() w.refreshAuthState(false)
if w.reloadCallback != nil { if w.reloadCallback != nil {
log.Debugf("triggering server update callback after add/update") log.Debugf("triggering server update callback after add/update")
@@ -955,7 +956,7 @@ func (w *Watcher) removeClient(path string) {
w.clientsMutex.Unlock() // Release the lock before the callback w.clientsMutex.Unlock() // Release the lock before the callback
w.refreshAuthState() w.refreshAuthState(false)
if w.reloadCallback != nil { if w.reloadCallback != nil {
log.Debugf("triggering server update callback after removal") log.Debugf("triggering server update callback after removal")
@@ -984,6 +985,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if key == "" { if key == "" {
continue continue
} }
prefix := strings.TrimSpace(entry.Prefix)
base := strings.TrimSpace(entry.BaseURL) base := strings.TrimSpace(entry.BaseURL)
proxyURL := strings.TrimSpace(entry.ProxyURL) proxyURL := strings.TrimSpace(entry.ProxyURL)
id, token := idGen.next("gemini:apikey", key, base) id, token := idGen.next("gemini:apikey", key, base)
@@ -999,6 +1001,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id, ID: id,
Provider: "gemini", Provider: "gemini",
Label: "gemini-apikey", Label: "gemini-apikey",
Prefix: prefix,
Status: coreauth.StatusActive, Status: coreauth.StatusActive,
ProxyURL: proxyURL, ProxyURL: proxyURL,
Attributes: attrs, Attributes: attrs,
@@ -1016,6 +1019,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if key == "" { if key == "" {
continue continue
} }
prefix := strings.TrimSpace(ck.Prefix)
base := strings.TrimSpace(ck.BaseURL) base := strings.TrimSpace(ck.BaseURL)
id, token := idGen.next("claude:apikey", key, base) id, token := idGen.next("claude:apikey", key, base)
attrs := map[string]string{ attrs := map[string]string{
@@ -1034,6 +1038,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id, ID: id,
Provider: "claude", Provider: "claude",
Label: "claude-apikey", Label: "claude-apikey",
Prefix: prefix,
Status: coreauth.StatusActive, Status: coreauth.StatusActive,
ProxyURL: proxyURL, ProxyURL: proxyURL,
Attributes: attrs, Attributes: attrs,
@@ -1050,6 +1055,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
if key == "" { if key == "" {
continue continue
} }
prefix := strings.TrimSpace(ck.Prefix)
id, token := idGen.next("codex:apikey", key, ck.BaseURL) id, token := idGen.next("codex:apikey", key, ck.BaseURL)
attrs := map[string]string{ attrs := map[string]string{
"source": fmt.Sprintf("config:codex[%s]", token), "source": fmt.Sprintf("config:codex[%s]", token),
@@ -1064,6 +1070,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id, ID: id,
Provider: "codex", Provider: "codex",
Label: "codex-apikey", Label: "codex-apikey",
Prefix: prefix,
Status: coreauth.StatusActive, Status: coreauth.StatusActive,
ProxyURL: proxyURL, ProxyURL: proxyURL,
Attributes: attrs, Attributes: attrs,
@@ -1075,6 +1082,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
} }
for i := range cfg.OpenAICompatibility { for i := range cfg.OpenAICompatibility {
compat := &cfg.OpenAICompatibility[i] compat := &cfg.OpenAICompatibility[i]
prefix := strings.TrimSpace(compat.Prefix)
providerName := strings.ToLower(strings.TrimSpace(compat.Name)) providerName := strings.ToLower(strings.TrimSpace(compat.Name))
if providerName == "" { if providerName == "" {
providerName = "openai-compatibility" providerName = "openai-compatibility"
@@ -1106,6 +1114,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id, ID: id,
Provider: providerName, Provider: providerName,
Label: compat.Name, Label: compat.Name,
Prefix: prefix,
Status: coreauth.StatusActive, Status: coreauth.StatusActive,
ProxyURL: proxyURL, ProxyURL: proxyURL,
Attributes: attrs, Attributes: attrs,
@@ -1132,6 +1141,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id, ID: id,
Provider: providerName, Provider: providerName,
Label: compat.Name, Label: compat.Name,
Prefix: prefix,
Status: coreauth.StatusActive, Status: coreauth.StatusActive,
Attributes: attrs, Attributes: attrs,
CreatedAt: now, CreatedAt: now,
@@ -1149,6 +1159,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
base := strings.TrimSpace(compat.BaseURL) base := strings.TrimSpace(compat.BaseURL)
key := strings.TrimSpace(compat.APIKey) key := strings.TrimSpace(compat.APIKey)
prefix := strings.TrimSpace(compat.Prefix)
proxyURL := strings.TrimSpace(compat.ProxyURL) proxyURL := strings.TrimSpace(compat.ProxyURL)
idKind := "vertex:apikey" idKind := "vertex:apikey"
id, token := idGen.next(idKind, key, base, proxyURL) id, token := idGen.next(idKind, key, base, proxyURL)
@@ -1168,6 +1179,7 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
ID: id, ID: id,
Provider: providerName, Provider: providerName,
Label: "vertex-apikey", Label: "vertex-apikey",
Prefix: prefix,
Status: coreauth.StatusActive, Status: coreauth.StatusActive,
ProxyURL: proxyURL, ProxyURL: proxyURL,
Attributes: attrs, Attributes: attrs,
@@ -1220,10 +1232,20 @@ func (w *Watcher) SnapshotCoreAuths() []*coreauth.Auth {
proxyURL = p proxyURL = p
} }
prefix := ""
if rawPrefix, ok := metadata["prefix"].(string); ok {
trimmed := strings.TrimSpace(rawPrefix)
trimmed = strings.Trim(trimmed, "/")
if trimmed != "" && !strings.Contains(trimmed, "/") {
prefix = trimmed
}
}
a := &coreauth.Auth{ a := &coreauth.Auth{
ID: id, ID: id,
Provider: provider, Provider: provider,
Label: label, Label: label,
Prefix: prefix,
Status: coreauth.StatusActive, Status: coreauth.StatusActive,
Attributes: map[string]string{ Attributes: map[string]string{
"source": full, "source": full,
@@ -1310,6 +1332,7 @@ func synthesizeGeminiVirtualAuths(primary *coreauth.Auth, metadata map[string]an
Attributes: attrs, Attributes: attrs,
Metadata: metadataCopy, Metadata: metadataCopy,
ProxyURL: primary.ProxyURL, ProxyURL: primary.ProxyURL,
Prefix: primary.Prefix,
CreatedAt: now, CreatedAt: now,
UpdatedAt: now, UpdatedAt: now,
Runtime: geminicli.NewVirtualCredential(projectID, shared), Runtime: geminicli.NewVirtualCredential(projectID, shared),

View File

@@ -497,7 +497,7 @@ func TestReloadClientsCachesAuthHashes(t *testing.T) {
config: &config.Config{AuthDir: tmpDir}, config: &config.Config{AuthDir: tmpDir},
} }
w.reloadClients(true, nil) w.reloadClients(true, nil, false)
w.clientsMutex.RLock() w.clientsMutex.RLock()
defer w.clientsMutex.RUnlock() defer w.clientsMutex.RUnlock()
@@ -522,7 +522,7 @@ func TestReloadClientsLogsConfigDiffs(t *testing.T) {
w.config = newCfg w.config = newCfg
w.clientsMutex.Unlock() w.clientsMutex.Unlock()
w.reloadClients(false, nil) w.reloadClients(false, nil, false)
} }
func TestSetAuthUpdateQueueNilResetsDispatch(t *testing.T) { func TestSetAuthUpdateQueueNilResetsDispatch(t *testing.T) {

View File

@@ -84,7 +84,8 @@ func (h *GeminiAPIHandler) GeminiGetHandler(c *gin.Context) {
}) })
return return
} }
switch request.Action { action := strings.TrimPrefix(request.Action, "/")
switch action {
case "gemini-3-pro-preview": case "gemini-3-pro-preview":
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"name": "models/gemini-3-pro-preview", "name": "models/gemini-3-pro-preview",
@@ -189,7 +190,7 @@ func (h *GeminiAPIHandler) GeminiHandler(c *gin.Context) {
}) })
return return
} }
action := strings.Split(request.Action, ":") action := strings.Split(strings.TrimPrefix(request.Action, "/"), ":")
if len(action) != 2 { if len(action) != 2 {
c.JSON(http.StatusNotFound, handlers.ErrorResponse{ c.JSON(http.StatusNotFound, handlers.ErrorResponse{
Error: handlers.ErrorDetail{ Error: handlers.ErrorDetail{

View File

@@ -49,9 +49,6 @@ type BaseAPIHandler struct {
// Cfg holds the current application configuration. // Cfg holds the current application configuration.
Cfg *config.SDKConfig Cfg *config.SDKConfig
// OpenAICompatProviders is a list of provider names for OpenAI compatibility.
OpenAICompatProviders []string
} }
// NewBaseAPIHandlers creates a new API handlers instance. // NewBaseAPIHandlers creates a new API handlers instance.
@@ -63,11 +60,10 @@ type BaseAPIHandler struct {
// //
// Returns: // Returns:
// - *BaseAPIHandler: A new API handlers instance // - *BaseAPIHandler: A new API handlers instance
func NewBaseAPIHandlers(cfg *config.SDKConfig, authManager *coreauth.Manager, openAICompatProviders []string) *BaseAPIHandler { func NewBaseAPIHandlers(cfg *config.SDKConfig, authManager *coreauth.Manager) *BaseAPIHandler {
return &BaseAPIHandler{ return &BaseAPIHandler{
Cfg: cfg, Cfg: cfg,
AuthManager: authManager, AuthManager: authManager,
OpenAICompatProviders: openAICompatProviders,
} }
} }
@@ -342,30 +338,19 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string
// Resolve "auto" model to an actual available model first // Resolve "auto" model to an actual available model first
resolvedModelName := util.ResolveAutoModel(modelName) resolvedModelName := util.ResolveAutoModel(modelName)
providerName, extractedModelName, isDynamic := h.parseDynamicModel(resolvedModelName)
targetModelName := resolvedModelName
if isDynamic {
targetModelName = extractedModelName
}
// Normalize the model name to handle dynamic thinking suffixes before determining the provider. // Normalize the model name to handle dynamic thinking suffixes before determining the provider.
normalizedModel, metadata = normalizeModelMetadata(targetModelName) normalizedModel, metadata = normalizeModelMetadata(resolvedModelName)
if isDynamic { // Use the normalizedModel to get the provider name.
providers = []string{providerName} providers = util.GetProviderName(normalizedModel)
} else { if len(providers) == 0 && metadata != nil {
// For non-dynamic models, use the normalizedModel to get the provider name. if originalRaw, ok := metadata[util.ThinkingOriginalModelMetadataKey]; ok {
providers = util.GetProviderName(normalizedModel) if originalModel, okStr := originalRaw.(string); okStr {
if len(providers) == 0 && metadata != nil { originalModel = strings.TrimSpace(originalModel)
if originalRaw, ok := metadata[util.ThinkingOriginalModelMetadataKey]; ok { if originalModel != "" && !strings.EqualFold(originalModel, normalizedModel) {
if originalModel, okStr := originalRaw.(string); okStr { if altProviders := util.GetProviderName(originalModel); len(altProviders) > 0 {
originalModel = strings.TrimSpace(originalModel) providers = altProviders
if originalModel != "" && !strings.EqualFold(originalModel, normalizedModel) { normalizedModel = originalModel
if altProviders := util.GetProviderName(originalModel); len(altProviders) > 0 {
providers = altProviders
normalizedModel = originalModel
}
} }
} }
} }
@@ -383,30 +368,6 @@ func (h *BaseAPIHandler) getRequestDetails(modelName string) (providers []string
return providers, normalizedModel, metadata, nil return providers, normalizedModel, metadata, nil
} }
func (h *BaseAPIHandler) parseDynamicModel(modelName string) (providerName, model string, isDynamic bool) {
var providerPart, modelPart string
for _, sep := range []string{"://"} {
if parts := strings.SplitN(modelName, sep, 2); len(parts) == 2 {
providerPart = parts[0]
modelPart = parts[1]
break
}
}
if providerPart == "" {
return "", modelName, false
}
// Check if the provider is a configured openai-compatibility provider
for _, pName := range h.OpenAICompatProviders {
if pName == providerPart {
return providerPart, modelPart, true
}
}
return "", modelName, false
}
func cloneBytes(src []byte) []byte { func cloneBytes(src []byte) []byte {
if len(src) == 0 { if len(src) == 0 {
return nil return nil

View File

@@ -363,10 +363,11 @@ func (m *Manager) executeWithProvider(ctx context.Context, provider string, req
if provider == "" { if provider == "" {
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"} return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"}
} }
routeModel := req.Model
tried := make(map[string]struct{}) tried := make(map[string]struct{})
var lastErr error var lastErr error
for { for {
auth, executor, errPick := m.pickNext(ctx, provider, req.Model, opts, tried) auth, executor, errPick := m.pickNext(ctx, provider, routeModel, opts, tried)
if errPick != nil { if errPick != nil {
if lastErr != nil { if lastErr != nil {
return cliproxyexecutor.Response{}, lastErr return cliproxyexecutor.Response{}, lastErr
@@ -396,8 +397,10 @@ func (m *Manager) executeWithProvider(ctx context.Context, provider string, req
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt) execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt) execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
} }
resp, errExec := executor.Execute(execCtx, auth, req, opts) execReq := req
result := Result{AuthID: auth.ID, Provider: provider, Model: req.Model, Success: errExec == nil} execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
resp, errExec := executor.Execute(execCtx, auth, execReq, opts)
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
if errExec != nil { if errExec != nil {
result.Error = &Error{Message: errExec.Error()} result.Error = &Error{Message: errExec.Error()}
var se cliproxyexecutor.StatusError var se cliproxyexecutor.StatusError
@@ -420,10 +423,11 @@ func (m *Manager) executeCountWithProvider(ctx context.Context, provider string,
if provider == "" { if provider == "" {
return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"} return cliproxyexecutor.Response{}, &Error{Code: "provider_not_found", Message: "provider identifier is empty"}
} }
routeModel := req.Model
tried := make(map[string]struct{}) tried := make(map[string]struct{})
var lastErr error var lastErr error
for { for {
auth, executor, errPick := m.pickNext(ctx, provider, req.Model, opts, tried) auth, executor, errPick := m.pickNext(ctx, provider, routeModel, opts, tried)
if errPick != nil { if errPick != nil {
if lastErr != nil { if lastErr != nil {
return cliproxyexecutor.Response{}, lastErr return cliproxyexecutor.Response{}, lastErr
@@ -453,8 +457,10 @@ func (m *Manager) executeCountWithProvider(ctx context.Context, provider string,
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt) execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt) execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
} }
resp, errExec := executor.CountTokens(execCtx, auth, req, opts) execReq := req
result := Result{AuthID: auth.ID, Provider: provider, Model: req.Model, Success: errExec == nil} execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
resp, errExec := executor.CountTokens(execCtx, auth, execReq, opts)
result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: errExec == nil}
if errExec != nil { if errExec != nil {
result.Error = &Error{Message: errExec.Error()} result.Error = &Error{Message: errExec.Error()}
var se cliproxyexecutor.StatusError var se cliproxyexecutor.StatusError
@@ -477,10 +483,11 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string
if provider == "" { if provider == "" {
return nil, &Error{Code: "provider_not_found", Message: "provider identifier is empty"} return nil, &Error{Code: "provider_not_found", Message: "provider identifier is empty"}
} }
routeModel := req.Model
tried := make(map[string]struct{}) tried := make(map[string]struct{})
var lastErr error var lastErr error
for { for {
auth, executor, errPick := m.pickNext(ctx, provider, req.Model, opts, tried) auth, executor, errPick := m.pickNext(ctx, provider, routeModel, opts, tried)
if errPick != nil { if errPick != nil {
if lastErr != nil { if lastErr != nil {
return nil, lastErr return nil, lastErr
@@ -510,14 +517,16 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string
execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt) execCtx = context.WithValue(execCtx, roundTripperContextKey{}, rt)
execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt) execCtx = context.WithValue(execCtx, "cliproxy.roundtripper", rt)
} }
chunks, errStream := executor.ExecuteStream(execCtx, auth, req, opts) execReq := req
execReq.Model, execReq.Metadata = rewriteModelForAuth(routeModel, req.Metadata, auth)
chunks, errStream := executor.ExecuteStream(execCtx, auth, execReq, opts)
if errStream != nil { if errStream != nil {
rerr := &Error{Message: errStream.Error()} rerr := &Error{Message: errStream.Error()}
var se cliproxyexecutor.StatusError var se cliproxyexecutor.StatusError
if errors.As(errStream, &se) && se != nil { if errors.As(errStream, &se) && se != nil {
rerr.HTTPStatus = se.StatusCode() rerr.HTTPStatus = se.StatusCode()
} }
result := Result{AuthID: auth.ID, Provider: provider, Model: req.Model, Success: false, Error: rerr} result := Result{AuthID: auth.ID, Provider: provider, Model: routeModel, Success: false, Error: rerr}
result.RetryAfter = retryAfterFromError(errStream) result.RetryAfter = retryAfterFromError(errStream)
m.MarkResult(execCtx, result) m.MarkResult(execCtx, result)
lastErr = errStream lastErr = errStream
@@ -535,18 +544,66 @@ func (m *Manager) executeStreamWithProvider(ctx context.Context, provider string
if errors.As(chunk.Err, &se) && se != nil { if errors.As(chunk.Err, &se) && se != nil {
rerr.HTTPStatus = se.StatusCode() rerr.HTTPStatus = se.StatusCode()
} }
m.MarkResult(streamCtx, Result{AuthID: streamAuth.ID, Provider: streamProvider, Model: req.Model, Success: false, Error: rerr}) m.MarkResult(streamCtx, Result{AuthID: streamAuth.ID, Provider: streamProvider, Model: routeModel, Success: false, Error: rerr})
} }
out <- chunk out <- chunk
} }
if !failed { if !failed {
m.MarkResult(streamCtx, Result{AuthID: streamAuth.ID, Provider: streamProvider, Model: req.Model, Success: true}) m.MarkResult(streamCtx, Result{AuthID: streamAuth.ID, Provider: streamProvider, Model: routeModel, Success: true})
} }
}(execCtx, auth.Clone(), provider, chunks) }(execCtx, auth.Clone(), provider, chunks)
return out, nil return out, nil
} }
} }
func rewriteModelForAuth(model string, metadata map[string]any, auth *Auth) (string, map[string]any) {
if auth == nil || model == "" {
return model, metadata
}
prefix := strings.TrimSpace(auth.Prefix)
if prefix == "" {
return model, metadata
}
needle := prefix + "/"
if !strings.HasPrefix(model, needle) {
return model, metadata
}
rewritten := strings.TrimPrefix(model, needle)
return rewritten, stripPrefixFromMetadata(metadata, needle)
}
func stripPrefixFromMetadata(metadata map[string]any, needle string) map[string]any {
if len(metadata) == 0 || needle == "" {
return metadata
}
keys := []string{
util.ThinkingOriginalModelMetadataKey,
util.GeminiOriginalModelMetadataKey,
}
var out map[string]any
for _, key := range keys {
raw, ok := metadata[key]
if !ok {
continue
}
value, okStr := raw.(string)
if !okStr || !strings.HasPrefix(value, needle) {
continue
}
if out == nil {
out = make(map[string]any, len(metadata))
for k, v := range metadata {
out[k] = v
}
}
out[key] = strings.TrimPrefix(value, needle)
}
if out == nil {
return metadata
}
return out
}
func (m *Manager) normalizeProviders(providers []string) []string { func (m *Manager) normalizeProviders(providers []string) []string {
if len(providers) == 0 { if len(providers) == 0 {
return nil return nil

View File

@@ -19,6 +19,8 @@ type Auth struct {
Index uint64 `json:"-"` Index uint64 `json:"-"`
// Provider is the upstream provider key (e.g. "gemini", "claude"). // Provider is the upstream provider key (e.g. "gemini", "claude").
Provider string `json:"provider"` Provider string `json:"provider"`
// Prefix optionally namespaces models for routing (e.g., "teamA/gemini-3-pro-preview").
Prefix string `json:"prefix,omitempty"`
// FileName stores the relative or absolute path of the backing auth file. // FileName stores the relative or absolute path of the backing auth file.
FileName string `json:"-"` FileName string `json:"-"`
// Storage holds the token persistence implementation used during login flows. // Storage holds the token persistence implementation used during login flows.

View File

@@ -787,7 +787,7 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
if providerKey == "" { if providerKey == "" {
providerKey = "openai-compatibility" providerKey = "openai-compatibility"
} }
GlobalModelRegistry().RegisterClient(a.ID, providerKey, ms) GlobalModelRegistry().RegisterClient(a.ID, providerKey, applyModelPrefixes(ms, a.Prefix, s.cfg.ForceModelPrefix))
} else { } else {
// Ensure stale registrations are cleared when model list becomes empty. // Ensure stale registrations are cleared when model list becomes empty.
GlobalModelRegistry().UnregisterClient(a.ID) GlobalModelRegistry().UnregisterClient(a.ID)
@@ -807,7 +807,7 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) {
if key == "" { if key == "" {
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, applyModelPrefixes(models, a.Prefix, s.cfg != nil && s.cfg.ForceModelPrefix))
return return
} }
@@ -987,6 +987,48 @@ func applyExcludedModels(models []*ModelInfo, excluded []string) []*ModelInfo {
return filtered return filtered
} }
func applyModelPrefixes(models []*ModelInfo, prefix string, forceModelPrefix bool) []*ModelInfo {
trimmedPrefix := strings.TrimSpace(prefix)
if trimmedPrefix == "" || len(models) == 0 {
return models
}
out := make([]*ModelInfo, 0, len(models)*2)
seen := make(map[string]struct{}, len(models)*2)
addModel := func(model *ModelInfo) {
if model == nil {
return
}
id := strings.TrimSpace(model.ID)
if id == "" {
return
}
if _, exists := seen[id]; exists {
return
}
seen[id] = struct{}{}
out = append(out, model)
}
for _, model := range models {
if model == nil {
continue
}
baseID := strings.TrimSpace(model.ID)
if baseID == "" {
continue
}
if !forceModelPrefix || trimmedPrefix == baseID {
addModel(model)
}
clone := *model
clone.ID = trimmedPrefix + "/" + baseID
addModel(&clone)
}
return out
}
// matchWildcard performs case-insensitive wildcard matching where '*' matches any substring. // matchWildcard performs case-insensitive wildcard matching where '*' matches any substring.
func matchWildcard(pattern, value string) bool { func matchWildcard(pattern, value string) bool {
if pattern == "" { if pattern == "" {

View File

@@ -9,6 +9,11 @@ type SDKConfig struct {
// ProxyURL is the URL of an optional proxy server to use for outbound requests. // ProxyURL is the URL of an optional proxy server to use for outbound requests.
ProxyURL string `yaml:"proxy-url" json:"proxy-url"` ProxyURL string `yaml:"proxy-url" json:"proxy-url"`
// ForceModelPrefix requires explicit model prefixes (e.g., "teamA/gemini-3-pro-preview")
// to target prefixed credentials. When false, unprefixed model requests may use prefixed
// credentials as well.
ForceModelPrefix bool `yaml:"force-model-prefix" json:"force-model-prefix"`
// RequestLog enables or disables detailed request logging functionality. // RequestLog enables or disables detailed request logging functionality.
RequestLog bool `yaml:"request-log" json:"request-log"` RequestLog bool `yaml:"request-log" json:"request-log"`

View File

@@ -295,7 +295,7 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) {
} }
// Check numeric budget fallback for allowCompat // Check numeric budget fallback for allowCompat
if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil { if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {
if mapped, okMap := util.OpenAIThinkingBudgetToEffort(normalizedModel, *budget); okMap && mapped != "" { if mapped, okMap := util.ThinkingBudgetToEffort(normalizedModel, *budget); okMap && mapped != "" {
return true, mapped, false return true, mapped, false
} }
} }
@@ -308,7 +308,7 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) {
effort, ok := util.ReasoningEffortFromMetadata(metadata) effort, ok := util.ReasoningEffortFromMetadata(metadata)
if !ok || strings.TrimSpace(effort) == "" { if !ok || strings.TrimSpace(effort) == "" {
if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil { if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {
if mapped, okMap := util.OpenAIThinkingBudgetToEffort(normalizedModel, *budget); okMap { if mapped, okMap := util.ThinkingBudgetToEffort(normalizedModel, *budget); okMap {
effort = mapped effort = mapped
ok = true ok = true
} }
@@ -336,7 +336,7 @@ func TestThinkingConversionsAcrossProtocolsAndModels(t *testing.T) {
return false, "", true return false, "", true
} }
if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil { if budget, _, _, matched := util.ThinkingFromMetadata(metadata); matched && budget != nil {
if mapped, okMap := util.OpenAIThinkingBudgetToEffort(normalizedModel, *budget); okMap && mapped != "" { if mapped, okMap := util.ThinkingBudgetToEffort(normalizedModel, *budget); okMap && mapped != "" {
mapped = strings.ToLower(strings.TrimSpace(mapped)) mapped = strings.ToLower(strings.TrimSpace(mapped))
if normalized, okLevel := util.NormalizeReasoningEffortLevel(normalizedModel, mapped); okLevel { if normalized, okLevel := util.NormalizeReasoningEffortLevel(normalizedModel, mapped); okLevel {
return true, normalized, false return true, normalized, false
@@ -609,7 +609,7 @@ func TestRawPayloadThinkingConversions(t *testing.T) {
return true, normalized, false return true, normalized, false
} }
if budget, ok := cs.thinkingParam.(int); ok { if budget, ok := cs.thinkingParam.(int); ok {
if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" { if mapped, okM := util.ThinkingBudgetToEffort(model, budget); okM && mapped != "" {
return true, mapped, false return true, mapped, false
} }
} }
@@ -625,7 +625,7 @@ func TestRawPayloadThinkingConversions(t *testing.T) {
return false, "", true // invalid level return false, "", true // invalid level
} }
if budget, ok := cs.thinkingParam.(int); ok { if budget, ok := cs.thinkingParam.(int); ok {
if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" { if mapped, okM := util.ThinkingBudgetToEffort(model, budget); okM && mapped != "" {
// Check if the mapped effort is valid for this model // Check if the mapped effort is valid for this model
if _, validLevel := util.NormalizeReasoningEffortLevel(model, mapped); !validLevel { if _, validLevel := util.NormalizeReasoningEffortLevel(model, mapped); !validLevel {
return true, mapped, true // expect validation error return true, mapped, true // expect validation error
@@ -646,7 +646,7 @@ func TestRawPayloadThinkingConversions(t *testing.T) {
return false, "", true return false, "", true
} }
if budget, ok := cs.thinkingParam.(int); ok { if budget, ok := cs.thinkingParam.(int); ok {
if mapped, okM := util.OpenAIThinkingBudgetToEffort(model, budget); okM && mapped != "" { if mapped, okM := util.ThinkingBudgetToEffort(model, budget); okM && mapped != "" {
// Check if the mapped effort is valid for this model // Check if the mapped effort is valid for this model
if _, validLevel := util.NormalizeReasoningEffortLevel(model, mapped); !validLevel { if _, validLevel := util.NormalizeReasoningEffortLevel(model, mapped); !validLevel {
return true, mapped, true // expect validation error return true, mapped, true // expect validation error
@@ -721,7 +721,7 @@ func TestRawPayloadThinkingConversions(t *testing.T) {
} }
} }
func TestOpenAIThinkingBudgetToEffortRanges(t *testing.T) { func TestThinkingBudgetToEffort(t *testing.T) {
cleanup := registerCoreModels(t) cleanup := registerCoreModels(t)
defer cleanup() defer cleanup()
@@ -733,7 +733,7 @@ func TestOpenAIThinkingBudgetToEffortRanges(t *testing.T) {
ok bool ok bool
}{ }{
{name: "dynamic-auto", model: "gpt-5", budget: -1, want: "auto", ok: true}, {name: "dynamic-auto", model: "gpt-5", budget: -1, want: "auto", ok: true},
{name: "zero-none", model: "gpt-5", budget: 0, want: "none", ok: true}, {name: "zero-none", model: "gpt-5", budget: 0, want: "minimal", ok: true},
{name: "low-min", model: "gpt-5", budget: 1, want: "low", ok: true}, {name: "low-min", model: "gpt-5", budget: 1, want: "low", ok: true},
{name: "low-max", model: "gpt-5", budget: 1024, want: "low", ok: true}, {name: "low-max", model: "gpt-5", budget: 1024, want: "low", ok: true},
{name: "medium-min", model: "gpt-5", budget: 1025, want: "medium", ok: true}, {name: "medium-min", model: "gpt-5", budget: 1025, want: "medium", ok: true},
@@ -741,14 +741,14 @@ func TestOpenAIThinkingBudgetToEffortRanges(t *testing.T) {
{name: "high-min", model: "gpt-5", budget: 8193, want: "high", ok: true}, {name: "high-min", model: "gpt-5", budget: 8193, want: "high", ok: true},
{name: "high-max", model: "gpt-5", budget: 24576, want: "high", ok: true}, {name: "high-max", model: "gpt-5", budget: 24576, want: "high", ok: true},
{name: "over-max-clamps-to-highest", model: "gpt-5", budget: 64000, want: "high", ok: true}, {name: "over-max-clamps-to-highest", model: "gpt-5", budget: 64000, want: "high", ok: true},
{name: "over-max-xhigh-model", model: "gpt-5.2", budget: 50000, want: "xhigh", ok: true}, {name: "over-max-xhigh-model", model: "gpt-5.2", budget: 64000, want: "xhigh", ok: true},
{name: "negative-unsupported", model: "gpt-5", budget: -5, want: "", ok: false}, {name: "negative-unsupported", model: "gpt-5", budget: -5, want: "", ok: false},
} }
for _, cs := range cases { for _, cs := range cases {
cs := cs cs := cs
t.Run(cs.name, func(t *testing.T) { t.Run(cs.name, func(t *testing.T) {
got, ok := util.OpenAIThinkingBudgetToEffort(cs.model, cs.budget) got, ok := util.ThinkingBudgetToEffort(cs.model, cs.budget)
if ok != cs.ok { if ok != cs.ok {
t.Fatalf("ok mismatch for model=%s budget=%d: expect %v got %v", cs.model, cs.budget, cs.ok, ok) t.Fatalf("ok mismatch for model=%s budget=%d: expect %v got %v", cs.model, cs.budget, cs.ok, ok)
} }
@@ -758,3 +758,41 @@ func TestOpenAIThinkingBudgetToEffortRanges(t *testing.T) {
}) })
} }
} }
func TestThinkingEffortToBudget(t *testing.T) {
cleanup := registerCoreModels(t)
defer cleanup()
cases := []struct {
name string
model string
effort string
want int
ok bool
}{
{name: "none", model: "gemini-2.5-pro", effort: "none", want: 0, ok: true},
{name: "auto", model: "gemini-2.5-pro", effort: "auto", want: -1, ok: true},
{name: "minimal", model: "gemini-2.5-pro", effort: "minimal", want: 512, ok: true},
{name: "low", model: "gemini-2.5-pro", effort: "low", want: 1024, ok: true},
{name: "medium", model: "gemini-2.5-pro", effort: "medium", want: 8192, ok: true},
{name: "high", model: "gemini-2.5-pro", effort: "high", want: 24576, ok: true},
{name: "xhigh", model: "gemini-2.5-pro", effort: "xhigh", want: 32768, ok: true},
{name: "empty-unsupported", model: "gemini-2.5-pro", effort: "", want: 0, ok: false},
{name: "invalid-unsupported", model: "gemini-2.5-pro", effort: "ultra", want: 0, ok: false},
{name: "case-insensitive", model: "gemini-2.5-pro", effort: "LOW", want: 1024, ok: true},
{name: "case-insensitive-medium", model: "gemini-2.5-pro", effort: "MEDIUM", want: 8192, ok: true},
}
for _, cs := range cases {
cs := cs
t.Run(cs.name, func(t *testing.T) {
got, ok := util.ThinkingEffortToBudget(cs.model, cs.effort)
if ok != cs.ok {
t.Fatalf("ok mismatch for model=%s effort=%s: expect %v got %v", cs.model, cs.effort, cs.ok, ok)
}
if got != cs.want {
t.Fatalf("value mismatch for model=%s effort=%s: expect %d got %d", cs.model, cs.effort, cs.want, got)
}
})
}
}