Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 163 additions & 1 deletion src/api/providers/__tests__/claude-code.spec.ts
Original file line number Diff line number Diff line change
@@ -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)
Expand Down Expand Up @@ -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<ClaudeCodeMessage | string> {
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<ClaudeCodeMessage | string> {
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<ClaudeCodeMessage | string> {
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")
})
})
35 changes: 35 additions & 0 deletions src/api/providers/claude-code.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")}`,
Expand Down Expand Up @@ -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))
}
Comment on lines +193 to +209
Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isAuthenticationError() function is duplicated here and in src/integrations/claude-code/run.ts (lines 290-308) with inconsistent pattern lists. The run.ts version includes two additional patterns: "please authenticate" and "claude login". Consider extracting this to a shared utility function to maintain consistency and reduce duplication.

}
3 changes: 2 additions & 1 deletion src/i18n/locales/en/common.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
33 changes: 33 additions & 0 deletions src/integrations/claude-code/run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}` : ""}`,
)
Expand Down Expand Up @@ -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))
}
Loading