diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts index 8d154d794f26..b9a992c10927 100644 --- a/src/api/providers/__tests__/claude-code.spec.ts +++ b/src/api/providers/__tests__/claude-code.spec.ts @@ -1,18 +1,32 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest" import { ClaudeCodeHandler } from "../claude-code" import { ApiHandlerOptions } from "../../../shared/api" import { ClaudeCodeMessage } from "../../../integrations/claude-code/types" +import { runClaudeCode } from "../../../integrations/claude-code/run" +import { t } from "../../../i18n" // Mock the runClaudeCode function vi.mock("../../../integrations/claude-code/run", () => ({ runClaudeCode: vi.fn(), })) +vi.mock("../../../i18n", () => ({ + t: vi.fn((key: string, options?: any) => { + if (key === "common:errors.claudeCode.authenticationError") { + return `Claude Code authentication failed. Original error: ${options?.originalError || "unknown"}` + } + if (key === "common:errors.claudeCode.apiKeyModelPlanMismatch") { + return "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." + } + return key + }), +})) + // Mock the message filter vi.mock("../../../integrations/claude-code/message-filter", () => ({ filterMessagesForClaudeCode: vi.fn((messages) => messages), })) -const { runClaudeCode } = await import("../../../integrations/claude-code/run") const { filterMessagesForClaudeCode } = await import("../../../integrations/claude-code/message-filter") const mockRunClaudeCode = vi.mocked(runClaudeCode) const mockFilterMessages = vi.mocked(filterMessagesForClaudeCode) @@ -563,3 +577,151 @@ describe("ClaudeCodeHandler", () => { consoleSpy.mockRestore() }) }) + +describe("ClaudeCodeHandler Authentication Error Handling", () => { + let handler: ClaudeCodeHandler + + beforeEach(() => { + vi.clearAllMocks() + handler = new ClaudeCodeHandler({ + claudeCodePath: "claude", + apiModelId: "claude-3-5-sonnet-20241022", + } as ApiHandlerOptions) + }) + + afterEach(() => { + vi.clearAllMocks() + }) + + it("should detect and handle authentication errors from API response", async () => { + const mockGenerator = async function* (): AsyncGenerator { + yield { + type: "assistant" as const, + message: { + id: "msg_123", + type: "message", + role: "assistant", + model: "claude-3-5-sonnet-20241022", + content: [ + { + type: "text", + text: 'API Error: 401 {"error":{"message":"Authentication failed. Please login with claude login"}}', + }, + ], + stop_reason: "stop", + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + } as any, + session_id: "session_123", + } + } + + mockRunClaudeCode.mockReturnValue(mockGenerator()) + + const messages: any[] = [{ role: "user", content: "test" }] + const generator = handler.createMessage("system", messages) + + await expect(async () => { + for await (const _ of generator) { + // consume generator + } + }).rejects.toThrow("Claude Code authentication failed") + }) + + it("should detect various authentication error patterns", async () => { + const authErrorMessages = [ + "API Error: 403 Unauthorized", + "API Error: Invalid API key", + "API Error: Credential expired", + "API Error: Login required", + "API Error: Not authenticated", + ] + + for (const errorMessage of authErrorMessages) { + const mockGenerator = async function* (): AsyncGenerator { + yield { + type: "assistant" as const, + message: { + id: "msg_123", + type: "message", + role: "assistant", + model: "claude-3-5-sonnet-20241022", + content: [ + { + type: "text", + text: errorMessage, + }, + ], + stop_reason: "stop", + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + } as any, + session_id: "session_123", + } + } + + mockRunClaudeCode.mockReturnValue(mockGenerator()) + + const messages: any[] = [{ role: "user", content: "test" }] + const generator = handler.createMessage("system", messages) + + await expect(async () => { + for await (const _ of generator) { + // consume generator + } + }).rejects.toThrow("Claude Code authentication failed") + + vi.clearAllMocks() + } + }) + + it("should not treat non-authentication errors as authentication errors", async () => { + const mockGenerator = async function* (): AsyncGenerator { + yield { + type: "assistant" as const, + message: { + id: "msg_123", + type: "message", + role: "assistant", + model: "claude-3-5-sonnet-20241022", + content: [ + { + type: "text", + text: "API Error: 500 Internal Server Error", + }, + ], + stop_reason: "stop", + stop_sequence: null, + usage: { + input_tokens: 100, + output_tokens: 50, + cache_read_input_tokens: 0, + cache_creation_input_tokens: 0, + }, + } as any, + session_id: "session_123", + } + } + + mockRunClaudeCode.mockReturnValue(mockGenerator()) + + const messages: any[] = [{ role: "user", content: "test" }] + const generator = handler.createMessage("system", messages) + + await expect(async () => { + for await (const _ of generator) { + // consume generator + } + }).rejects.toThrow("API Error: 500 Internal Server Error") + }) +}) diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index dfafb78aabaf..947e5351eb1a 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -82,9 +82,26 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler { const error = this.attemptParse(errorMessage) if (!error) { + // Check for authentication errors in raw message + if (this.isAuthenticationError(content.text)) { + throw new Error( + t("common:errors.claudeCode.authenticationError", { + originalError: content.text, + }), + ) + } throw new Error(content.text) } + // Check for authentication-related errors + if (this.isAuthenticationError(error.error?.message || errorMessage)) { + throw new Error( + t("common:errors.claudeCode.authenticationError", { + originalError: error.error?.message || errorMessage, + }), + ) + } + if (error.error.message.includes("Invalid model name")) { throw new Error( content.text + `\n\n${t("common:errors.claudeCode.apiKeyModelPlanMismatch")}`, @@ -172,4 +189,22 @@ export class ClaudeCodeHandler extends BaseProvider implements ApiHandler { return null } } + + private isAuthenticationError(message: string): boolean { + const authErrorPatterns = [ + "authentication failed", + "unauthorized", + "not authenticated", + "login required", + "invalid api key", + "api key expired", + "credential", + "auth error", + "403", + "401", + ] + + const lowerMessage = message.toLowerCase() + return authErrorPatterns.some((pattern) => lowerMessage.includes(pattern)) + } } diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index 784540e06f79..d9efb8bbc1d9 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -92,7 +92,8 @@ "processExitedWithError": "Claude Code process exited with code {{exitCode}}. Error output: {{output}}", "stoppedWithReason": "Claude Code stopped with reason: {{reason}}", "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan.", - "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}" + "notFound": "Claude Code executable '{{claudePath}}' not found.\n\nPlease install Claude Code CLI:\n1. Visit {{installationUrl}} to download Claude Code\n2. Follow the installation instructions for your operating system\n3. Ensure the 'claude' command is available in your PATH\n4. Alternatively, configure a custom path in Roo settings under 'Claude Code Path'\n\nOriginal error: {{originalError}}", + "authenticationError": "Claude Code authentication failed.\n\nTo authenticate with Claude Code CLI:\n1. Open a terminal/command prompt\n2. Run: claude login\n3. Follow the authentication prompts in your browser\n4. Once authenticated, try again in VS Code\n\nIf you're still having issues:\n• Ensure you're using Claude Code CLI v2.0.30 or later\n• Try running: claude logout && claude login\n• Check if your API key has expired at https://console.anthropic.com\n\nOriginal error: {{originalError}}" }, "message": { "no_active_task_to_delete": "No active task to delete messages from", diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts index 1d617b9242be..d7586d906f0b 100644 --- a/src/integrations/claude-code/run.ts +++ b/src/integrations/claude-code/run.ts @@ -108,6 +108,16 @@ export async function* runClaudeCode( } const errorOutput = (processState.error as any)?.message || processState.stderrLogs?.trim() + + // Check for authentication errors in stderr or error output + if (errorOutput && isAuthenticationError(errorOutput)) { + throw new Error( + t("common:errors.claudeCode.authenticationError", { + originalError: errorOutput, + }), + ) + } + throw new Error( `Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput}` : ""}`, ) @@ -273,3 +283,26 @@ function createClaudeCodeNotFoundError(claudePath: string, originalError: Error) error.name = "ClaudeCodeNotFoundError" return error } + +/** + * Checks if an error message indicates an authentication issue + */ +function isAuthenticationError(message: string): boolean { + const authErrorPatterns = [ + "authentication failed", + "unauthorized", + "not authenticated", + "login required", + "invalid api key", + "api key expired", + "credential", + "auth error", + "403", + "401", + "please authenticate", + "claude login", + ] + + const lowerMessage = message.toLowerCase() + return authErrorPatterns.some((pattern) => lowerMessage.includes(pattern)) +}