From 4c78e40da4f1031f6c4a0dcb010ce9dc0214ce0a Mon Sep 17 00:00:00 2001 From: Luis Pater Date: Sat, 20 Jun 2026 00:58:20 +0800 Subject: [PATCH] feat(auth): unify provider key handling with OpenAI compatibility support - Replaced direct `strings.ToLower` usage with `util.OpenAICompatibleProviderKey` for generating provider keys. - Updated auth and executor workflows to use namespaced keys for OpenAI-compatible providers. - Adjusted tests to validate namespaced key handling, including new test cases for provider registration and execution logic. - Added `OpenAICompatibleProviderKey` helper in `util` for consistent key transformations. Closes: #3600 --- internal/util/provider.go | 14 ++++ internal/watcher/synthesizer/config.go | 10 ++- internal/watcher/synthesizer/config_test.go | 39 ++++++++- sdk/cliproxy/auth/conductor.go | 35 ++++---- sdk/cliproxy/auth/openai_compat_pool_test.go | 84 ++++++++++--------- sdk/cliproxy/auth/scheduler.go | 4 +- sdk/cliproxy/service.go | 9 +- .../service_executor_registration_test.go | 62 ++++++++++++++ 8 files changed, 193 insertions(+), 64 deletions(-) diff --git a/internal/util/provider.go b/internal/util/provider.go index 6313f58e..ae25a631 100644 --- a/internal/util/provider.go +++ b/internal/util/provider.go @@ -12,6 +12,20 @@ import ( log "github.com/sirupsen/logrus" ) +const openAICompatibleProviderPrefix = "openai-compatible-" + +// OpenAICompatibleProviderKey returns the internal provider key for an OpenAI-compatible provider. +func OpenAICompatibleProviderKey(name string) string { + name = strings.ToLower(strings.TrimSpace(name)) + if name == "" || name == "openai-compatibility" || strings.HasPrefix(name, openAICompatibleProviderPrefix) { + if name == "" { + return "openai-compatibility" + } + return name + } + return openAICompatibleProviderPrefix + name +} + // GetProviderName determines all AI service providers capable of serving a registered model. // It first queries the global model registry to retrieve the providers backing the supplied model name. // When the model has not been registered yet, it falls back to legacy string heuristics to infer diff --git a/internal/watcher/synthesizer/config.go b/internal/watcher/synthesizer/config.go index 1eea3dc1..14e1cd5b 100644 --- a/internal/watcher/synthesizer/config.go +++ b/internal/watcher/synthesizer/config.go @@ -5,6 +5,7 @@ import ( "strconv" "strings" + "github.com/router-for-me/CLIProxyAPI/v7/internal/util" "github.com/router-for-me/CLIProxyAPI/v7/internal/watcher/diff" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" ) @@ -226,6 +227,7 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor if providerName == "" { providerName = "openai-compatibility" } + internalProviderKey := util.OpenAICompatibleProviderKey(providerName) base := strings.TrimSpace(compat.BaseURL) disableCooling := compat.DisableCooling @@ -241,7 +243,7 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor "source": fmt.Sprintf("config:%s[%s]", providerName, token), "base_url": base, "compat_name": compat.Name, - "provider_key": providerName, + "provider_key": internalProviderKey, } metadata := map[string]any{} if disableCooling { @@ -259,7 +261,7 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor addConfigHeadersToAttrs(compat.Headers, attrs) a := &coreauth.Auth{ ID: id, - Provider: providerName, + Provider: internalProviderKey, Label: compat.Name, Prefix: prefix, Status: coreauth.StatusActive, @@ -283,7 +285,7 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor "source": fmt.Sprintf("config:%s[%s]", providerName, token), "base_url": base, "compat_name": compat.Name, - "provider_key": providerName, + "provider_key": internalProviderKey, } metadata := map[string]any{} if disableCooling { @@ -298,7 +300,7 @@ func (s *ConfigSynthesizer) synthesizeOpenAICompat(ctx *SynthesisContext) []*cor addConfigHeadersToAttrs(compat.Headers, attrs) a := &coreauth.Auth{ ID: id, - Provider: providerName, + Provider: internalProviderKey, Label: compat.Name, Prefix: prefix, Status: coreauth.StatusActive, diff --git a/internal/watcher/synthesizer/config_test.go b/internal/watcher/synthesizer/config_test.go index c8526a65..0016844b 100644 --- a/internal/watcher/synthesizer/config_test.go +++ b/internal/watcher/synthesizer/config_test.go @@ -400,6 +400,43 @@ func TestConfigSynthesizer_OpenAICompat(t *testing.T) { } } +func TestConfigSynthesizer_OpenAICompat_UsesNamespacedProviderKey(t *testing.T) { + synth := NewConfigSynthesizer() + ctx := &SynthesisContext{ + Config: &config.Config{ + OpenAICompatibility: []config.OpenAICompatibility{ + { + Name: "kimi", + BaseURL: "https://kimi-compatible.example.com/v1", + APIKeyEntries: []config.OpenAICompatibilityAPIKey{ + {APIKey: "test-key"}, + }, + }, + }, + }, + Now: time.Now(), + IDGenerator: NewStableIDGenerator(), + } + + auths, err := synth.Synthesize(ctx) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(auths) != 1 { + t.Fatalf("expected 1 auth, got %d", len(auths)) + } + auth := auths[0] + if auth.Provider != "openai-compatible-kimi" { + t.Fatalf("provider = %q, want openai-compatible-kimi", auth.Provider) + } + if auth.Attributes["provider_key"] != "openai-compatible-kimi" { + t.Fatalf("provider_key = %q, want openai-compatible-kimi", auth.Attributes["provider_key"]) + } + if auth.Attributes["compat_name"] != "kimi" { + t.Fatalf("compat_name = %q, want kimi", auth.Attributes["compat_name"]) + } +} + func TestConfigSynthesizer_VertexCompat(t *testing.T) { synth := NewConfigSynthesizer() ctx := &SynthesisContext{ @@ -639,7 +676,7 @@ func TestConfigSynthesizer_AllProviders(t *testing.T) { providers[a.Provider] = true } - expected := []string{"gemini", "claude", "codex", "compat", "vertex"} + expected := []string{"gemini", "claude", "codex", "openai-compatible-compat", "vertex"} for _, p := range expected { if !providers[p] { t.Errorf("expected provider %s not found", p) diff --git a/sdk/cliproxy/auth/conductor.go b/sdk/cliproxy/auth/conductor.go index ec723aad..44302da1 100644 --- a/sdk/cliproxy/auth/conductor.go +++ b/sdk/cliproxy/auth/conductor.go @@ -913,13 +913,13 @@ func openAICompatProviderKey(auth *Auth) string { } if auth.Attributes != nil { if providerKey := strings.TrimSpace(auth.Attributes["provider_key"]); providerKey != "" { - return strings.ToLower(providerKey) + return util.OpenAICompatibleProviderKey(providerKey) } if compatName := strings.TrimSpace(auth.Attributes["compat_name"]); compatName != "" { - return strings.ToLower(compatName) + return util.OpenAICompatibleProviderKey(compatName) } } - return strings.ToLower(strings.TrimSpace(auth.Provider)) + return util.OpenAICompatibleProviderKey(auth.Provider) } func openAICompatModelPoolKey(auth *Auth, requestedModel string) string { @@ -3063,7 +3063,7 @@ func (m *Manager) closestCooldownWait(providers []string, model string, attempt if auth == nil { continue } - providerKey := strings.TrimSpace(strings.ToLower(auth.Provider)) + providerKey := executorKeyFromAuth(auth) if _, ok := providerSet[providerKey]; !ok { continue } @@ -3123,7 +3123,7 @@ func (m *Manager) retryAllowed(attempt int, providers []string) bool { if auth == nil { continue } - providerKey := strings.TrimSpace(strings.ToLower(auth.Provider)) + providerKey := executorKeyFromAuth(auth) if _, ok := providerSet[providerKey]; !ok { continue } @@ -4017,7 +4017,7 @@ func (m *Manager) pickNextLegacy(ctx context.Context, provider, model string, op } registryRef := registry.GetGlobalRegistry() for _, candidate := range m.auths { - if candidate.Provider != provider || candidate.Disabled { + if candidate == nil || executorKeyFromAuth(candidate) != provider || candidate.Disabled { continue } if pinnedAuthID != "" && candidate.ID != pinnedAuthID { @@ -4083,7 +4083,7 @@ func (m *Manager) pickNext(ctx context.Context, provider, model string, opts cli if strings.TrimSpace(model) != "" { m.mu.RLock() for _, candidate := range m.auths { - if candidate == nil || candidate.Provider != provider || candidate.Disabled { + if candidate == nil || executorKeyFromAuth(candidate) != provider || candidate.Disabled { continue } if _, used := tried[candidate.ID]; used { @@ -4176,7 +4176,7 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m if disallowFreeAuth && isFreeCodexAuth(candidate) { continue } - providerKey := strings.TrimSpace(strings.ToLower(candidate.Provider)) + providerKey := executorKeyFromAuth(candidate) if providerKey == "" { continue } @@ -4219,7 +4219,7 @@ func (m *Manager) pickNextMixedLegacy(ctx context.Context, providers []string, m if selected == nil { return nil, nil, "", &Error{Code: "auth_not_found", Message: "selector returned no auth"} } - providerKey := strings.TrimSpace(strings.ToLower(selected.Provider)) + providerKey := executorKeyFromAuth(selected) executor, okExecutor := m.Executor(providerKey) if !okExecutor { return nil, nil, "", &Error{Code: "executor_not_found", Message: "executor not registered"} @@ -4274,7 +4274,7 @@ func (m *Manager) pickNextMixed(ctx context.Context, providers []string, model s if candidate == nil || candidate.Disabled { continue } - if _, ok := providerSet[strings.TrimSpace(strings.ToLower(candidate.Provider))]; !ok { + if _, ok := providerSet[executorKeyFromAuth(candidate)]; !ok { continue } if _, used := tried[candidate.ID]; used { @@ -4554,7 +4554,7 @@ func (m *Manager) homeRuntimeAuthByID(sessionID string, authID string) (*Auth, P if auth == nil || !authWebsocketsEnabled(auth) { return nil, nil, "", false } - providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) + providerKey := executorKeyFromAuth(auth) if providerKey == "" { return nil, nil, "", false } @@ -4652,7 +4652,7 @@ func (m *Manager) pickNextViaHome(ctx context.Context, model string, opts clipro if homeAuthAlreadyTried(tried, auth.ID) { return nil, nil, "", repeatedHomeAuthError() } - providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) + providerKey := executorKeyFromAuth(&auth) if providerKey == "" { return nil, nil, "", &Error{Code: "invalid_auth", Message: "home returned auth without provider", HTTPStatus: http.StatusBadGateway} } @@ -4725,7 +4725,7 @@ func (m *Manager) findAllAntigravityCreditsCandidateAuths(ctx context.Context, r if !strings.Contains(strings.ToLower(strings.TrimSpace(routeModel)), "claude") { continue } - providerKey := strings.TrimSpace(strings.ToLower(auth.Provider)) + providerKey := executorKeyFromAuth(auth) executor, ok := m.executors[providerKey] if !ok { continue @@ -5338,9 +5338,16 @@ func executorKeyFromAuth(auth *Auth) string { if providerKey == "" { providerKey = compatName } - return strings.ToLower(providerKey) + return util.OpenAICompatibleProviderKey(providerKey) } } + if strings.EqualFold(strings.TrimSpace(auth.Provider), "openai-compatibility") { + providerKey := strings.TrimSpace(auth.Label) + if providerKey == "" { + providerKey = "openai-compatibility" + } + return util.OpenAICompatibleProviderKey(providerKey) + } return strings.ToLower(strings.TrimSpace(auth.Provider)) } diff --git a/sdk/cliproxy/auth/openai_compat_pool_test.go b/sdk/cliproxy/auth/openai_compat_pool_test.go index f052c486..33e40e57 100644 --- a/sdk/cliproxy/auth/openai_compat_pool_test.go +++ b/sdk/cliproxy/auth/openai_compat_pool_test.go @@ -12,6 +12,8 @@ import ( cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" ) +const openAICompatPoolProviderKey = "openai-compatible-pool" + type openAICompatPoolExecutor struct { id string @@ -169,18 +171,18 @@ func newOpenAICompatPoolTestManager(t *testing.T, alias string, models []interna m := NewManager(nil, nil, nil) m.SetConfig(cfg) if executor == nil { - executor = &openAICompatPoolExecutor{id: "pool"} + executor = &openAICompatPoolExecutor{id: openAICompatPoolProviderKey} } m.RegisterExecutor(executor) auth := &Auth{ ID: "pool-auth-" + t.Name(), - Provider: "pool", + Provider: openAICompatPoolProviderKey, Status: StatusActive, Attributes: map[string]string{ "api_key": "test-key", "compat_name": "pool", - "provider_key": "pool", + "provider_key": openAICompatPoolProviderKey, }, } if _, err := m.Register(context.Background(), auth); err != nil { @@ -188,7 +190,7 @@ func newOpenAICompatPoolTestManager(t *testing.T, alias string, models []interna } reg := registry.GetGlobalRegistry() - reg.RegisterClient(auth.ID, "pool", []*registry.ModelInfo{{ID: alias}}) + reg.RegisterClient(auth.ID, openAICompatPoolProviderKey, []*registry.ModelInfo{{ID: alias}}) t.Cleanup(func() { reg.UnregisterClient(auth.ID) }) @@ -214,7 +216,7 @@ func TestManagerExecuteCount_OpenAICompatAliasPoolStopsOnInvalidRequest(t *testi alias := "claude-opus-4.66" invalidErr := &Error{HTTPStatus: http.StatusUnprocessableEntity, Message: "unprocessable entity"} executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, countErrors: map[string]error{"deepseek-v3.1": invalidErr}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -222,7 +224,7 @@ func TestManagerExecuteCount_OpenAICompatAliasPoolStopsOnInvalidRequest(t *testi {Name: "glm-5", Alias: alias}, }, executor) - _, err := m.ExecuteCount(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + _, err := m.ExecuteCount(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err == nil || err.Error() != invalidErr.Error() { t.Fatalf("execute count error = %v, want %v", err, invalidErr) } @@ -251,14 +253,14 @@ func TestResolveModelAliasPoolFromConfigModels(t *testing.T) { func TestManagerExecute_OpenAICompatAliasPoolRotatesWithinAuth(t *testing.T) { alias := "claude-opus-4.66" - executor := &openAICompatPoolExecutor{id: "pool"} + executor := &openAICompatPoolExecutor{id: openAICompatPoolProviderKey} m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ {Name: "deepseek-v3.1", Alias: alias}, {Name: "glm-5", Alias: alias}, }, executor) for i := 0; i < 3; i++ { - resp, err := m.Execute(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + resp, err := m.Execute(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute %d: %v", i, err) } @@ -283,7 +285,7 @@ func TestManagerExecute_OpenAICompatAliasPoolStopsOnBadRequest(t *testing.T) { alias := "claude-opus-4.66" invalidErr := &Error{HTTPStatus: http.StatusBadRequest, Message: "invalid_request_error: malformed payload"} executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, executeErrors: map[string]error{"deepseek-v3.1": invalidErr}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -291,7 +293,7 @@ func TestManagerExecute_OpenAICompatAliasPoolStopsOnBadRequest(t *testing.T) { {Name: "glm-5", Alias: alias}, }, executor) - _, err := m.Execute(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + _, err := m.Execute(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err == nil || err.Error() != invalidErr.Error() { t.Fatalf("execute error = %v, want %v", err, invalidErr) } @@ -308,7 +310,7 @@ func TestManagerExecute_OpenAICompatAliasPoolFallsBackOnModelSupportBadRequest(t Message: "invalid_request_error: The requested model is not supported.", } executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, executeErrors: map[string]error{"deepseek-v3.1": modelSupportErr}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -316,7 +318,7 @@ func TestManagerExecute_OpenAICompatAliasPoolFallsBackOnModelSupportBadRequest(t {Name: "glm-5", Alias: alias}, }, executor) - resp, err := m.Execute(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + resp, err := m.Execute(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute error = %v, want fallback success", err) } @@ -354,7 +356,7 @@ func TestManagerExecute_OpenAICompatAliasPoolFallsBackOnModelSupportUnprocessabl Message: "The requested model is not supported.", } executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, executeErrors: map[string]error{"deepseek-v3.1": modelSupportErr}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -362,7 +364,7 @@ func TestManagerExecute_OpenAICompatAliasPoolFallsBackOnModelSupportUnprocessabl {Name: "glm-5", Alias: alias}, }, executor) - resp, err := m.Execute(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + resp, err := m.Execute(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute error = %v, want fallback success", err) } @@ -384,7 +386,7 @@ func TestManagerExecute_OpenAICompatAliasPoolFallsBackOnModelSupportUnprocessabl func TestManagerExecute_OpenAICompatAliasPoolFallsBackWithinSameAuth(t *testing.T) { alias := "claude-opus-4.66" executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, executeErrors: map[string]error{"deepseek-v3.1": &Error{HTTPStatus: http.StatusTooManyRequests, Message: "quota"}}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -392,7 +394,7 @@ func TestManagerExecute_OpenAICompatAliasPoolFallsBackWithinSameAuth(t *testing. {Name: "glm-5", Alias: alias}, }, executor) - resp, err := m.Execute(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + resp, err := m.Execute(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute: %v", err) } @@ -411,7 +413,7 @@ func TestManagerExecute_OpenAICompatAliasPoolFallsBackWithinSameAuth(t *testing. func TestManagerExecuteStream_OpenAICompatAliasPoolRetriesOnEmptyBootstrap(t *testing.T) { alias := "claude-opus-4.66" executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, streamPayloads: map[string][]cliproxyexecutor.StreamChunk{ "deepseek-v3.1": {}, }, @@ -421,7 +423,7 @@ func TestManagerExecuteStream_OpenAICompatAliasPoolRetriesOnEmptyBootstrap(t *te {Name: "glm-5", Alias: alias}, }, executor) - streamResult, err := m.ExecuteStream(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + streamResult, err := m.ExecuteStream(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute stream: %v", err) } @@ -447,7 +449,7 @@ func TestManagerExecuteStream_OpenAICompatAliasPoolRetriesOnEmptyBootstrap(t *te func TestManagerExecuteStream_OpenAICompatAliasPoolFallsBackBeforeFirstByte(t *testing.T) { alias := "claude-opus-4.66" executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, streamFirstErrors: map[string]error{"deepseek-v3.1": &Error{HTTPStatus: http.StatusTooManyRequests, Message: "quota"}}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -455,7 +457,7 @@ func TestManagerExecuteStream_OpenAICompatAliasPoolFallsBackBeforeFirstByte(t *t {Name: "glm-5", Alias: alias}, }, executor) - streamResult, err := m.ExecuteStream(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + streamResult, err := m.ExecuteStream(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute stream: %v", err) } @@ -485,7 +487,7 @@ func TestManagerExecuteStream_OpenAICompatAliasPoolStopsOnInvalidRequest(t *test alias := "claude-opus-4.66" invalidErr := &Error{HTTPStatus: http.StatusUnprocessableEntity, Message: "unprocessable entity"} executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, streamFirstErrors: map[string]error{"deepseek-v3.1": invalidErr}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -493,7 +495,7 @@ func TestManagerExecuteStream_OpenAICompatAliasPoolStopsOnInvalidRequest(t *test {Name: "glm-5", Alias: alias}, }, executor) - _, err := m.ExecuteStream(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + _, err := m.ExecuteStream(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err == nil || err.Error() != invalidErr.Error() { t.Fatalf("execute stream error = %v, want %v", err, invalidErr) } @@ -510,7 +512,7 @@ func TestManagerExecute_OpenAICompatAliasPoolSkipsSuspendedUpstreamOnLaterReques Message: "invalid_request_error: The requested model is not supported.", } executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, executeErrors: map[string]error{"deepseek-v3.1": modelSupportErr}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -519,7 +521,7 @@ func TestManagerExecute_OpenAICompatAliasPoolSkipsSuspendedUpstreamOnLaterReques }, executor) for i := 0; i < 3; i++ { - resp, err := m.Execute(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + resp, err := m.Execute(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute %d: %v", i, err) } @@ -547,7 +549,7 @@ func TestManagerExecuteStream_OpenAICompatAliasPoolSkipsSuspendedUpstreamOnLater Message: "The requested model is not supported.", } executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, streamFirstErrors: map[string]error{"deepseek-v3.1": modelSupportErr}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -556,7 +558,7 @@ func TestManagerExecuteStream_OpenAICompatAliasPoolSkipsSuspendedUpstreamOnLater }, executor) for i := 0; i < 3; i++ { - streamResult, err := m.ExecuteStream(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + streamResult, err := m.ExecuteStream(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute stream %d: %v", i, err) } @@ -582,14 +584,14 @@ func TestManagerExecuteStream_OpenAICompatAliasPoolSkipsSuspendedUpstreamOnLater func TestManagerExecuteCount_OpenAICompatAliasPoolRotatesWithinAuth(t *testing.T) { alias := "claude-opus-4.66" - executor := &openAICompatPoolExecutor{id: "pool"} + executor := &openAICompatPoolExecutor{id: openAICompatPoolProviderKey} m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ {Name: "deepseek-v3.1", Alias: alias}, {Name: "glm-5", Alias: alias}, }, executor) for i := 0; i < 2; i++ { - resp, err := m.ExecuteCount(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + resp, err := m.ExecuteCount(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute count %d: %v", i, err) } @@ -614,7 +616,7 @@ func TestManagerExecuteCount_OpenAICompatAliasPoolSkipsSuspendedUpstreamOnLaterR Message: "invalid_request_error: The requested model is unsupported.", } executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, countErrors: map[string]error{"deepseek-v3.1": modelSupportErr}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -623,7 +625,7 @@ func TestManagerExecuteCount_OpenAICompatAliasPoolSkipsSuspendedUpstreamOnLaterR }, executor) for i := 0; i < 3; i++ { - resp, err := m.ExecuteCount(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + resp, err := m.ExecuteCount(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute count %d: %v", i, err) } @@ -659,27 +661,27 @@ func TestManagerExecute_OpenAICompatAliasPoolBlockedAuthDoesNotConsumeRetryBudge m.SetConfig(cfg) m.SetRetryConfig(0, 0, 1) - executor := &authScopedOpenAICompatPoolExecutor{id: "pool"} + executor := &authScopedOpenAICompatPoolExecutor{id: openAICompatPoolProviderKey} m.RegisterExecutor(executor) badAuth := &Auth{ ID: "aa-blocked-auth", - Provider: "pool", + Provider: openAICompatPoolProviderKey, Status: StatusActive, Attributes: map[string]string{ "api_key": "bad-key", "compat_name": "pool", - "provider_key": "pool", + "provider_key": openAICompatPoolProviderKey, }, } goodAuth := &Auth{ ID: "bb-good-auth", - Provider: "pool", + Provider: openAICompatPoolProviderKey, Status: StatusActive, Attributes: map[string]string{ "api_key": "good-key", "compat_name": "pool", - "provider_key": "pool", + "provider_key": openAICompatPoolProviderKey, }, } if _, err := m.Register(context.Background(), badAuth); err != nil { @@ -690,8 +692,8 @@ func TestManagerExecute_OpenAICompatAliasPoolBlockedAuthDoesNotConsumeRetryBudge } reg := registry.GetGlobalRegistry() - reg.RegisterClient(badAuth.ID, "pool", []*registry.ModelInfo{{ID: alias}}) - reg.RegisterClient(goodAuth.ID, "pool", []*registry.ModelInfo{{ID: alias}}) + reg.RegisterClient(badAuth.ID, openAICompatPoolProviderKey, []*registry.ModelInfo{{ID: alias}}) + reg.RegisterClient(goodAuth.ID, openAICompatPoolProviderKey, []*registry.ModelInfo{{ID: alias}}) t.Cleanup(func() { reg.UnregisterClient(badAuth.ID) reg.UnregisterClient(goodAuth.ID) @@ -704,14 +706,14 @@ func TestManagerExecute_OpenAICompatAliasPoolBlockedAuthDoesNotConsumeRetryBudge for _, upstreamModel := range []string{"deepseek-v3.1", "glm-5"} { m.MarkResult(context.Background(), Result{ AuthID: badAuth.ID, - Provider: "pool", + Provider: openAICompatPoolProviderKey, Model: upstreamModel, Success: false, Error: modelSupportErr, }) } - resp, err := m.Execute(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + resp, err := m.Execute(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err != nil { t.Fatalf("execute error = %v, want success via fallback auth", err) } @@ -732,7 +734,7 @@ func TestManagerExecuteStream_OpenAICompatAliasPoolStopsOnInvalidBootstrap(t *te alias := "claude-opus-4.66" invalidErr := &Error{HTTPStatus: http.StatusBadRequest, Message: "invalid_request_error: malformed payload"} executor := &openAICompatPoolExecutor{ - id: "pool", + id: openAICompatPoolProviderKey, streamFirstErrors: map[string]error{"deepseek-v3.1": invalidErr}, } m := newOpenAICompatPoolTestManager(t, alias, []internalconfig.OpenAICompatibilityModel{ @@ -740,7 +742,7 @@ func TestManagerExecuteStream_OpenAICompatAliasPoolStopsOnInvalidBootstrap(t *te {Name: "glm-5", Alias: alias}, }, executor) - streamResult, err := m.ExecuteStream(context.Background(), []string{"pool"}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) + streamResult, err := m.ExecuteStream(context.Background(), []string{openAICompatPoolProviderKey}, cliproxyexecutor.Request{Model: alias}, cliproxyexecutor.Options{}) if err == nil { t.Fatal("expected invalid request error") } diff --git a/sdk/cliproxy/auth/scheduler.go b/sdk/cliproxy/auth/scheduler.go index 1bc3b74a..8c864221 100644 --- a/sdk/cliproxy/auth/scheduler.go +++ b/sdk/cliproxy/auth/scheduler.go @@ -491,7 +491,7 @@ func (s *authScheduler) upsertAuthLocked(auth *Auth, now time.Time) { return } authID := strings.TrimSpace(auth.ID) - providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) + providerKey := executorKeyFromAuth(auth) if authID == "" || providerKey == "" || auth.Disabled { s.removeAuthLocked(authID) return @@ -538,7 +538,7 @@ func (s *authScheduler) ensureProviderLocked(providerKey string) *providerSchedu // buildScheduledAuthMeta extracts the scheduling metadata needed for shard bookkeeping. func buildScheduledAuthMeta(auth *Auth) *scheduledAuthMeta { - providerKey := strings.ToLower(strings.TrimSpace(auth.Provider)) + providerKey := executorKeyFromAuth(auth) return &scheduledAuthMeta{ auth: auth, providerKey: providerKey, diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index b09334b4..3645e622 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -787,11 +787,16 @@ func openAICompatInfoFromAuth(a *coreauth.Auth) (providerKey string, compatName if providerKey == "" { providerKey = compatName } - return strings.ToLower(providerKey), compatName, true + return util.OpenAICompatibleProviderKey(providerKey), compatName, true } } if strings.EqualFold(strings.TrimSpace(a.Provider), "openai-compatibility") { - return "openai-compatibility", strings.TrimSpace(a.Label), true + compatName = strings.TrimSpace(a.Label) + providerKey = compatName + if providerKey == "" { + providerKey = "openai-compatibility" + } + return util.OpenAICompatibleProviderKey(providerKey), compatName, true } return "", "", false } diff --git a/sdk/cliproxy/service_executor_registration_test.go b/sdk/cliproxy/service_executor_registration_test.go index 4383fff6..5366fa09 100644 --- a/sdk/cliproxy/service_executor_registration_test.go +++ b/sdk/cliproxy/service_executor_registration_test.go @@ -6,6 +6,7 @@ import ( "testing" "github.com/router-for-me/CLIProxyAPI/v7/internal/pluginhost" + runtimeexecutor "github.com/router-for-me/CLIProxyAPI/v7/internal/runtime/executor" coreauth "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/auth" cliproxyexecutor "github.com/router-for-me/CLIProxyAPI/v7/sdk/cliproxy/executor" "github.com/router-for-me/CLIProxyAPI/v7/sdk/config" @@ -98,3 +99,64 @@ func TestRegisterAvailableExecutors(t *testing.T) { t.Fatalf("executor type = %T, want serviceTestPluginExecutor", resolved) } } + +func TestRegisterExecutorForAuth_OpenAICompatUsesNamespacedProviderKey(t *testing.T) { + testCases := []struct { + name string + auths []*coreauth.Auth + }{ + { + name: "native first", + auths: []*coreauth.Auth{ + {ID: "native-kimi", Provider: "kimi"}, + openAICompatKimiAuth(), + }, + }, + { + name: "compat first", + auths: []*coreauth.Auth{ + openAICompatKimiAuth(), + {ID: "native-kimi", Provider: "kimi"}, + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + service := &Service{ + cfg: &config.Config{}, + coreManager: coreauth.NewManager(nil, nil, nil), + } + + service.registerExecutorsForAuths(tt.auths, true) + + nativeExecutor, okNative := service.coreManager.Executor("kimi") + if !okNative { + t.Fatal("expected native kimi executor") + } + if _, okKimi := nativeExecutor.(*runtimeexecutor.KimiExecutor); !okKimi { + t.Fatalf("native executor type = %T, want *executor.KimiExecutor", nativeExecutor) + } + + compatExecutor, okCompat := service.coreManager.Executor("openai-compatible-kimi") + if !okCompat { + t.Fatal("expected namespaced OpenAI-compatible executor") + } + if _, okOpenAICompat := compatExecutor.(*runtimeexecutor.OpenAICompatExecutor); !okOpenAICompat { + t.Fatalf("compat executor type = %T, want *executor.OpenAICompatExecutor", compatExecutor) + } + }) + } +} + +func openAICompatKimiAuth() *coreauth.Auth { + return &coreauth.Auth{ + ID: "compat-kimi", + Provider: "openai-compatibility", + Label: "kimi", + Attributes: map[string]string{ + "compat_name": "kimi", + "provider_key": "kimi", + }, + } +}