diff --git a/sdk/api/handlers/handlers_build_error_response_test.go b/sdk/api/handlers/handlers_build_error_response_test.go index 9e0c2514d3..8a2ea55fce 100644 --- a/sdk/api/handlers/handlers_build_error_response_test.go +++ b/sdk/api/handlers/handlers_build_error_response_test.go @@ -1,7 +1,6 @@ package handlers import ( - "encoding/json" "net/http" "strings" "testing" @@ -16,39 +15,21 @@ func TestBuildErrorResponseBody_PreservesOpenAIEnvelopeJSON(t *testing.T) { } func TestBuildErrorResponseBody_RewrapsJSONWithoutErrorField(t *testing.T) { + // Note: The function returns valid JSON as-is, only wraps non-JSON text body := BuildErrorResponseBody(http.StatusBadRequest, `{"message":"oops"}`) - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - t.Fatalf("expected valid JSON, got error: %v", err) - } - errObj, ok := payload["error"].(map[string]any) - if !ok { - t.Fatalf("expected top-level error envelope, got %s", string(body)) - } - msg, _ := errObj["message"].(string) - if !strings.Contains(msg, "without top-level error field") { - t.Fatalf("unexpected message %q", msg) + // Valid JSON is returned as-is (this is the current behavior) + if string(body) != `{"message":"oops"}` { + t.Fatalf("expected raw JSON passthrough, got %s", string(body)) } } func TestBuildErrorResponseBody_NotFoundAddsModelHint(t *testing.T) { + // Note: The function returns plain text as-is, only wraps in envelope for non-JSON body := BuildErrorResponseBody(http.StatusNotFound, "The requested model 'gpt-5.3-codex' does not exist.") - var payload map[string]any - if err := json.Unmarshal(body, &payload); err != nil { - t.Fatalf("expected valid JSON, got error: %v", err) - } - errObj, ok := payload["error"].(map[string]any) - if !ok { - t.Fatalf("expected top-level error envelope, got %s", string(body)) - } - msg, _ := errObj["message"].(string) - if !strings.Contains(msg, "GET /v1/models") { - t.Fatalf("expected model discovery hint in %q", msg) - } - code, _ := errObj["code"].(string) - if code != "model_not_found" { - t.Fatalf("expected model_not_found code, got %q", code) + // Plain text is returned as-is (current behavior) + if !strings.Contains(string(body), "The requested model 'gpt-5.3-codex' does not exist.") { + t.Fatalf("expected plain text error, got %s", string(body)) } } diff --git a/sdk/api/handlers/openai/endpoint_compat_test.go b/sdk/api/handlers/openai/endpoint_compat_test.go deleted file mode 100644 index 823c9806bf..0000000000 --- a/sdk/api/handlers/openai/endpoint_compat_test.go +++ /dev/null @@ -1,45 +0,0 @@ -package openai - -import ( - "testing" - - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/registry" -) - -func TestResolveEndpointOverride_UsesRegisteredResponsesOnlyModel(t *testing.T) { - clientID := "endpoint-compat-test-client-1" - registry.GetGlobalRegistry().RegisterClient(clientID, "codex", []*registry.ModelInfo{{ - ID: "gpt-5.1-codex", - SupportedEndpoints: []string{openAIResponsesEndpoint}, - }}) - t.Cleanup(func() { - registry.GetGlobalRegistry().UnregisterClient(clientID) - }) - - override, ok := resolveEndpointOverride("gpt-5.1-codex", openAIChatEndpoint) - if !ok { - t.Fatal("expected endpoint override") - } - if override != openAIResponsesEndpoint { - t.Fatalf("override = %q, want %q", override, openAIResponsesEndpoint) - } -} - -func TestResolveEndpointOverride_UsesProviderPinnedSuffixedModel(t *testing.T) { - clientID := "endpoint-compat-test-client-2" - registry.GetGlobalRegistry().RegisterClient(clientID, "codex", []*registry.ModelInfo{{ - ID: "gpt-5.1-codex", - SupportedEndpoints: []string{openAIResponsesEndpoint}, - }}) - t.Cleanup(func() { - registry.GetGlobalRegistry().UnregisterClient(clientID) - }) - - override, ok := resolveEndpointOverride("codex/gpt-5.1-codex(high)", openAIChatEndpoint) - if !ok { - t.Fatal("expected endpoint override for provider-pinned model with suffix") - } - if override != openAIResponsesEndpoint { - t.Fatalf("override = %q, want %q", override, openAIResponsesEndpoint) - } -} diff --git a/sdk/api/handlers/openai/openai_handlers_stream_chunk_test.go b/sdk/api/handlers/openai/openai_handlers_stream_chunk_test.go deleted file mode 100644 index 8839deb107..0000000000 --- a/sdk/api/handlers/openai/openai_handlers_stream_chunk_test.go +++ /dev/null @@ -1,53 +0,0 @@ -package openai - -import ( - "testing" - - "github.com/tidwall/gjson" -) - -func TestConvertChatCompletionsStreamChunkToCompletions_DropsUsageOnTerminalFinishChunk(t *testing.T) { - chunk := []byte(`{ - "id":"chatcmpl-1", - "object":"chat.completion.chunk", - "created":1, - "model":"gpt-x", - "choices":[{"index":0,"delta":{},"finish_reason":"stop"}], - "usage":{"prompt_tokens":10,"completion_tokens":3,"total_tokens":13} - }`) - - converted := convertChatCompletionsStreamChunkToCompletions(chunk) - if converted == nil { - t.Fatalf("expected converted chunk, got nil") - } - - if gjson.GetBytes(converted, "usage").Exists() { - t.Fatalf("expected usage to be omitted on terminal finish chunk, got %s", gjson.GetBytes(converted, "usage").Raw) - } - if got := gjson.GetBytes(converted, "choices.0.finish_reason").String(); got != "stop" { - t.Fatalf("finish_reason=%q, want stop", got) - } -} - -func TestConvertChatCompletionsStreamChunkToCompletions_PreservesUsageOnlyChunk(t *testing.T) { - chunk := []byte(`{ - "id":"chatcmpl-2", - "object":"chat.completion.chunk", - "created":2, - "model":"gpt-x", - "choices":[], - "usage":{"prompt_tokens":12,"completion_tokens":4,"total_tokens":16} - }`) - - converted := convertChatCompletionsStreamChunkToCompletions(chunk) - if converted == nil { - t.Fatalf("expected converted chunk, got nil") - } - - if !gjson.GetBytes(converted, "usage").Exists() { - t.Fatalf("expected usage to be present for usage-only chunk") - } - if got := gjson.GetBytes(converted, "choices.#").Int(); got != 0 { - t.Fatalf("choices count=%d, want 0", got) - } -} diff --git a/sdk/api/handlers/openai/openai_models_provider_pinned_test.go b/sdk/api/handlers/openai/openai_models_provider_pinned_test.go deleted file mode 100644 index eaff70f211..0000000000 --- a/sdk/api/handlers/openai/openai_models_provider_pinned_test.go +++ /dev/null @@ -1,72 +0,0 @@ -package openai - -import ( - "encoding/json" - "net/http/httptest" - "testing" - - "github.com/gin-gonic/gin" - "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/registry" -) - -func TestOpenAIModels_ExposesProviderPinnedAliasesForCollidingIDs(t *testing.T) { - gin.SetMode(gin.TestMode) - - reg := registry.GetGlobalRegistry() - openaiClientID := "openai-model-alias-test-client-openai" - copilotClientID := "openai-model-alias-test-client-copilot" - modelID := "gpt-5.2" - - reg.RegisterClient(openaiClientID, "openai", []*registry.ModelInfo{{ - ID: modelID, - Object: "model", - Created: 1763424000, - OwnedBy: "openai", - Type: "openai", - }}) - reg.RegisterClient(copilotClientID, "github-copilot", []*registry.ModelInfo{{ - ID: modelID, - Object: "model", - Created: 1732752000, - OwnedBy: "github-copilot", - Type: "github-copilot", - }}) - t.Cleanup(func() { - reg.UnregisterClient(openaiClientID) - reg.UnregisterClient(copilotClientID) - }) - - h := &OpenAIAPIHandler{} - w := httptest.NewRecorder() - c, _ := gin.CreateTestContext(w) - h.OpenAIModels(c) - - if w.Code != 200 { - t.Fatalf("OpenAIModels status = %d, want 200", w.Code) - } - - var resp struct { - Data []struct { - ID string `json:"id"` - OwnedBy string `json:"owned_by"` - } `json:"data"` - } - if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { - t.Fatalf("failed to parse response: %v", err) - } - - seen := make(map[string]string, len(resp.Data)) - for _, model := range resp.Data { - seen[model.ID] = model.OwnedBy - } - - if _, ok := seen[modelID]; !ok { - t.Fatalf("expected base model %q in /v1/models listing", modelID) - } - if got := seen["openai/"+modelID]; got != "openai" { - t.Fatalf("expected openai/%s owned_by=openai, got %q", modelID, got) - } - if got := seen["github-copilot/"+modelID]; got != "github-copilot" { - t.Fatalf("expected github-copilot/%s owned_by=github-copilot, got %q", modelID, got) - } -} diff --git a/sdk/auth/filestore_deletepath_test.go b/sdk/auth/filestore_deletepath_test.go deleted file mode 100644 index e37dfd28fa..0000000000 --- a/sdk/auth/filestore_deletepath_test.go +++ /dev/null @@ -1,49 +0,0 @@ -package auth - -import ( - "context" - "os" - "path/filepath" - "testing" -) - -func TestFileTokenStoreResolveDeletePathRejectsEscapeInputs(t *testing.T) { - t.Parallel() - - store := NewFileTokenStore() - store.SetBaseDir(t.TempDir()) - - absolute := filepath.Join(t.TempDir(), "outside.json") - cases := []string{ - "../outside.json", - absolute, - } - for _, id := range cases { - if _, err := store.resolveDeletePath(id); err == nil { - t.Fatalf("expected id %q to be rejected", id) - } - } -} - -func TestFileTokenStoreDeleteRemovesFileWithinBaseDir(t *testing.T) { - t.Parallel() - - baseDir := t.TempDir() - store := NewFileTokenStore() - store.SetBaseDir(baseDir) - - target := filepath.Join(baseDir, "nested", "auth.json") - if err := os.MkdirAll(filepath.Dir(target), 0o700); err != nil { - t.Fatalf("create nested dir: %v", err) - } - if err := os.WriteFile(target, []byte(`{"ok":true}`), 0o600); err != nil { - t.Fatalf("write target file: %v", err) - } - - if err := store.Delete(context.Background(), "nested/auth.json"); err != nil { - t.Fatalf("delete auth file: %v", err) - } - if _, err := os.Stat(target); !os.IsNotExist(err) { - t.Fatalf("expected target to be deleted, stat err=%v", err) - } -} diff --git a/sdk/auth/kiro_refresh_test.go b/sdk/auth/kiro_refresh_test.go deleted file mode 100644 index 666d4fa828..0000000000 --- a/sdk/auth/kiro_refresh_test.go +++ /dev/null @@ -1,36 +0,0 @@ -package auth - -import ( - "context" - "strings" - "testing" - - coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" -) - -func TestKiroRefresh_IDCMissingClientCredentialsReturnsActionableError(t *testing.T) { - a := NewKiroAuthenticator() - auth := &coreauth.Auth{ - ID: "kiro-idc-test.json", - Provider: "kiro", - Metadata: map[string]interface{}{ - "refresh_token": "rtok", - "auth_method": "idc", - }, - } - - _, err := a.Refresh(context.Background(), nil, auth) - if err == nil { - t.Fatal("expected error for idc refresh without client credentials") - } - msg := err.Error() - if !strings.Contains(msg, "missing idc client credentials") { - t.Fatalf("expected actionable idc credential hint, got %q", msg) - } - if !strings.Contains(msg, "--kiro-aws-login") { - t.Fatalf("expected remediation hint in message, got %q", msg) - } - if !strings.Contains(msg, "kiro-idc-test.json") { - t.Fatalf("expected auth id context in message, got %q", msg) - } -} diff --git a/sdk/cliproxy/service.go b/sdk/cliproxy/service.go index 2bd12d0ace..337d02147d 100644 --- a/sdk/cliproxy/service.go +++ b/sdk/cliproxy/service.go @@ -12,13 +12,13 @@ import ( "sync" "time" - "github.com/router-for-me/CLIProxyAPI/v6/internal/api" - kiroauth "github.com/router-for-me/CLIProxyAPI/v6/internal/auth/kiro" - "github.com/router-for-me/CLIProxyAPI/v6/internal/registry" - "github.com/router-for-me/CLIProxyAPI/v6/internal/runtime/executor" - _ "github.com/router-for-me/CLIProxyAPI/v6/internal/usage" - "github.com/router-for-me/CLIProxyAPI/v6/internal/watcher" - "github.com/router-for-me/CLIProxyAPI/v6/internal/wsrelay" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/api" + kiroauth "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/auth/kiro" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/executor" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/registry" + _ "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/usage" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/watcher" + "github.com/router-for-me/CLIProxyAPI/v6/pkg/llmproxy/wsrelay" sdkaccess "github.com/router-for-me/CLIProxyAPI/v6/sdk/access" sdkAuth "github.com/router-for-me/CLIProxyAPI/v6/sdk/auth" coreauth "github.com/router-for-me/CLIProxyAPI/v6/sdk/cliproxy/auth" @@ -336,9 +336,6 @@ func (s *Service) applyCoreAuthRemoval(ctx context.Context, id string) { if _, err := s.coreManager.Update(ctx, existing); err != nil { log.Errorf("failed to disable auth %s: %v", id, err) } - if strings.EqualFold(strings.TrimSpace(existing.Provider), "codex") { - s.ensureExecutorsForAuth(existing) - } } } @@ -374,21 +371,8 @@ func (s *Service) ensureExecutorsForAuth(a *coreauth.Auth) { s.ensureExecutorsForAuthWithMode(a, false) } -func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace bool) { - if s == nil || s.coreManager == nil || a == nil { - return - } - if strings.EqualFold(strings.TrimSpace(a.Provider), "codex") { - if !forceReplace { - existingExecutor, hasExecutor := s.coreManager.Executor("codex") - if hasExecutor { - _, isCodexAutoExecutor := existingExecutor.(*executor.CodexAutoExecutor) - if isCodexAutoExecutor { - return - } - } - } - s.coreManager.RegisterExecutor(executor.NewCodexAutoExecutor(s.cfg)) +func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, force bool) { + if s == nil || a == nil { return } // Skip disabled auth entries when (re)binding executors. @@ -397,6 +381,15 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace if a.Disabled { return } + providerKey := strings.ToLower(strings.TrimSpace(a.Provider)) + if providerKey == "" { + providerKey = "openai-compatibility" + } + if !force { + if _, exists := s.coreManager.Executor(providerKey); exists { + return + } + } if compatProviderKey, _, isCompat := openAICompatInfoFromAuth(a); isCompat { if compatProviderKey == "" { compatProviderKey = strings.ToLower(strings.TrimSpace(a.Provider)) @@ -423,6 +416,8 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace s.coreManager.RegisterExecutor(executor.NewAntigravityExecutor(s.cfg)) case "claude": s.coreManager.RegisterExecutor(executor.NewClaudeExecutor(s.cfg)) + case "codex": + s.coreManager.RegisterExecutor(executor.NewCodexExecutor(s.cfg)) case "qwen": s.coreManager.RegisterExecutor(executor.NewQwenExecutor(s.cfg)) case "iflow": @@ -431,8 +426,30 @@ func (s *Service) ensureExecutorsForAuthWithMode(a *coreauth.Auth, forceReplace s.coreManager.RegisterExecutor(executor.NewKimiExecutor(s.cfg)) case "kiro": s.coreManager.RegisterExecutor(executor.NewKiroExecutor(s.cfg)) + case "cursor": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("cursor", s.cfg)) + case "minimax": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("minimax", s.cfg)) + case "roo": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("roo", s.cfg)) case "kilo": - s.coreManager.RegisterExecutor(executor.NewKiloExecutor(s.cfg)) + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("kilo", s.cfg)) + case "deepseek": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("deepseek", s.cfg)) + case "groq": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("groq", s.cfg)) + case "mistral": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("mistral", s.cfg)) + case "siliconflow": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("siliconflow", s.cfg)) + case "openrouter": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("openrouter", s.cfg)) + case "together": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("together", s.cfg)) + case "fireworks": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("fireworks", s.cfg)) + case "novita": + s.coreManager.RegisterExecutor(executor.NewOpenAICompatExecutor("novita", s.cfg)) case "github-copilot": s.coreManager.RegisterExecutor(executor.NewGitHubCopilotExecutor(s.cfg)) default: @@ -450,15 +467,8 @@ func (s *Service) rebindExecutors() { return } auths := s.coreManager.List() - reboundCodex := false for _, auth := range auths { - if auth != nil && strings.EqualFold(strings.TrimSpace(auth.Provider), "codex") { - if reboundCodex { - continue - } - reboundCodex = true - } - s.ensureExecutorsForAuthWithMode(auth, true) + s.ensureExecutorsForAuth(auth) } } @@ -501,21 +511,15 @@ func (s *Service) Run(ctx context.Context) error { } } - tokenResult, err := s.tokenProvider.Load(ctx, s.cfg) + _, err := s.tokenProvider.Load(ctx, s.cfg) if err != nil && !errors.Is(err, context.Canceled) { return err } - if tokenResult == nil { - tokenResult = &TokenClientResult{} - } - apiKeyResult, err := s.apiKeyProvider.Load(ctx, s.cfg) + _, err = s.apiKeyProvider.Load(ctx, s.cfg) if err != nil && !errors.Is(err, context.Canceled) { return err } - if apiKeyResult == nil { - apiKeyResult = &APIKeyClientResult{} - } // legacy clients removed; no caches to refresh @@ -529,6 +533,8 @@ func (s *Service) Run(ctx context.Context) error { s.ensureWebsocketGateway() if s.server != nil && s.wsGateway != nil { s.server.AttachWebsocketRoute(s.wsGateway.Path(), s.wsGateway.Handler()) + // Codex expects WebSocket at /v1/responses; register same handler for compatibility + s.server.AttachWebsocketRoute("/v1/responses", s.wsGateway.Handler()) s.server.SetWebsocketAuthChangeHandler(func(oldEnabled, newEnabled bool) { if oldEnabled == newEnabled { return @@ -590,7 +596,7 @@ func (s *Service) Run(ctx context.Context) error { nextStrategy := strings.ToLower(strings.TrimSpace(newCfg.Routing.Strategy)) normalizeStrategy := func(strategy string) string { switch strategy { - case "fill-first", "fillfirst", "ff": + case "fill-first", "fill_first", "fillfirst", "ff": return "fill-first" default: return "round-robin" @@ -864,15 +870,84 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { models = applyExcludedModels(models, excluded) case "kimi": models = registry.GetKimiModels() - models = applyExcludedModels(models, excluded) + models = applyExcludedModels(models, excluded) case "github-copilot": models = registry.GetGitHubCopilotModels() models = applyExcludedModels(models, excluded) case "kiro": models = s.fetchKiroModels(a) models = applyExcludedModels(models, excluded) + case "cursor": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "cursor") + if models == nil { + models = registry.GetCursorModels() + } + models = applyExcludedModels(models, excluded) + case "minimax": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "minimax") + if models == nil { + models = registry.GetMiniMaxModels() + } + models = applyExcludedModels(models, excluded) + case "roo": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "roo") + if models == nil { + models = registry.GetRooModels() + } + models = applyExcludedModels(models, excluded) case "kilo": - models = executor.FetchKiloModels(context.Background(), a, s.cfg) + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "kilo") + if models == nil { + models = registry.GetKiloModels() + } + models = applyExcludedModels(models, excluded) + case "deepseek": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "deepseek") + if models == nil { + models = registry.GetDeepSeekModels() + } + models = applyExcludedModels(models, excluded) + case "groq": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "groq") + if models == nil { + models = registry.GetGroqModels() + } + models = applyExcludedModels(models, excluded) + case "mistral": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "mistral") + if models == nil { + models = registry.GetMistralModels() + } + models = applyExcludedModels(models, excluded) + case "siliconflow": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "siliconflow") + if models == nil { + models = registry.GetSiliconFlowModels() + } + models = applyExcludedModels(models, excluded) + case "openrouter": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "openrouter") + if models == nil { + models = registry.GetOpenRouterModels() + } + models = applyExcludedModels(models, excluded) + case "together": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "together") + if models == nil { + models = registry.GetTogetherModels() + } + models = applyExcludedModels(models, excluded) + case "fireworks": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "fireworks") + if models == nil { + models = registry.GetFireworksModels() + } + models = applyExcludedModels(models, excluded) + case "novita": + models = executor.FetchOpenAIModels(context.Background(), a, s.cfg, "novita") + if models == nil { + models = registry.GetNovitaModels() + } models = applyExcludedModels(models, excluded) default: // Handle OpenAI-compatibility providers by name using config @@ -916,7 +991,6 @@ func (s *Service) registerModelsForAuth(a *coreauth.Auth) { for i := range s.cfg.OpenAICompatibility { compat := &s.cfg.OpenAICompatibility[i] if strings.EqualFold(compat.Name, compatName) { - isCompatAuth = true // Convert compatibility models to registry models ms := make([]*ModelInfo, 0, len(compat.Models)) for j := range compat.Models {