mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-19 04:40:52 +08:00
Merge branch 'dev' into watcher
This commit is contained in:
@@ -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:
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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),
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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{
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -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"`
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user