From 812e340f7a68fb6f1291d452a82ff425f08eb5e1 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 13 Dec 2025 21:18:04 -0700 Subject: [PATCH 01/16] fix: Claude Code Opus support --- .../providers/__tests__/claude-code.spec.ts | 18 +- packages/types/src/providers/claude-code.ts | 199 ++-- .../providers/__tests__/claude-code.spec.ts | 565 +++++------ src/api/providers/claude-code.ts | 337 ++++--- src/api/transform/stream.ts | 27 + src/core/task/Task.ts | 32 +- src/core/webview/ClineProvider.ts | 8 + src/core/webview/webviewMessageHandler.ts | 70 ++ src/extension.ts | 4 + .../__tests__/message-filter.spec.ts | 263 ------ .../claude-code/__tests__/oauth.spec.ts | 198 ++++ .../__tests__/streaming-client.spec.ts | 740 +++++++++++++++ .../claude-code/message-filter.ts | 35 - src/integrations/claude-code/oauth.ts | 479 ++++++++++ .../claude-code/streaming-client.ts | 883 ++++++++++++++++++ src/shared/ExtensionMessage.ts | 2 + src/shared/WebviewMessage.ts | 3 + .../src/components/settings/ApiOptions.tsx | 6 +- .../src/components/settings/ModelInfoView.tsx | 7 +- .../src/components/settings/ModelPicker.tsx | 31 +- .../settings/providers/ClaudeCode.tsx | 95 +- .../ClaudeCodeRateLimitDashboard.tsx | 181 ++++ webview-ui/src/components/ui/select.tsx | 2 +- 23 files changed, 3263 insertions(+), 922 deletions(-) delete mode 100644 src/integrations/claude-code/__tests__/message-filter.spec.ts create mode 100644 src/integrations/claude-code/__tests__/oauth.spec.ts create mode 100644 src/integrations/claude-code/__tests__/streaming-client.spec.ts delete mode 100644 src/integrations/claude-code/message-filter.ts create mode 100644 src/integrations/claude-code/oauth.ts create mode 100644 src/integrations/claude-code/streaming-client.ts create mode 100644 webview-ui/src/components/settings/providers/ClaudeCodeRateLimitDashboard.tsx diff --git a/packages/types/src/providers/__tests__/claude-code.spec.ts b/packages/types/src/providers/__tests__/claude-code.spec.ts index a73912d44ac..51ac07ab63e 100644 --- a/packages/types/src/providers/__tests__/claude-code.spec.ts +++ b/packages/types/src/providers/__tests__/claude-code.spec.ts @@ -23,18 +23,20 @@ describe("convertModelNameForVertex", () => { describe("getClaudeCodeModelId", () => { test("should return original model when useVertex is false", () => { - expect(getClaudeCodeModelId("claude-sonnet-4-20250514", false)).toBe("claude-sonnet-4-20250514") - expect(getClaudeCodeModelId("claude-opus-4-20250514", false)).toBe("claude-opus-4-20250514") - expect(getClaudeCodeModelId("claude-3-7-sonnet-20250219", false)).toBe("claude-3-7-sonnet-20250219") + // Use valid ClaudeCodeModelId values - they don't have date suffixes + expect(getClaudeCodeModelId("claude-sonnet-4-5", false)).toBe("claude-sonnet-4-5") + expect(getClaudeCodeModelId("claude-opus-4-5", false)).toBe("claude-opus-4-5") + expect(getClaudeCodeModelId("claude-haiku-4-5", false)).toBe("claude-haiku-4-5") }) - test("should return converted model when useVertex is true", () => { - expect(getClaudeCodeModelId("claude-sonnet-4-20250514", true)).toBe("claude-sonnet-4@20250514") - expect(getClaudeCodeModelId("claude-opus-4-20250514", true)).toBe("claude-opus-4@20250514") - expect(getClaudeCodeModelId("claude-3-7-sonnet-20250219", true)).toBe("claude-3-7-sonnet@20250219") + test("should return same model when useVertex is true (no date suffix to convert)", () => { + // Valid ClaudeCodeModelIds don't have 8-digit date suffixes, so no conversion happens + expect(getClaudeCodeModelId("claude-sonnet-4-5", true)).toBe("claude-sonnet-4-5") + expect(getClaudeCodeModelId("claude-opus-4-5", true)).toBe("claude-opus-4-5") + expect(getClaudeCodeModelId("claude-haiku-4-5", true)).toBe("claude-haiku-4-5") }) test("should default to useVertex false when parameter not provided", () => { - expect(getClaudeCodeModelId("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-20250514") + expect(getClaudeCodeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4-5") }) }) diff --git a/packages/types/src/providers/claude-code.ts b/packages/types/src/providers/claude-code.ts index 988617e515f..dcf93b693c8 100644 --- a/packages/types/src/providers/claude-code.ts +++ b/packages/types/src/providers/claude-code.ts @@ -1,5 +1,41 @@ import type { ModelInfo } from "../model.js" -import { anthropicModels } from "./anthropic.js" + +/** + * Rate limit information from Claude Code API + */ +export interface ClaudeCodeRateLimitInfo { + // 5-hour limit info + fiveHour: { + status: string + utilization: number + resetTime: number // Unix timestamp + } + // 7-day (weekly) limit info (Sonnet-specific) + weekly?: { + status: string + utilization: number + resetTime: number // Unix timestamp + } + // 7-day unified limit info + weeklyUnified?: { + status: string + utilization: number + resetTime: number // Unix timestamp + } + // Representative claim type + representativeClaim?: string + // Overage status + overage?: { + status: string + disabledReason?: string + } + // Fallback percentage + fallbackPercentage?: number + // Organization ID + organizationId?: string + // Timestamp when this was fetched + fetchedAt: number +} // Regex pattern to match 8-digit date at the end of model names const VERTEX_DATE_PATTERN = /-(\d{8})$/ @@ -19,11 +55,29 @@ export function convertModelNameForVertex(modelName: string): string { return modelName.replace(VERTEX_DATE_PATTERN, "@$1") } -// Claude Code +// Claude Code - Only models that work with Claude Code OAuth tokens export type ClaudeCodeModelId = keyof typeof claudeCodeModels export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5" export const CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS = 16000 +/** + * Reasoning effort configuration for Claude Code thinking mode. + * Maps reasoning effort level to budget_tokens for the thinking process. + * + * Note: With interleaved thinking (enabled via beta header), budget_tokens + * can exceed max_tokens as the token limit becomes the entire context window. + * The max_tokens is drawn from the model's maxTokens definition. + * + * @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking + */ +export const claudeCodeReasoningConfig = { + low: { budgetTokens: 16_000 }, + medium: { budgetTokens: 32_000 }, + high: { budgetTokens: 64_000 }, +} as const + +export type ClaudeCodeReasoningLevel = keyof typeof claudeCodeReasoningConfig + /** * Gets the appropriate model ID based on whether Vertex AI is being used. * @@ -39,116 +93,41 @@ export function getClaudeCodeModelId(baseModelId: ClaudeCodeModelId, useVertex = return useVertex ? convertModelNameForVertex(baseModelId) : baseModelId } +// Models that work with Claude Code OAuth tokens +// See: https://docs.anthropic.com/en/docs/claude-code +// NOTE: Claude Code is subscription-based with no per-token cost - pricing fields are 0 export const claudeCodeModels = { - "claude-sonnet-4-5": { - ...anthropicModels["claude-sonnet-4-5"], - supportsImages: false, - supportsPromptCache: true, // Claude Code does report cache tokens - supportsReasoningEffort: false, - supportsReasoningBudget: false, - requiredReasoningBudget: false, - // Claude Code manages its own tools and temperature via the CLI - supportsNativeTools: false, - supportsTemperature: false, - }, - "claude-sonnet-4-5-20250929[1m]": { - ...anthropicModels["claude-sonnet-4-5"], - contextWindow: 1_000_000, // 1M token context window (requires [1m] suffix) - supportsImages: false, - supportsPromptCache: true, // Claude Code does report cache tokens - supportsReasoningEffort: false, - supportsReasoningBudget: false, - requiredReasoningBudget: false, - // Claude Code manages its own tools and temperature via the CLI - supportsNativeTools: false, - supportsTemperature: false, - }, - "claude-sonnet-4-20250514": { - ...anthropicModels["claude-sonnet-4-20250514"], - supportsImages: false, - supportsPromptCache: true, // Claude Code does report cache tokens - supportsReasoningEffort: false, - supportsReasoningBudget: false, - requiredReasoningBudget: false, - // Claude Code manages its own tools and temperature via the CLI - supportsNativeTools: false, - supportsTemperature: false, - }, - "claude-opus-4-5-20251101": { - ...anthropicModels["claude-opus-4-5-20251101"], - supportsImages: false, - supportsPromptCache: true, // Claude Code does report cache tokens - supportsReasoningEffort: false, - supportsReasoningBudget: false, - requiredReasoningBudget: false, - // Claude Code manages its own tools and temperature via the CLI - supportsNativeTools: false, - supportsTemperature: false, - }, - "claude-opus-4-1-20250805": { - ...anthropicModels["claude-opus-4-1-20250805"], - supportsImages: false, - supportsPromptCache: true, // Claude Code does report cache tokens - supportsReasoningEffort: false, - supportsReasoningBudget: false, - requiredReasoningBudget: false, - // Claude Code manages its own tools and temperature via the CLI - supportsNativeTools: false, - supportsTemperature: false, + "claude-haiku-4-5": { + maxTokens: 32768, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", + supportsReasoningEffort: ["disable", "low", "medium", "high"], + reasoningEffort: "medium", + description: "Claude Haiku 4.5 - Fast and efficient with thinking", }, - "claude-opus-4-20250514": { - ...anthropicModels["claude-opus-4-20250514"], - supportsImages: false, - supportsPromptCache: true, // Claude Code does report cache tokens - supportsReasoningEffort: false, - supportsReasoningBudget: false, - requiredReasoningBudget: false, - // Claude Code manages its own tools and temperature via the CLI - supportsNativeTools: false, - supportsTemperature: false, - }, - "claude-3-7-sonnet-20250219": { - ...anthropicModels["claude-3-7-sonnet-20250219"], - supportsImages: false, - supportsPromptCache: true, // Claude Code does report cache tokens - supportsReasoningEffort: false, - supportsReasoningBudget: false, - requiredReasoningBudget: false, - // Claude Code manages its own tools and temperature via the CLI - supportsNativeTools: false, - supportsTemperature: false, - }, - "claude-3-5-sonnet-20241022": { - ...anthropicModels["claude-3-5-sonnet-20241022"], - supportsImages: false, - supportsPromptCache: true, // Claude Code does report cache tokens - supportsReasoningEffort: false, - supportsReasoningBudget: false, - requiredReasoningBudget: false, - // Claude Code manages its own tools and temperature via the CLI - supportsNativeTools: false, - supportsTemperature: false, - }, - "claude-3-5-haiku-20241022": { - ...anthropicModels["claude-3-5-haiku-20241022"], - supportsImages: false, - supportsPromptCache: true, // Claude Code does report cache tokens - supportsReasoningEffort: false, - supportsReasoningBudget: false, - requiredReasoningBudget: false, - // Claude Code manages its own tools and temperature via the CLI - supportsNativeTools: false, - supportsTemperature: false, + "claude-sonnet-4-5": { + maxTokens: 32768, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", + supportsReasoningEffort: ["disable", "low", "medium", "high"], + reasoningEffort: "medium", + description: "Claude Sonnet 4.5 - Balanced performance with thinking", }, - "claude-haiku-4-5-20251001": { - ...anthropicModels["claude-haiku-4-5-20251001"], - supportsImages: false, - supportsPromptCache: true, // Claude Code does report cache tokens - supportsReasoningEffort: false, - supportsReasoningBudget: false, - requiredReasoningBudget: false, - // Claude Code manages its own tools and temperature via the CLI - supportsNativeTools: false, - supportsTemperature: false, + "claude-opus-4-5": { + maxTokens: 32768, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + supportsNativeTools: true, + defaultToolProtocol: "native", + supportsReasoningEffort: ["disable", "low", "medium", "high"], + reasoningEffort: "medium", + description: "Claude Opus 4.5 - Most capable with thinking", }, } as const satisfies Record diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts index 41375432027..eefd1609f07 100644 --- a/src/api/providers/__tests__/claude-code.spec.ts +++ b/src/api/providers/__tests__/claude-code.spec.ts @@ -1,21 +1,30 @@ import { ClaudeCodeHandler } from "../claude-code" import { ApiHandlerOptions } from "../../../shared/api" -import { ClaudeCodeMessage } from "../../../integrations/claude-code/types" - -// Mock the runClaudeCode function -vi.mock("../../../integrations/claude-code/run", () => ({ - runClaudeCode: vi.fn(), +import type { StreamChunk } from "../../../integrations/claude-code/streaming-client" + +// Mock the OAuth manager +vi.mock("../../../integrations/claude-code/oauth", () => ({ + claudeCodeOAuthManager: { + getAccessToken: vi.fn(), + getEmail: vi.fn(), + loadCredentials: vi.fn(), + saveCredentials: vi.fn(), + clearCredentials: vi.fn(), + isAuthenticated: vi.fn(), + }, + generateUserId: vi.fn(() => "user_abc123_account_def456_session_ghi789"), })) -// Mock the message filter -vi.mock("../../../integrations/claude-code/message-filter", () => ({ - filterMessagesForClaudeCode: vi.fn((messages) => messages), +// Mock the streaming client +vi.mock("../../../integrations/claude-code/streaming-client", () => ({ + createStreamingMessage: vi.fn(), })) -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) +const { claudeCodeOAuthManager } = await import("../../../integrations/claude-code/oauth") +const { createStreamingMessage } = await import("../../../integrations/claude-code/streaming-client") + +const mockGetAccessToken = vi.mocked(claudeCodeOAuthManager.getAccessToken) +const mockCreateStreamingMessage = vi.mocked(createStreamingMessage) describe("ClaudeCodeHandler", () => { let handler: ClaudeCodeHandler @@ -23,22 +32,20 @@ describe("ClaudeCodeHandler", () => { beforeEach(() => { vi.clearAllMocks() const options: ApiHandlerOptions = { - claudeCodePath: "claude", - apiModelId: "claude-3-5-sonnet-20241022", + apiModelId: "claude-sonnet-4-5", } handler = new ClaudeCodeHandler(options) }) test("should create handler with correct model configuration", () => { const model = handler.getModel() - expect(model.id).toBe("claude-3-5-sonnet-20241022") - expect(model.info.supportsImages).toBe(false) - expect(model.info.supportsPromptCache).toBe(true) // Claude Code now supports prompt caching + expect(model.id).toBe("claude-sonnet-4-5") + expect(model.info.supportsImages).toBe(true) + expect(model.info.supportsPromptCache).toBe(true) }) test("should use default model when invalid model provided", () => { const options: ApiHandlerOptions = { - claudeCodePath: "claude", apiModelId: "invalid-model", } const handlerWithInvalidModel = new ClaudeCodeHandler(options) @@ -47,44 +54,53 @@ describe("ClaudeCodeHandler", () => { expect(model.id).toBe("claude-sonnet-4-5") // default model }) - test("should override maxTokens when claudeCodeMaxOutputTokens is provided", () => { + test("should return model maxTokens from model definition", () => { const options: ApiHandlerOptions = { - claudeCodePath: "claude", - apiModelId: "claude-sonnet-4-20250514", - claudeCodeMaxOutputTokens: 8000, + apiModelId: "claude-opus-4-5", } - const handlerWithMaxTokens = new ClaudeCodeHandler(options) - const model = handlerWithMaxTokens.getModel() + const handlerWithModel = new ClaudeCodeHandler(options) + const model = handlerWithModel.getModel() - expect(model.id).toBe("claude-sonnet-4-20250514") - expect(model.info.maxTokens).toBe(8000) // Should use the configured value, not the default 64000 + expect(model.id).toBe("claude-opus-4-5") + // Model maxTokens is 32768 as defined in claudeCodeModels for opus + expect(model.info.maxTokens).toBe(32768) }) - test("should override maxTokens for default model when claudeCodeMaxOutputTokens is provided", () => { + test("should support reasoning effort configuration", () => { const options: ApiHandlerOptions = { - claudeCodePath: "claude", - apiModelId: "invalid-model", // Will fall back to default - claudeCodeMaxOutputTokens: 16384, + apiModelId: "claude-sonnet-4-5", } - const handlerWithMaxTokens = new ClaudeCodeHandler(options) - const model = handlerWithMaxTokens.getModel() + const handler = new ClaudeCodeHandler(options) + const model = handler.getModel() - expect(model.id).toBe("claude-sonnet-4-5") // default model - expect(model.info.maxTokens).toBe(16384) // Should use the configured value + // Default model has supportsReasoningEffort + expect(model.info.supportsReasoningEffort).toEqual(["disable", "low", "medium", "high"]) + expect(model.info.reasoningEffort).toBe("medium") + }) + + test("should throw error when not authenticated", async () => { + const systemPrompt = "You are a helpful assistant" + const messages = [{ role: "user" as const, content: "Hello" }] + + mockGetAccessToken.mockResolvedValue(null) + + const stream = handler.createMessage(systemPrompt, messages) + const iterator = stream[Symbol.asyncIterator]() + + await expect(iterator.next()).rejects.toThrow(/not authenticated/i) }) - test("should filter messages and call runClaudeCode", async () => { + test("should call createStreamingMessage with thinking enabled by default", async () => { const systemPrompt = "You are a helpful assistant" const messages = [{ role: "user" as const, content: "Hello" }] - const filteredMessages = [{ role: "user" as const, content: "Hello (filtered)" }] - mockFilterMessages.mockReturnValue(filteredMessages) + mockGetAccessToken.mockResolvedValue("test-access-token") // Mock empty async generator - const mockGenerator = async function* (): AsyncGenerator { + const mockGenerator = async function* (): AsyncGenerator { // Empty generator for basic test } - mockRunClaudeCode.mockReturnValue(mockGenerator()) + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) const stream = handler.createMessage(systemPrompt, messages) @@ -92,86 +108,124 @@ describe("ClaudeCodeHandler", () => { const iterator = stream[Symbol.asyncIterator]() await iterator.next() - // Verify message filtering was called - expect(mockFilterMessages).toHaveBeenCalledWith(messages) + // Verify createStreamingMessage was called with correct parameters + // Default model has reasoning effort of "medium" so thinking should be enabled + // With interleaved thinking, maxTokens comes from model definition, not reasoning config + expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ + accessToken: "test-access-token", + model: "claude-sonnet-4-5", + systemPrompt, + messages, + maxTokens: 16384, // model's maxTokens (interleaved thinking uses context window) + thinking: { + type: "enabled", + budget_tokens: 32000, // medium reasoning budget_tokens + }, + tools: undefined, + toolChoice: undefined, + metadata: { + user_id: "user_abc123_account_def456_session_ghi789", + }, + }) + }) + + test("should disable thinking when reasoningEffort is set to disable", async () => { + const options: ApiHandlerOptions = { + apiModelId: "claude-sonnet-4-5", + reasoningEffort: "disable", + } + const handlerNoThinking = new ClaudeCodeHandler(options) + + const systemPrompt = "You are a helpful assistant" + const messages = [{ role: "user" as const, content: "Hello" }] + + mockGetAccessToken.mockResolvedValue("test-access-token") + + // Mock empty async generator + const mockGenerator = async function* (): AsyncGenerator { + // Empty generator for basic test + } + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) + + const stream = handlerNoThinking.createMessage(systemPrompt, messages) + + // Need to start iterating to trigger the call + const iterator = stream[Symbol.asyncIterator]() + await iterator.next() - // Verify runClaudeCode was called with filtered messages - expect(mockRunClaudeCode).toHaveBeenCalledWith({ + // Verify createStreamingMessage was called with thinking disabled + expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ + accessToken: "test-access-token", + model: "claude-sonnet-4-5", systemPrompt, - messages: filteredMessages, - path: "claude", - modelId: "claude-3-5-sonnet-20241022", - maxOutputTokens: undefined, // No maxOutputTokens configured in this test + messages, + maxTokens: 16384, // model default maxTokens + thinking: { type: "disabled" }, + tools: undefined, + toolChoice: undefined, + metadata: { + user_id: "user_abc123_account_def456_session_ghi789", + }, }) }) - test("should pass maxOutputTokens to runClaudeCode when configured", async () => { + test("should use high reasoning config when reasoningEffort is high", async () => { const options: ApiHandlerOptions = { - claudeCodePath: "claude", - apiModelId: "claude-3-5-sonnet-20241022", - claudeCodeMaxOutputTokens: 16384, + apiModelId: "claude-sonnet-4-5", + reasoningEffort: "high", } - const handlerWithMaxTokens = new ClaudeCodeHandler(options) + const handlerHighThinking = new ClaudeCodeHandler(options) const systemPrompt = "You are a helpful assistant" const messages = [{ role: "user" as const, content: "Hello" }] - const filteredMessages = [{ role: "user" as const, content: "Hello (filtered)" }] - mockFilterMessages.mockReturnValue(filteredMessages) + mockGetAccessToken.mockResolvedValue("test-access-token") // Mock empty async generator - const mockGenerator = async function* (): AsyncGenerator { + const mockGenerator = async function* (): AsyncGenerator { // Empty generator for basic test } - mockRunClaudeCode.mockReturnValue(mockGenerator()) + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - const stream = handlerWithMaxTokens.createMessage(systemPrompt, messages) + const stream = handlerHighThinking.createMessage(systemPrompt, messages) // Need to start iterating to trigger the call const iterator = stream[Symbol.asyncIterator]() await iterator.next() - // Verify runClaudeCode was called with maxOutputTokens - expect(mockRunClaudeCode).toHaveBeenCalledWith({ + // Verify createStreamingMessage was called with high thinking config + // With interleaved thinking, maxTokens comes from model definition, not reasoning config + expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ + accessToken: "test-access-token", + model: "claude-sonnet-4-5", systemPrompt, - messages: filteredMessages, - path: "claude", - modelId: "claude-3-5-sonnet-20241022", - maxOutputTokens: 16384, + messages, + maxTokens: 16384, // model's maxTokens (interleaved thinking uses context window) + thinking: { + type: "enabled", + budget_tokens: 64000, // high reasoning budget_tokens + }, + tools: undefined, + toolChoice: undefined, + metadata: { + user_id: "user_abc123_account_def456_session_ghi789", + }, }) }) - test("should handle thinking content properly", async () => { + test("should handle text content from streaming", async () => { const systemPrompt = "You are a helpful assistant" const messages = [{ role: "user" as const, content: "Hello" }] - // Mock async generator that yields thinking content - 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: "thinking", - thinking: "I need to think about this carefully...", - }, - ], - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 20, - }, - } as any, - session_id: "session_123", - } + mockGetAccessToken.mockResolvedValue("test-access-token") + + // Mock async generator that yields text chunks + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "text", text: "Hello " } + yield { type: "text", text: "there!" } } - mockRunClaudeCode.mockReturnValue(mockGenerator()) + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) const stream = handler.createMessage(systemPrompt, messages) const results = [] @@ -180,43 +234,29 @@ describe("ClaudeCodeHandler", () => { results.push(chunk) } - expect(results).toHaveLength(1) + expect(results).toHaveLength(2) expect(results[0]).toEqual({ - type: "reasoning", - text: "I need to think about this carefully...", + type: "text", + text: "Hello ", + }) + expect(results[1]).toEqual({ + type: "text", + text: "there!", }) }) - test("should handle redacted thinking content", async () => { + test("should handle reasoning content from streaming", async () => { const systemPrompt = "You are a helpful assistant" const messages = [{ role: "user" as const, content: "Hello" }] - // Mock async generator that yields redacted thinking content - 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: "redacted_thinking", - }, - ], - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 20, - }, - } as any, - session_id: "session_123", - } + mockGetAccessToken.mockResolvedValue("test-access-token") + + // Mock async generator that yields reasoning chunks + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "reasoning", text: "I need to think about this carefully..." } } - mockRunClaudeCode.mockReturnValue(mockGenerator()) + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) const stream = handler.createMessage(systemPrompt, messages) const results = [] @@ -228,45 +268,23 @@ describe("ClaudeCodeHandler", () => { expect(results).toHaveLength(1) expect(results[0]).toEqual({ type: "reasoning", - text: "[Redacted thinking block]", + text: "I need to think about this carefully...", }) }) - test("should handle mixed content types", async () => { + test("should handle mixed content types from streaming", async () => { const systemPrompt = "You are a helpful assistant" const messages = [{ role: "user" as const, content: "Hello" }] + mockGetAccessToken.mockResolvedValue("test-access-token") + // Mock async generator that yields mixed content - 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: "thinking", - thinking: "Let me think about this...", - }, - { - type: "text", - text: "Here's my response!", - }, - ], - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 20, - }, - } as any, - session_id: "session_123", - } + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "reasoning", text: "Let me think about this..." } + yield { type: "text", text: "Here's my response!" } } - mockRunClaudeCode.mockReturnValue(mockGenerator()) + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) const stream = handler.createMessage(systemPrompt, messages) const results = [] @@ -286,17 +304,20 @@ describe("ClaudeCodeHandler", () => { }) }) - test("should handle string chunks from generator", async () => { + test("should handle tool call partial chunks from streaming", async () => { const systemPrompt = "You are a helpful assistant" const messages = [{ role: "user" as const, content: "Hello" }] - // Mock async generator that yields string chunks - const mockGenerator = async function* (): AsyncGenerator { - yield "This is a string chunk" - yield "Another string chunk" + mockGetAccessToken.mockResolvedValue("test-access-token") + + // Mock async generator that yields tool call partial chunks + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "tool_call_partial", index: 0, id: "tool_123", name: "read_file", arguments: undefined } + yield { type: "tool_call_partial", index: 0, id: undefined, name: undefined, arguments: '{"path":' } + yield { type: "tool_call_partial", index: 0, id: undefined, name: undefined, arguments: '"test.txt"}' } } - mockRunClaudeCode.mockReturnValue(mockGenerator()) + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) const stream = handler.createMessage(systemPrompt, messages) const results = [] @@ -305,74 +326,49 @@ describe("ClaudeCodeHandler", () => { results.push(chunk) } - expect(results).toHaveLength(2) + expect(results).toHaveLength(3) expect(results[0]).toEqual({ - type: "text", - text: "This is a string chunk", + type: "tool_call_partial", + index: 0, + id: "tool_123", + name: "read_file", + arguments: undefined, }) expect(results[1]).toEqual({ - type: "text", - text: "Another string chunk", + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '{"path":', + }) + expect(results[2]).toEqual({ + type: "tool_call_partial", + index: 0, + id: undefined, + name: undefined, + arguments: '"test.txt"}', }) }) - test("should handle usage and cost tracking with paid usage", async () => { + test("should handle usage and cost tracking from streaming", async () => { const systemPrompt = "You are a helpful assistant" const messages = [{ role: "user" as const, content: "Hello" }] - // Mock async generator with init, assistant, and result messages - const mockGenerator = async function* (): AsyncGenerator { - // Init message indicating paid usage - yield { - type: "system" as const, - subtype: "init" as const, - session_id: "session_123", - tools: [], - mcp_servers: [], - apiKeySource: "/login managed key", - } - - // Assistant message - yield { - type: "assistant" as const, - message: { - id: "msg_123", - type: "message", - role: "assistant", - model: "claude-3-5-sonnet-20241022", - content: [ - { - type: "text", - text: "Hello there!", - }, - ], - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 20, - cache_read_input_tokens: 5, - cache_creation_input_tokens: 3, - }, - } as any, - session_id: "session_123", - } + mockGetAccessToken.mockResolvedValue("test-access-token") - // Result message + // Mock async generator with text and usage + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "text", text: "Hello there!" } yield { - type: "result" as const, - subtype: "success" as const, - total_cost_usd: 0.05, - is_error: false, - duration_ms: 1000, - duration_api_ms: 800, - num_turns: 1, - result: "success", - session_id: "session_123", + type: "usage", + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 5, + cacheWriteTokens: 3, } } - mockRunClaudeCode.mockReturnValue(mockGenerator()) + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) const stream = handler.createMessage(systemPrompt, messages) const results = [] @@ -387,71 +383,34 @@ describe("ClaudeCodeHandler", () => { type: "text", text: "Hello there!", }) + // Claude Code is subscription-based, no per-token cost expect(results[1]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20, cacheReadTokens: 5, cacheWriteTokens: 3, - totalCost: 0.05, // Paid usage, so cost is included + totalCost: 0, }) }) - test("should handle usage tracking with subscription (free) usage", async () => { + test("should handle usage without cache tokens", async () => { const systemPrompt = "You are a helpful assistant" const messages = [{ role: "user" as const, content: "Hello" }] - // Mock async generator with subscription usage - const mockGenerator = async function* (): AsyncGenerator { - // Init message indicating subscription usage - yield { - type: "system" as const, - subtype: "init" as const, - session_id: "session_123", - tools: [], - mcp_servers: [], - apiKeySource: "none", // Subscription usage - } - - // Assistant message - yield { - type: "assistant" as const, - message: { - id: "msg_123", - type: "message", - role: "assistant", - model: "claude-3-5-sonnet-20241022", - content: [ - { - type: "text", - text: "Hello there!", - }, - ], - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 20, - }, - } as any, - session_id: "session_123", - } + mockGetAccessToken.mockResolvedValue("test-access-token") - // Result message + // Mock async generator with usage without cache tokens + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "text", text: "Hello there!" } yield { - type: "result" as const, - subtype: "success" as const, - total_cost_usd: 0.05, - is_error: false, - duration_ms: 1000, - duration_api_ms: 800, - num_turns: 1, - result: "success", - session_id: "session_123", + type: "usage", + inputTokens: 10, + outputTokens: 20, } } - mockRunClaudeCode.mockReturnValue(mockGenerator()) + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) const stream = handler.createMessage(systemPrompt, messages) const results = [] @@ -460,95 +419,50 @@ describe("ClaudeCodeHandler", () => { results.push(chunk) } - // Should have text chunk and usage chunk + // Claude Code is subscription-based, no per-token cost expect(results).toHaveLength(2) - expect(results[0]).toEqual({ - type: "text", - text: "Hello there!", - }) expect(results[1]).toEqual({ type: "usage", inputTokens: 10, outputTokens: 20, - cacheReadTokens: 0, - cacheWriteTokens: 0, - totalCost: 0, // Subscription usage, so cost is 0 + cacheReadTokens: undefined, + cacheWriteTokens: undefined, + totalCost: 0, }) }) - test("should handle API errors properly", async () => { + test("should handle API errors from streaming", async () => { const systemPrompt = "You are a helpful assistant" const messages = [{ role: "user" as const, content: "Hello" }] - // Mock async generator that yields an API error - 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: 400 {"error":{"message":"Invalid model name"}}', - }, - ], - stop_reason: "stop_sequence", - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 20, - }, - } as any, - session_id: "session_123", - } + mockGetAccessToken.mockResolvedValue("test-access-token") + + // Mock async generator that yields an error + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "error", error: "Invalid model name" } } - mockRunClaudeCode.mockReturnValue(mockGenerator()) + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) const stream = handler.createMessage(systemPrompt, messages) const iterator = stream[Symbol.asyncIterator]() // Should throw an error - await expect(iterator.next()).rejects.toThrow() + await expect(iterator.next()).rejects.toThrow("Invalid model name") }) - test("should log warning for unsupported tool_use content", async () => { + test("should handle authentication refresh and continue streaming", async () => { const systemPrompt = "You are a helpful assistant" const messages = [{ role: "user" as const, content: "Hello" }] - const consoleSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - // Mock async generator that yields tool_use content - 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: "tool_use", - id: "tool_123", - name: "test_tool", - input: { test: "data" }, - }, - ], - stop_reason: null, - stop_sequence: null, - usage: { - input_tokens: 10, - output_tokens: 20, - }, - } as any, - session_id: "session_123", - } + // First call returns a valid token + mockGetAccessToken.mockResolvedValue("refreshed-token") + + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "text", text: "Response after refresh" } } - mockRunClaudeCode.mockReturnValue(mockGenerator()) + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) const stream = handler.createMessage(systemPrompt, messages) const results = [] @@ -557,9 +471,16 @@ describe("ClaudeCodeHandler", () => { results.push(chunk) } - // Should log error for unsupported tool_use - expect(consoleSpy).toHaveBeenCalledWith(expect.stringContaining("tool_use is not supported yet")) + expect(results).toHaveLength(1) + expect(results[0]).toEqual({ + type: "text", + text: "Response after refresh", + }) - consoleSpy.mockRestore() + expect(mockCreateStreamingMessage).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "refreshed-token", + }), + ) }) }) diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index a15512d65f4..7f4b0284001 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -1,143 +1,270 @@ import type { Anthropic } from "@anthropic-ai/sdk" +import OpenAI from "openai" import { claudeCodeDefaultModelId, type ClaudeCodeModelId, claudeCodeModels, + claudeCodeReasoningConfig, + type ClaudeCodeReasoningLevel, type ModelInfo, - getClaudeCodeModelId, } from "@roo-code/types" import { type ApiHandler, ApiHandlerCreateMessageMetadata } from ".." import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream" -import { runClaudeCode } from "../../integrations/claude-code/run" -import { filterMessagesForClaudeCode } from "../../integrations/claude-code/message-filter" +import { claudeCodeOAuthManager, generateUserId } from "../../integrations/claude-code/oauth" +import { + createStreamingMessage, + type StreamChunk, + type ThinkingConfig, +} from "../../integrations/claude-code/streaming-client" import { t } from "../../i18n" import { ApiHandlerOptions } from "../../shared/api" import { countTokens } from "../../utils/countTokens" +import { convertOpenAIToolsToAnthropic } from "../../core/prompts/tools/native-tools/converters" + +/** + * Converts OpenAI tool_choice to Anthropic ToolChoice format + * @param toolChoice - OpenAI tool_choice parameter + * @param parallelToolCalls - When true, allows parallel tool calls. When false (default), disables parallel tool calls. + */ +function convertOpenAIToolChoice( + toolChoice: OpenAI.Chat.ChatCompletionCreateParams["tool_choice"], + parallelToolCalls?: boolean, +): Anthropic.Messages.MessageCreateParams["tool_choice"] | undefined { + // Anthropic allows parallel tool calls by default. When parallelToolCalls is false or undefined, + // we disable parallel tool use to ensure one tool call at a time. + const disableParallelToolUse = !parallelToolCalls + + if (!toolChoice) { + // Default to auto with parallel tool use control + return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } + } + + if (typeof toolChoice === "string") { + switch (toolChoice) { + case "none": + return undefined // Anthropic doesn't have "none", just omit tools + case "auto": + return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } + case "required": + return { type: "any", disable_parallel_tool_use: disableParallelToolUse } + default: + return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } + } + } + + // Handle object form { type: "function", function: { name: string } } + if (typeof toolChoice === "object" && "function" in toolChoice) { + return { + type: "tool", + name: toolChoice.function.name, + disable_parallel_tool_use: disableParallelToolUse, + } + } + + return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } +} export class ClaudeCodeHandler implements ApiHandler { private options: ApiHandlerOptions + /** + * Store the last thinking block signature for interleaved thinking with tool use. + * This is captured from thinking_complete events during streaming and + * must be passed back to the API when providing tool results. + * Similar to Gemini's thoughtSignature pattern. + */ + private lastThinkingSignature?: string constructor(options: ApiHandlerOptions) { this.options = options } + /** + * Get the thinking signature from the last response. + * Used by Task.addToApiConversationHistory to persist the signature + * so it can be passed back to the API for tool use continuations. + * This follows the same pattern as Gemini's getThoughtSignature(). + */ + public getThoughtSignature(): string | undefined { + return this.lastThinkingSignature + } + + /** + * Gets the reasoning effort level for the current request. + * Returns the effective reasoning level (low/medium/high) or null if disabled. + */ + private getReasoningEffort(modelInfo: ModelInfo): ClaudeCodeReasoningLevel | null { + // Check if reasoning is explicitly disabled + if (this.options.enableReasoningEffort === false) { + return null + } + + // Get the selected effort from settings or model default + const selectedEffort = this.options.reasoningEffort ?? modelInfo.reasoningEffort + + // "disable" or no selection means no reasoning + if (!selectedEffort || selectedEffort === "disable") { + return null + } + + // Only allow valid levels for Claude Code + if (selectedEffort === "low" || selectedEffort === "medium" || selectedEffort === "high") { + return selectedEffort + } + + return null + } + async *createMessage( systemPrompt: string, messages: Anthropic.Messages.MessageParam[], - _metadata?: ApiHandlerCreateMessageMetadata, + metadata?: ApiHandlerCreateMessageMetadata, ): ApiStream { - // Filter out image blocks since Claude Code doesn't support them - const filteredMessages = filterMessagesForClaudeCode(messages) + // Reset per-request state that we persist into apiConversationHistory + this.lastThinkingSignature = undefined + + // Get access token from OAuth manager + const accessToken = await claudeCodeOAuthManager.getAccessToken() + + if (!accessToken) { + throw new Error( + t("common:errors.claudeCode.notAuthenticated", { + defaultValue: + "Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.", + }), + ) + } + + // Get user email for generating user_id metadata + const email = await claudeCodeOAuthManager.getEmail() - const useVertex = process.env.CLAUDE_CODE_USE_VERTEX === "1" const model = this.getModel() // Validate that the model ID is a valid ClaudeCodeModelId const modelId = model.id in claudeCodeModels ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId - const claudeProcess = runClaudeCode({ - systemPrompt, - messages: filteredMessages, - path: this.options.claudeCodePath, - modelId: getClaudeCodeModelId(modelId, useVertex), - maxOutputTokens: this.options.claudeCodeMaxOutputTokens, - }) + // Generate user_id metadata in the format required by Claude Code API + const userId = generateUserId(email || undefined) - // Usage is included with assistant messages, - // but cost is included in the result chunk - const usage: ApiStreamUsageChunk = { - type: "usage", - inputTokens: 0, - outputTokens: 0, - cacheReadTokens: 0, - cacheWriteTokens: 0, - } + // Convert OpenAI tools to Anthropic format if provided and protocol is native + // Exclude tools when tool_choice is "none" since that means "don't use tools" + const shouldIncludeNativeTools = + metadata?.tools && + metadata.tools.length > 0 && + metadata?.toolProtocol !== "xml" && + metadata?.tool_choice !== "none" - let isPaidUsage = true + const anthropicTools = shouldIncludeNativeTools ? convertOpenAIToolsToAnthropic(metadata.tools!) : undefined - for await (const chunk of claudeProcess) { - if (typeof chunk === "string") { - yield { - type: "text", - text: chunk, - } + const anthropicToolChoice = shouldIncludeNativeTools + ? convertOpenAIToolChoice(metadata.tool_choice, metadata.parallelToolCalls) + : undefined - continue - } + // Determine reasoning effort and thinking configuration + const reasoningLevel = this.getReasoningEffort(model.info) - if (chunk.type === "system" && chunk.subtype === "init") { - // Based on my tests, subscription usage sets the `apiKeySource` to "none" - isPaidUsage = chunk.apiKeySource !== "none" - continue - } + let thinking: ThinkingConfig + // With interleaved thinking (enabled via beta header), budget_tokens can exceed max_tokens + // as the token limit becomes the entire context window. We use the model's maxTokens. + // See: https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking + const maxTokens = model.info.maxTokens ?? 16384 - if (chunk.type === "assistant" && "message" in chunk) { - const message = chunk.message + if (reasoningLevel) { + // Use thinking mode with budget_tokens from config + const config = claudeCodeReasoningConfig[reasoningLevel] + thinking = { + type: "enabled", + budget_tokens: config.budgetTokens, + } + } else { + // Explicitly disable thinking + thinking = { type: "disabled" } + } - if (message.stop_reason !== null) { - const content = "text" in message.content[0] ? message.content[0] : undefined + // Create streaming request using OAuth + const stream = createStreamingMessage({ + accessToken, + model: modelId, + systemPrompt, + messages, + maxTokens, + thinking, + tools: anthropicTools, + toolChoice: anthropicToolChoice, + metadata: { + user_id: userId, + }, + }) - const isError = content && content.text.startsWith(`API Error`) - if (isError) { - // Error messages are formatted as: `API Error: <> <>` - const errorMessageStart = content.text.indexOf("{") - const errorMessage = content.text.slice(errorMessageStart) + // Track usage for cost calculation + let inputTokens = 0 + let outputTokens = 0 + let cacheReadTokens = 0 + let cacheWriteTokens = 0 - const error = this.attemptParse(errorMessage) - if (!error) { - throw new Error(content.text) - } + for await (const chunk of stream) { + switch (chunk.type) { + case "text": + yield { + type: "text", + text: chunk.text, + } + break - if (error.error.message.includes("Invalid model name")) { - throw new Error( - content.text + `\n\n${t("common:errors.claudeCode.apiKeyModelPlanMismatch")}`, - ) - } + case "reasoning": + yield { + type: "reasoning", + text: chunk.text, + } + break - throw new Error(errorMessage) + case "thinking_complete": + // Capture the signature for persistence in api_conversation_history + // This enables tool use continuations where thinking blocks must be passed back + if (chunk.signature) { + this.lastThinkingSignature = chunk.signature } - } + // Emit a complete thinking block with signature + // This is critical for interleaved thinking with tool use + // The signature must be included when passing thinking blocks back to the API + yield { + type: "reasoning", + text: chunk.thinking, + signature: chunk.signature, + } + break - for (const content of message.content) { - switch (content.type) { - case "text": - yield { - type: "text", - text: content.text, - } - break - case "thinking": - yield { - type: "reasoning", - text: content.thinking || "", - } - break - case "redacted_thinking": - yield { - type: "reasoning", - text: "[Redacted thinking block]", - } - break - case "tool_use": - console.error(`tool_use is not supported yet. Received: ${JSON.stringify(content)}`) - break + case "tool_call_partial": + yield { + type: "tool_call_partial", + index: chunk.index, + id: chunk.id, + name: chunk.name, + arguments: chunk.arguments, } - } + break - // Accumulate usage across streaming chunks - usage.inputTokens += message.usage.input_tokens - usage.outputTokens += message.usage.output_tokens - usage.cacheReadTokens = (usage.cacheReadTokens || 0) + (message.usage.cache_read_input_tokens || 0) - usage.cacheWriteTokens = - (usage.cacheWriteTokens || 0) + (message.usage.cache_creation_input_tokens || 0) + case "usage": { + inputTokens = chunk.inputTokens + outputTokens = chunk.outputTokens + cacheReadTokens = chunk.cacheReadTokens || 0 + cacheWriteTokens = chunk.cacheWriteTokens || 0 - continue - } + // Claude Code is subscription-based, no per-token cost + const usageChunk: ApiStreamUsageChunk = { + type: "usage", + inputTokens, + outputTokens, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + totalCost: 0, + } - if (chunk.type === "result" && "result" in chunk) { - usage.totalCost = isPaidUsage ? chunk.total_cost_usd : 0 + yield usageChunk + break + } - yield usage + case "error": + throw new Error(chunk.error) } } } @@ -146,26 +273,12 @@ export class ClaudeCodeHandler implements ApiHandler { const modelId = this.options.apiModelId if (modelId && modelId in claudeCodeModels) { const id = modelId as ClaudeCodeModelId - const modelInfo: ModelInfo = { ...claudeCodeModels[id] } - - // Override maxTokens with the configured value if provided - if (this.options.claudeCodeMaxOutputTokens !== undefined) { - modelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens - } - - return { id, info: modelInfo } - } - - const defaultModelInfo: ModelInfo = { ...claudeCodeModels[claudeCodeDefaultModelId] } - - // Override maxTokens with the configured value if provided - if (this.options.claudeCodeMaxOutputTokens !== undefined) { - defaultModelInfo.maxTokens = this.options.claudeCodeMaxOutputTokens + return { id, info: { ...claudeCodeModels[id] } } } return { id: claudeCodeDefaultModelId, - info: defaultModelInfo, + info: { ...claudeCodeModels[claudeCodeDefaultModelId] }, } } @@ -175,12 +288,4 @@ export class ClaudeCodeHandler implements ApiHandler { } return countTokens(content, { useWorker: true }) } - - private attemptParse(str: string) { - try { - return JSON.parse(str) - } catch (err) { - return null - } - } } diff --git a/src/api/transform/stream.ts b/src/api/transform/stream.ts index a4a0fe4a9a7..960ebbe770d 100644 --- a/src/api/transform/stream.ts +++ b/src/api/transform/stream.ts @@ -4,6 +4,7 @@ export type ApiStreamChunk = | ApiStreamTextChunk | ApiStreamUsageChunk | ApiStreamReasoningChunk + | ApiStreamThinkingCompleteChunk | ApiStreamGroundingChunk | ApiStreamToolCallChunk | ApiStreamToolCallStartChunk @@ -23,9 +24,35 @@ export interface ApiStreamTextChunk { text: string } +/** + * Reasoning/thinking chunk from the API stream. + * For Anthropic extended thinking, this may include a signature field + * which is required for passing thinking blocks back to the API during tool use. + */ export interface ApiStreamReasoningChunk { type: "reasoning" text: string + /** + * Signature for the thinking block (Anthropic extended thinking). + * When present, this indicates a complete thinking block that should be + * preserved for tool use continuations. The signature is used to verify + * that thinking blocks were generated by Claude. + */ + signature?: string +} + +/** + * Signals completion of a thinking block with its verification signature. + * Used by Anthropic extended thinking to pass the signature needed for + * tool use continuations and caching. + */ +export interface ApiStreamThinkingCompleteChunk { + type: "thinking_complete" + /** + * Cryptographic signature that verifies this thinking block was generated by Claude. + * Must be preserved and passed back to the API when continuing conversations with tool use. + */ + signature: string } export interface ApiStreamUsageChunk { diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 075caf028cd..9161d3b462a 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -751,9 +751,30 @@ export class Task extends EventEmitter implements TaskLike { messageWithTs.reasoning_details = reasoningDetails } - // Store reasoning: plain text (most providers) or encrypted (OpenAI Native) + // Store reasoning: Anthropic thinking (with signature), plain text (most providers), or encrypted (OpenAI Native) // Skip if reasoning_details already contains the reasoning (to avoid duplication) - if (reasoning && !reasoningDetails) { + if (reasoning && thoughtSignature && !reasoningDetails) { + // Anthropic provider with extended thinking: Store as proper `thinking` block + // This format passes through anthropic-filter.ts and is properly round-tripped + // for interleaved thinking with tool use (required by Anthropic API) + const thinkingBlock = { + type: "thinking", + thinking: reasoning, + signature: thoughtSignature, + } + + if (typeof messageWithTs.content === "string") { + messageWithTs.content = [ + thinkingBlock, + { type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam, + ] + } else if (Array.isArray(messageWithTs.content)) { + messageWithTs.content = [thinkingBlock, ...messageWithTs.content] + } else if (!messageWithTs.content) { + messageWithTs.content = [thinkingBlock] + } + } else if (reasoning && !reasoningDetails) { + // Other providers (non-Anthropic): Store as generic reasoning block const reasoningBlock = { type: "reasoning", text: reasoning, @@ -791,9 +812,10 @@ export class Task extends EventEmitter implements TaskLike { } } - // If we have a thought signature, append it as a dedicated content block - // so it can be round-tripped in api_history.json and re-sent on subsequent calls. - if (thoughtSignature) { + // If we have a thought signature WITHOUT reasoning text (edge case), + // append it as a dedicated content block for non-Anthropic providers (e.g., Gemini). + // Note: For Anthropic, the signature is already included in the thinking block above. + if (thoughtSignature && !reasoning) { const thoughtSignatureBlock = { type: "thoughtSignature", thoughtSignature, diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index af7efaf11cc..5cf6c6611b9 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2068,6 +2068,14 @@ export class ClineProvider openRouterImageGenerationSelectedModel, openRouterUseMiddleOutTransform, featureRoomoteControlEnabled, + claudeCodeIsAuthenticated: await (async () => { + try { + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + return await claudeCodeOAuthManager.isAuthenticated() + } catch { + return false + } + })(), debug: vscode.workspace.getConfiguration(Package.name).get("debug", false), } } diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index c08504c576d..e1640d3f2a8 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2268,6 +2268,45 @@ export const webviewMessageHandler = async ( break } + case "claudeCodeSignIn": { + try { + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + const authUrl = claudeCodeOAuthManager.startAuthorizationFlow() + + // Open the authorization URL in the browser + await vscode.env.openExternal(vscode.Uri.parse(authUrl)) + + // Wait for the callback in a separate promise (non-blocking) + claudeCodeOAuthManager + .waitForCallback() + .then(async () => { + vscode.window.showInformationMessage("Successfully signed in to Claude Code") + await provider.postStateToWebview() + }) + .catch((error) => { + provider.log(`Claude Code OAuth callback failed: ${error}`) + if (!String(error).includes("timed out")) { + vscode.window.showErrorMessage(`Claude Code sign in failed: ${error.message || error}`) + } + }) + } catch (error) { + provider.log(`Claude Code OAuth failed: ${error}`) + vscode.window.showErrorMessage("Claude Code sign in failed.") + } + break + } + case "claudeCodeSignOut": { + try { + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + await claudeCodeOAuthManager.clearCredentials() + vscode.window.showInformationMessage("Signed out from Claude Code") + await provider.postStateToWebview() + } catch (error) { + provider.log(`Claude Code sign out failed: ${error}`) + vscode.window.showErrorMessage("Claude Code sign out failed.") + } + break + } case "rooCloudManualUrl": { try { if (!message.text) { @@ -3042,6 +3081,37 @@ export const webviewMessageHandler = async ( break } + case "requestClaudeCodeRateLimits": { + try { + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + const accessToken = await claudeCodeOAuthManager.getAccessToken() + + if (!accessToken) { + provider.postMessageToWebview({ + type: "claudeCodeRateLimits", + error: "Not authenticated with Claude Code", + }) + break + } + + const { fetchRateLimitInfo } = await import("../../integrations/claude-code/streaming-client") + const rateLimits = await fetchRateLimitInfo(accessToken) + + provider.postMessageToWebview({ + type: "claudeCodeRateLimits", + values: rateLimits, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error fetching Claude Code rate limits: ${errorMessage}`) + provider.postMessageToWebview({ + type: "claudeCodeRateLimits", + error: errorMessage, + }) + } + break + } + case "openDebugApiHistory": case "openDebugUiHistory": { const currentTask = provider.getCurrentTask() diff --git a/src/extension.ts b/src/extension.ts index e286891cdc3..41d61af740a 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -25,6 +25,7 @@ import { ContextProxy } from "./core/config/ContextProxy" import { ClineProvider } from "./core/webview/ClineProvider" import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" +import { claudeCodeOAuthManager } from "./integrations/claude-code/oauth" import { McpServerManager } from "./services/mcp/McpServerManager" import { CodeIndexManager } from "./services/code-index/manager" import { MdmService } from "./services/mdm/MdmService" @@ -90,6 +91,9 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize terminal shell execution handlers. TerminalRegistry.initialize() + // Initialize Claude Code OAuth manager for direct API access. + claudeCodeOAuthManager.initialize(context) + // Get default commands from configuration. const defaultCommands = vscode.workspace.getConfiguration(Package.name).get("allowedCommands") || [] diff --git a/src/integrations/claude-code/__tests__/message-filter.spec.ts b/src/integrations/claude-code/__tests__/message-filter.spec.ts deleted file mode 100644 index a2fc701f418..00000000000 --- a/src/integrations/claude-code/__tests__/message-filter.spec.ts +++ /dev/null @@ -1,263 +0,0 @@ -import type { Anthropic } from "@anthropic-ai/sdk" - -import { filterMessagesForClaudeCode } from "../message-filter" - -describe("filterMessagesForClaudeCode", () => { - test("should pass through string messages unchanged", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "Hello, this is a simple text message", - }, - ] - - const result = filterMessagesForClaudeCode(messages) - - expect(result).toEqual(messages) - }) - - test("should pass through text-only content blocks unchanged", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "This is a text block", - }, - ], - }, - ] - - const result = filterMessagesForClaudeCode(messages) - - expect(result).toEqual(messages) - }) - - test("should replace image blocks with text placeholders", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Here's an image:", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8/5+hHgAHggJ/PchI7wAAAABJRU5ErkJggg==", - }, - }, - ], - }, - ] - - const result = filterMessagesForClaudeCode(messages) - - expect(result).toEqual([ - { - role: "user", - content: [ - { - type: "text", - text: "Here's an image:", - }, - { - type: "text", - text: "[Image (base64): image/png not supported by Claude Code]", - }, - ], - }, - ]) - }) - - test("should handle image blocks with unknown source types", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "image", - source: undefined as any, - }, - ], - }, - ] - - const result = filterMessagesForClaudeCode(messages) - - expect(result).toEqual([ - { - role: "user", - content: [ - { - type: "text", - text: "[Image (unknown): unknown not supported by Claude Code]", - }, - ], - }, - ]) - }) - - test("should handle mixed content with multiple images", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Compare these images:", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/jpeg", - data: "base64data1", - }, - }, - { - type: "text", - text: "and", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/gif", - data: "base64data2", - }, - }, - { - type: "text", - text: "What do you think?", - }, - ], - }, - ] - - const result = filterMessagesForClaudeCode(messages) - - expect(result).toEqual([ - { - role: "user", - content: [ - { - type: "text", - text: "Compare these images:", - }, - { - type: "text", - text: "[Image (base64): image/jpeg not supported by Claude Code]", - }, - { - type: "text", - text: "and", - }, - { - type: "text", - text: "[Image (base64): image/gif not supported by Claude Code]", - }, - { - type: "text", - text: "What do you think?", - }, - ], - }, - ]) - }) - - test("should handle multiple messages with images", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: "First message with text only", - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I can help with that.", - }, - ], - }, - { - role: "user", - content: [ - { - type: "text", - text: "Here's an image:", - }, - { - type: "image", - source: { - type: "base64", - media_type: "image/png", - data: "imagedata", - }, - }, - ], - }, - ] - - const result = filterMessagesForClaudeCode(messages) - - expect(result).toEqual([ - { - role: "user", - content: "First message with text only", - }, - { - role: "assistant", - content: [ - { - type: "text", - text: "I can help with that.", - }, - ], - }, - { - role: "user", - content: [ - { - type: "text", - text: "Here's an image:", - }, - { - type: "text", - text: "[Image (base64): image/png not supported by Claude Code]", - }, - ], - }, - ]) - }) - - test("should preserve other content block types unchanged", () => { - const messages: Anthropic.Messages.MessageParam[] = [ - { - role: "user", - content: [ - { - type: "text", - text: "Regular text", - }, - // This would be some other content type that's not an image - { - type: "tool_use" as any, - id: "tool_123", - name: "test_tool", - input: { test: "data" }, - }, - ], - }, - ] - - const result = filterMessagesForClaudeCode(messages) - - expect(result).toEqual(messages) - }) -}) diff --git a/src/integrations/claude-code/__tests__/oauth.spec.ts b/src/integrations/claude-code/__tests__/oauth.spec.ts new file mode 100644 index 00000000000..526ef2f6f7d --- /dev/null +++ b/src/integrations/claude-code/__tests__/oauth.spec.ts @@ -0,0 +1,198 @@ +import { + generateCodeVerifier, + generateCodeChallenge, + generateState, + generateUserId, + buildAuthorizationUrl, + isTokenExpired, + CLAUDE_CODE_OAUTH_CONFIG, + type ClaudeCodeCredentials, +} from "../oauth" + +describe("Claude Code OAuth", () => { + describe("generateCodeVerifier", () => { + test("should generate a base64url encoded verifier", () => { + const verifier = generateCodeVerifier() + // Base64url encoded 32 bytes = 43 characters + expect(verifier).toHaveLength(43) + // Should only contain base64url safe characters + expect(verifier).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + test("should generate unique verifiers on each call", () => { + const verifier1 = generateCodeVerifier() + const verifier2 = generateCodeVerifier() + expect(verifier1).not.toBe(verifier2) + }) + }) + + describe("generateCodeChallenge", () => { + test("should generate a base64url encoded SHA256 hash", () => { + const verifier = "test-verifier-string" + const challenge = generateCodeChallenge(verifier) + // Base64url encoded SHA256 hash = 43 characters + expect(challenge).toHaveLength(43) + // Should only contain base64url safe characters + expect(challenge).toMatch(/^[A-Za-z0-9_-]+$/) + }) + + test("should generate consistent challenge for same verifier", () => { + const verifier = "test-verifier-string" + const challenge1 = generateCodeChallenge(verifier) + const challenge2 = generateCodeChallenge(verifier) + expect(challenge1).toBe(challenge2) + }) + + test("should generate different challenges for different verifiers", () => { + const challenge1 = generateCodeChallenge("verifier1") + const challenge2 = generateCodeChallenge("verifier2") + expect(challenge1).not.toBe(challenge2) + }) + }) + + describe("generateState", () => { + test("should generate a 32-character hex string", () => { + const state = generateState() + expect(state).toHaveLength(32) // 16 bytes = 32 hex chars + expect(state).toMatch(/^[0-9a-f]+$/) + }) + + test("should generate unique states on each call", () => { + const state1 = generateState() + const state2 = generateState() + expect(state1).not.toBe(state2) + }) + }) + + describe("generateUserId", () => { + test("should generate user ID with correct format", () => { + const userId = generateUserId() + // Format: user_<16 hex>_account_<32 hex>_session_<32 hex> + expect(userId).toMatch(/^user_[0-9a-f]{16}_account_[0-9a-f]{32}_session_[0-9a-f]{32}$/) + }) + + test("should generate unique session IDs on each call", () => { + const userId1 = generateUserId() + const userId2 = generateUserId() + // Full IDs should be different due to random session UUID + expect(userId1).not.toBe(userId2) + }) + + test("should generate deterministic user hash and account UUID from email", () => { + const email = "test@example.com" + const userId1 = generateUserId(email) + const userId2 = generateUserId(email) + + // Extract user and account parts (everything except session) + const userAccount1 = userId1.replace(/_session_[0-9a-f]{32}$/, "") + const userAccount2 = userId2.replace(/_session_[0-9a-f]{32}$/, "") + + // User hash and account UUID should be deterministic for same email + expect(userAccount1).toBe(userAccount2) + + // But session UUID should be different + const session1 = userId1.match(/_session_([0-9a-f]{32})$/)?.[1] + const session2 = userId2.match(/_session_([0-9a-f]{32})$/)?.[1] + expect(session1).not.toBe(session2) + }) + + test("should generate different user hash for different emails", () => { + const userId1 = generateUserId("user1@example.com") + const userId2 = generateUserId("user2@example.com") + + const userHash1 = userId1.match(/^user_([0-9a-f]{16})_/)?.[1] + const userHash2 = userId2.match(/^user_([0-9a-f]{16})_/)?.[1] + + expect(userHash1).not.toBe(userHash2) + }) + + test("should generate random user hash and account UUID without email", () => { + const userId1 = generateUserId() + const userId2 = generateUserId() + + // Without email, even user hash should be different each call + const userHash1 = userId1.match(/^user_([0-9a-f]{16})_/)?.[1] + const userHash2 = userId2.match(/^user_([0-9a-f]{16})_/)?.[1] + + // Extremely unlikely to be the same (random 8 bytes) + expect(userHash1).not.toBe(userHash2) + }) + }) + + describe("buildAuthorizationUrl", () => { + test("should build correct authorization URL with all parameters", () => { + const codeChallenge = "test-code-challenge" + const state = "test-state" + const url = buildAuthorizationUrl(codeChallenge, state) + + const parsedUrl = new URL(url) + expect(parsedUrl.origin + parsedUrl.pathname).toBe(CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint) + + const params = parsedUrl.searchParams + expect(params.get("client_id")).toBe(CLAUDE_CODE_OAUTH_CONFIG.clientId) + expect(params.get("redirect_uri")).toBe(CLAUDE_CODE_OAUTH_CONFIG.redirectUri) + expect(params.get("scope")).toBe(CLAUDE_CODE_OAUTH_CONFIG.scopes) + expect(params.get("code_challenge")).toBe(codeChallenge) + expect(params.get("code_challenge_method")).toBe("S256") + expect(params.get("response_type")).toBe("code") + expect(params.get("state")).toBe(state) + }) + }) + + describe("isTokenExpired", () => { + test("should return false for non-expired token", () => { + const futureDate = new Date(Date.now() + 60 * 60 * 1000) // 1 hour in future + const credentials: ClaudeCodeCredentials = { + type: "claude", + access_token: "test-token", + refresh_token: "test-refresh", + expired: futureDate.toISOString(), + } + expect(isTokenExpired(credentials)).toBe(false) + }) + + test("should return true for expired token", () => { + const pastDate = new Date(Date.now() - 60 * 60 * 1000) // 1 hour in past + const credentials: ClaudeCodeCredentials = { + type: "claude", + access_token: "test-token", + refresh_token: "test-refresh", + expired: pastDate.toISOString(), + } + expect(isTokenExpired(credentials)).toBe(true) + }) + + test("should return true for token expiring within 5 minute buffer", () => { + const almostExpired = new Date(Date.now() + 3 * 60 * 1000) // 3 minutes in future (within 5 min buffer) + const credentials: ClaudeCodeCredentials = { + type: "claude", + access_token: "test-token", + refresh_token: "test-refresh", + expired: almostExpired.toISOString(), + } + expect(isTokenExpired(credentials)).toBe(true) + }) + + test("should return false for token expiring after 5 minute buffer", () => { + const notYetExpiring = new Date(Date.now() + 10 * 60 * 1000) // 10 minutes in future + const credentials: ClaudeCodeCredentials = { + type: "claude", + access_token: "test-token", + refresh_token: "test-refresh", + expired: notYetExpiring.toISOString(), + } + expect(isTokenExpired(credentials)).toBe(false) + }) + }) + + describe("CLAUDE_CODE_OAUTH_CONFIG", () => { + test("should have correct configuration values", () => { + expect(CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint).toBe("https://claude.ai/oauth/authorize") + expect(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint).toBe("https://console.anthropic.com/v1/oauth/token") + expect(CLAUDE_CODE_OAUTH_CONFIG.clientId).toBe("9d1c250a-e61b-44d9-88ed-5944d1962f5e") + expect(CLAUDE_CODE_OAUTH_CONFIG.redirectUri).toBe("http://localhost:54545/callback") + expect(CLAUDE_CODE_OAUTH_CONFIG.scopes).toBe("org:create_api_key user:profile user:inference") + expect(CLAUDE_CODE_OAUTH_CONFIG.callbackPort).toBe(54545) + }) + }) +}) diff --git a/src/integrations/claude-code/__tests__/streaming-client.spec.ts b/src/integrations/claude-code/__tests__/streaming-client.spec.ts new file mode 100644 index 00000000000..bb475620c44 --- /dev/null +++ b/src/integrations/claude-code/__tests__/streaming-client.spec.ts @@ -0,0 +1,740 @@ +import { CLAUDE_CODE_API_CONFIG } from "../streaming-client" + +describe("Claude Code Streaming Client", () => { + describe("CLAUDE_CODE_API_CONFIG", () => { + test("should have correct API endpoint", () => { + expect(CLAUDE_CODE_API_CONFIG.endpoint).toBe("https://api.anthropic.com/v1/messages") + }) + + test("should have correct API version", () => { + expect(CLAUDE_CODE_API_CONFIG.version).toBe("2023-06-01") + }) + + test("should have correct default betas", () => { + expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("claude-code-20250219") + expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("oauth-2025-04-20") + expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("interleaved-thinking-2025-05-14") + expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("fine-grained-tool-streaming-2025-05-14") + }) + + test("should have correct user agent", () => { + expect(CLAUDE_CODE_API_CONFIG.userAgent).toBe("claude-cli/1.0.83 (external, cli)") + }) + }) + + describe("createStreamingMessage", () => { + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + test("should make request with correct headers", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(CLAUDE_CODE_API_CONFIG.endpoint), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + "Content-Type": "application/json", + "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, + Accept: "text/event-stream", + }), + }), + ) + }) + + test("should include correct body parameters", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + maxTokens: 4096, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + expect(body.model).toBe("claude-3-5-sonnet-20241022") + expect(body.stream).toBe(true) + expect(body.max_tokens).toBe(4096) + // System prompt should have cache_control on the user-provided text + expect(body.system).toEqual([ + { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, + { type: "text", text: "You are helpful", cache_control: { type: "ephemeral" } }, + ]) + // Messages should have cache_control on the last user message + expect(body.messages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }], + }, + ]) + }) + + test("should add cache breakpoints to last two user messages", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [ + { role: "user", content: "First message" }, + { role: "assistant", content: "Response" }, + { role: "user", content: "Second message" }, + { role: "assistant", content: "Another response" }, + { role: "user", content: "Third message" }, + ], + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // Only the last two user messages should have cache_control + expect(body.messages[0].content).toBe("First message") // No cache_control + expect(body.messages[2].content).toEqual([ + { type: "text", text: "Second message", cache_control: { type: "ephemeral" } }, + ]) + expect(body.messages[4].content).toEqual([ + { type: "text", text: "Third message", cache_control: { type: "ephemeral" } }, + ]) + }) + + test("should filter out non-Anthropic block types", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Internal reasoning" }, // Should be filtered + { type: "thoughtSignature", data: "encrypted" }, // Should be filtered + { type: "text", text: "Response" }, + ], + }, + { + role: "user", + content: [{ type: "text", text: "Follow up" }], + }, + ] as any, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // The assistant message should only have the text block + expect(body.messages[1].content).toEqual([{ type: "text", text: "Response" }]) + }) + + test("should preserve thinking and redacted_thinking blocks", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Let me think...", signature: "abc123" }, + { type: "text", text: "Response" }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "123", content: "result" }], + }, + ] as any, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // Thinking blocks should be preserved + expect(body.messages[1].content).toContainEqual({ + type: "thinking", + thinking: "Let me think...", + signature: "abc123", + }) + // Tool result blocks should be preserved + expect(body.messages[2].content).toContainEqual({ + type: "tool_result", + tool_use_id: "123", + content: "result", + }) + }) + + test("should convert reasoning + thoughtSignature to thinking blocks for interleaved thinking (adjacent)", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + { + role: "assistant", + content: [ + // Adjacent format (simple case) + { type: "reasoning", text: "Let me analyze this problem step by step..." }, + { + type: "thoughtSignature", + thoughtSignature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve", + }, + { type: "tool_use", id: "tool_123", name: "read_file", input: { path: "/test.txt" } }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool_123", content: "file contents" }], + }, + ] as any, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // The reasoning + thoughtSignature should be converted to a proper thinking block + expect(body.messages[1].content).toContainEqual({ + type: "thinking", + thinking: "Let me analyze this problem step by step...", + signature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve", + }) + // tool_use should be preserved + expect(body.messages[1].content).toContainEqual({ + type: "tool_use", + id: "tool_123", + name: "read_file", + input: { path: "/test.txt" }, + }) + // tool_result should be preserved in user message + expect(body.messages[2].content).toContainEqual({ + type: "tool_result", + tool_use_id: "tool_123", + content: "file contents", + }) + }) + + test("should convert reasoning + thoughtSignature to thinking blocks when not adjacent (Task.ts format)", async () => { + // This matches the actual format from Task.ts where: + // - reasoning is PREPENDED (line 769) + // - text/tool_use blocks are in the middle + // - thoughtSignature is APPENDED (line 808) + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + { + role: "assistant", + content: [ + // Task.ts format: reasoning at START, content in MIDDLE, thoughtSignature at END + { type: "reasoning", text: "Let me analyze this problem step by step..." }, + { type: "text", text: "I'll help you with that." }, + { type: "tool_use", id: "tool_123", name: "read_file", input: { path: "/test.txt" } }, + { + type: "thoughtSignature", + thoughtSignature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve", + }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "tool_123", content: "file contents" }], + }, + ] as any, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // Check the ORDER of blocks - thinking should be FIRST (at reasoning position) + const assistantContent = body.messages[1].content + expect(assistantContent[0]).toEqual({ + type: "thinking", + thinking: "Let me analyze this problem step by step...", + signature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve", + }) + + // text block should be second + expect(assistantContent[1]).toMatchObject({ + type: "text", + text: "I'll help you with that.", + }) + + // tool_use should be third + expect(assistantContent[2]).toEqual({ + type: "tool_use", + id: "tool_123", + name: "read_file", + input: { path: "/test.txt" }, + }) + + // thoughtSignature should be filtered out (combined with reasoning) + expect(assistantContent.length).toBe(3) + expect(assistantContent.some((b: { type: string }) => b.type === "thoughtSignature")).toBe(false) + + // tool_result should be preserved in user message + expect(body.messages[2].content).toContainEqual({ + type: "tool_result", + tool_use_id: "tool_123", + content: "file contents", + }) + }) + + test("should strip reasoning_details from messages (provider switching)", async () => { + // When switching from OpenRouter/Roo to Claude Code, messages may have + // reasoning_details fields that the Anthropic API doesn't accept + // This causes errors like: "messages.3.reasoning_details: Extra inputs are not permitted" + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + // Simulate messages with reasoning_details (added by OpenRouter for Gemini/o-series) + const messagesWithReasoningDetails = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [{ type: "text", text: "I'll help with that." }], + // This field is added by OpenRouter/Roo providers for Gemini/OpenAI reasoning + reasoning_details: [{ type: "summary_text", summary: "Thinking about the request" }], + }, + { role: "user", content: "Follow up question" }, + ] + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: messagesWithReasoningDetails as any, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // The assistant message should NOT have reasoning_details + expect(body.messages[1]).not.toHaveProperty("reasoning_details") + // But should still have the content + expect(body.messages[1].content).toContainEqual( + expect.objectContaining({ + type: "text", + text: "I'll help with that.", + }), + ) + // Only role and content should be present + expect(Object.keys(body.messages[1])).toEqual(["role", "content"]) + }) + + test("should strip other non-standard message fields", async () => { + // Ensure any non-standard fields are stripped from messages + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const messagesWithExtraFields = [ + { + role: "user", + content: "Hello", + customField: "should be stripped", + metadata: { foo: "bar" }, + }, + { + role: "assistant", + content: [{ type: "text", text: "Response" }], + internalId: "123", + timestamp: Date.now(), + }, + ] + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: messagesWithExtraFields as any, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // All messages should only have role and content + body.messages.forEach((msg: Record) => { + expect(Object.keys(msg).filter((k) => k !== "role" && k !== "content")).toHaveLength(0) + }) + }) + + test("should yield error chunk on non-ok response", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: vi.fn().mockResolvedValue('{"error":{"message":"Invalid API key"}}'), + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "invalid-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toHaveLength(1) + expect(chunks[0].type).toBe("error") + expect((chunks[0] as { type: "error"; error: string }).error).toBe("Invalid API key") + }) + + test("should yield error chunk when no response body", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: null, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toHaveLength(1) + expect(chunks[0].type).toBe("error") + expect((chunks[0] as { type: "error"; error: string }).error).toBe("No response body") + }) + + test("should parse text SSE events correctly", async () => { + const sseData = [ + 'event: content_block_start\ndata: {"index":0,"content_block":{"type":"text","text":"Hello"}}\n\n', + 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"text_delta","text":" world"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ] + + let readIndex = 0 + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockImplementation(() => { + if (readIndex < sseData.length) { + const value = new TextEncoder().encode(sseData[readIndex++]) + return Promise.resolve({ done: false, value }) + } + return Promise.resolve({ done: true, value: undefined }) + }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have text chunks and usage + expect(chunks.some((c) => c.type === "text")).toBe(true) + expect(chunks.filter((c) => c.type === "text")).toEqual([ + { type: "text", text: "Hello" }, + { type: "text", text: " world" }, + ]) + }) + + test("should parse thinking/reasoning SSE events correctly", async () => { + const sseData = [ + 'event: content_block_start\ndata: {"index":0,"content_block":{"type":"thinking","thinking":"Let me think..."}}\n\n', + 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"thinking_delta","thinking":" more thoughts"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ] + + let readIndex = 0 + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockImplementation(() => { + if (readIndex < sseData.length) { + const value = new TextEncoder().encode(sseData[readIndex++]) + return Promise.resolve({ done: false, value }) + } + return Promise.resolve({ done: true, value: undefined }) + }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.filter((c) => c.type === "reasoning")).toEqual([ + { type: "reasoning", text: "Let me think..." }, + { type: "reasoning", text: " more thoughts" }, + ]) + }) + + test("should track and yield usage from message events", async () => { + const sseData = [ + 'event: message_start\ndata: {"message":{"usage":{"input_tokens":10,"output_tokens":0,"cache_read_input_tokens":5}}}\n\n', + 'event: message_delta\ndata: {"usage":{"output_tokens":20}}\n\n', + "event: message_stop\ndata: {}\n\n", + ] + + let readIndex = 0 + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockImplementation(() => { + if (readIndex < sseData.length) { + const value = new TextEncoder().encode(sseData[readIndex++]) + return Promise.resolve({ done: false, value }) + } + return Promise.resolve({ done: true, value: undefined }) + }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk).toMatchObject({ + type: "usage", + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 5, + }) + }) + }) +}) diff --git a/src/integrations/claude-code/message-filter.ts b/src/integrations/claude-code/message-filter.ts deleted file mode 100644 index 25ffacce6b7..00000000000 --- a/src/integrations/claude-code/message-filter.ts +++ /dev/null @@ -1,35 +0,0 @@ -import type { Anthropic } from "@anthropic-ai/sdk" - -/** - * Filters out image blocks from messages since Claude Code doesn't support images. - * Replaces image blocks with text placeholders similar to how VSCode LM provider handles it. - */ -export function filterMessagesForClaudeCode( - messages: Anthropic.Messages.MessageParam[], -): Anthropic.Messages.MessageParam[] { - return messages.map((message) => { - // Handle simple string messages - if (typeof message.content === "string") { - return message - } - - // Handle complex message structures - const filteredContent = message.content.map((block) => { - if (block.type === "image") { - // Replace image blocks with text placeholders - const sourceType = block.source?.type || "unknown" - const mediaType = block.source?.media_type || "unknown" - return { - type: "text" as const, - text: `[Image (${sourceType}): ${mediaType} not supported by Claude Code]`, - } - } - return block - }) - - return { - ...message, - content: filteredContent, - } - }) -} diff --git a/src/integrations/claude-code/oauth.ts b/src/integrations/claude-code/oauth.ts new file mode 100644 index 00000000000..036ad70b631 --- /dev/null +++ b/src/integrations/claude-code/oauth.ts @@ -0,0 +1,479 @@ +import * as crypto from "crypto" +import * as http from "http" +import { URL } from "url" +import type { ExtensionContext } from "vscode" +import { z } from "zod" + +// OAuth Configuration +export const CLAUDE_CODE_OAUTH_CONFIG = { + authorizationEndpoint: "https://claude.ai/oauth/authorize", + tokenEndpoint: "https://console.anthropic.com/v1/oauth/token", + clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e", + redirectUri: "http://localhost:54545/callback", + scopes: "org:create_api_key user:profile user:inference", + callbackPort: 54545, +} as const + +// Token storage key +const CLAUDE_CODE_CREDENTIALS_KEY = "claude-code-oauth-credentials" + +// Credentials schema +const claudeCodeCredentialsSchema = z.object({ + type: z.literal("claude"), + access_token: z.string().min(1), + refresh_token: z.string().min(1), + expired: z.string(), // RFC3339 datetime + email: z.string().optional(), +}) + +export type ClaudeCodeCredentials = z.infer + +// Token response schema from Anthropic +const tokenResponseSchema = z.object({ + access_token: z.string(), + refresh_token: z.string(), + expires_in: z.number(), + email: z.string().optional(), + token_type: z.string().optional(), +}) + +/** + * Generates a cryptographically random PKCE code verifier + * Must be 43-128 characters long using unreserved characters + */ +export function generateCodeVerifier(): string { + // Generate 32 random bytes and encode as base64url (will be 43 characters) + const buffer = crypto.randomBytes(32) + return buffer.toString("base64url") +} + +/** + * Generates the PKCE code challenge from the verifier using S256 method + */ +export function generateCodeChallenge(verifier: string): string { + const hash = crypto.createHash("sha256").update(verifier).digest() + return hash.toString("base64url") +} + +/** + * Generates a random state parameter for CSRF protection + */ +export function generateState(): string { + return crypto.randomBytes(16).toString("hex") +} + +/** + * Generates a user_id in the format required by Claude Code API + * Format: user__account__session_ + */ +export function generateUserId(email?: string): string { + // Generate user hash from email or random bytes + const userHash = email + ? crypto.createHash("sha256").update(email).digest("hex").slice(0, 16) + : crypto.randomBytes(8).toString("hex") + + // Generate account UUID (persistent per email or random) + const accountUuid = email + ? crypto.createHash("sha256").update(`account:${email}`).digest("hex").slice(0, 32) + : crypto.randomUUID().replace(/-/g, "") + + // Generate session UUID (always random for each request) + const sessionUuid = crypto.randomUUID().replace(/-/g, "") + + return `user_${userHash}_account_${accountUuid}_session_${sessionUuid}` +} + +/** + * Builds the authorization URL for OAuth flow + */ +export function buildAuthorizationUrl(codeChallenge: string, state: string): string { + const params = new URLSearchParams({ + client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId, + redirect_uri: CLAUDE_CODE_OAUTH_CONFIG.redirectUri, + scope: CLAUDE_CODE_OAUTH_CONFIG.scopes, + code_challenge: codeChallenge, + code_challenge_method: "S256", + response_type: "code", + state, + }) + + return `${CLAUDE_CODE_OAUTH_CONFIG.authorizationEndpoint}?${params.toString()}` +} + +/** + * Exchanges the authorization code for tokens + */ +export async function exchangeCodeForTokens( + code: string, + codeVerifier: string, + state: string, +): Promise { + const body = { + code, + state, + grant_type: "authorization_code", + client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId, + redirect_uri: CLAUDE_CODE_OAUTH_CONFIG.redirectUri, + code_verifier: codeVerifier, + } + + const response = await fetch(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Token exchange failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + const data = await response.json() + const tokenResponse = tokenResponseSchema.parse(data) + + // Calculate expiry time + const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000) + + return { + type: "claude", + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token, + expired: expiresAt.toISOString(), + email: tokenResponse.email, + } +} + +/** + * Refreshes the access token using the refresh token + */ +export async function refreshAccessToken(refreshToken: string): Promise { + const body = { + grant_type: "refresh_token", + client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId, + refresh_token: refreshToken, + } + + const response = await fetch(CLAUDE_CODE_OAUTH_CONFIG.tokenEndpoint, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Token refresh failed: ${response.status} ${response.statusText} - ${errorText}`) + } + + const data = await response.json() + const tokenResponse = tokenResponseSchema.parse(data) + + // Calculate expiry time + const expiresAt = new Date(Date.now() + tokenResponse.expires_in * 1000) + + return { + type: "claude", + access_token: tokenResponse.access_token, + refresh_token: tokenResponse.refresh_token, + expired: expiresAt.toISOString(), + email: tokenResponse.email, + } +} + +/** + * Checks if the credentials are expired (with 5 minute buffer) + */ +export function isTokenExpired(credentials: ClaudeCodeCredentials): boolean { + const expiryTime = new Date(credentials.expired).getTime() + const bufferMs = 5 * 60 * 1000 // 5 minutes buffer + return Date.now() >= expiryTime - bufferMs +} + +/** + * ClaudeCodeOAuthManager - Handles OAuth flow and token management + */ +export class ClaudeCodeOAuthManager { + private context: ExtensionContext | null = null + private credentials: ClaudeCodeCredentials | null = null + private pendingAuth: { + codeVerifier: string + state: string + server?: http.Server + } | null = null + + /** + * Initialize the OAuth manager with VS Code extension context + */ + initialize(context: ExtensionContext): void { + this.context = context + } + + /** + * Load credentials from storage + */ + async loadCredentials(): Promise { + if (!this.context) { + return null + } + + try { + const credentialsJson = await this.context.secrets.get(CLAUDE_CODE_CREDENTIALS_KEY) + if (!credentialsJson) { + return null + } + + const parsed = JSON.parse(credentialsJson) + this.credentials = claudeCodeCredentialsSchema.parse(parsed) + return this.credentials + } catch (error) { + console.error("[claude-code-oauth] Failed to load credentials:", error) + return null + } + } + + /** + * Save credentials to storage + */ + async saveCredentials(credentials: ClaudeCodeCredentials): Promise { + if (!this.context) { + throw new Error("OAuth manager not initialized") + } + + await this.context.secrets.store(CLAUDE_CODE_CREDENTIALS_KEY, JSON.stringify(credentials)) + this.credentials = credentials + } + + /** + * Clear credentials from storage + */ + async clearCredentials(): Promise { + if (!this.context) { + return + } + + await this.context.secrets.delete(CLAUDE_CODE_CREDENTIALS_KEY) + this.credentials = null + } + + /** + * Get a valid access token, refreshing if necessary + */ + async getAccessToken(): Promise { + // Try to load credentials if not already loaded + if (!this.credentials) { + await this.loadCredentials() + } + + if (!this.credentials) { + return null + } + + // Check if token is expired and refresh if needed + if (isTokenExpired(this.credentials)) { + try { + const newCredentials = await refreshAccessToken(this.credentials.refresh_token) + await this.saveCredentials(newCredentials) + } catch (error) { + console.error("[claude-code-oauth] Failed to refresh token:", error) + // Clear invalid credentials + await this.clearCredentials() + return null + } + } + + return this.credentials.access_token + } + + /** + * Get the user's email from credentials + */ + async getEmail(): Promise { + if (!this.credentials) { + await this.loadCredentials() + } + return this.credentials?.email || null + } + + /** + * Check if the user is authenticated + */ + async isAuthenticated(): Promise { + const token = await this.getAccessToken() + return token !== null + } + + /** + * Start the OAuth authorization flow + * Returns the authorization URL to open in browser + */ + startAuthorizationFlow(): string { + // Cancel any existing authorization flow before starting a new one + this.cancelAuthorizationFlow() + + const codeVerifier = generateCodeVerifier() + const codeChallenge = generateCodeChallenge(codeVerifier) + const state = generateState() + + this.pendingAuth = { + codeVerifier, + state, + } + + return buildAuthorizationUrl(codeChallenge, state) + } + + /** + * Start a local server to receive the OAuth callback + * Returns a promise that resolves when authentication is complete + */ + async waitForCallback(): Promise { + if (!this.pendingAuth) { + throw new Error("No pending authorization flow") + } + + // Close any existing server before starting a new one + if (this.pendingAuth.server) { + try { + this.pendingAuth.server.close() + } catch { + // Ignore errors when closing + } + this.pendingAuth.server = undefined + } + + return new Promise((resolve, reject) => { + const server = http.createServer(async (req, res) => { + try { + const url = new URL(req.url || "", `http://localhost:${CLAUDE_CODE_OAUTH_CONFIG.callbackPort}`) + + if (url.pathname !== "/callback") { + res.writeHead(404) + res.end("Not Found") + return + } + + const code = url.searchParams.get("code") + const state = url.searchParams.get("state") + const error = url.searchParams.get("error") + + if (error) { + res.writeHead(400) + res.end(`Authentication failed: ${error}`) + reject(new Error(`OAuth error: ${error}`)) + server.close() + return + } + + if (!code || !state) { + res.writeHead(400) + res.end("Missing code or state parameter") + reject(new Error("Missing code or state parameter")) + server.close() + return + } + + if (state !== this.pendingAuth?.state) { + res.writeHead(400) + res.end("State mismatch - possible CSRF attack") + reject(new Error("State mismatch")) + server.close() + return + } + + try { + const credentials = await exchangeCodeForTokens(code, this.pendingAuth.codeVerifier, state) + + await this.saveCredentials(credentials) + + res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) + res.end(` + + + +Authentication Successful + + +

✓ Authentication Successful

+

You can close this window and return to VS Code.

+ + +`) + + this.pendingAuth = null + server.close() + resolve(credentials) + } catch (exchangeError) { + res.writeHead(500) + res.end(`Token exchange failed: ${exchangeError}`) + reject(exchangeError) + server.close() + } + } catch (err) { + res.writeHead(500) + res.end("Internal server error") + reject(err) + server.close() + } + }) + + server.on("error", (err: NodeJS.ErrnoException) => { + this.pendingAuth = null + if (err.code === "EADDRINUSE") { + reject( + new Error( + `Port ${CLAUDE_CODE_OAUTH_CONFIG.callbackPort} is already in use. ` + + `Please close any other applications using this port and try again.`, + ), + ) + } else { + reject(err) + } + }) + + // Set a timeout for the callback + const timeout = setTimeout( + () => { + server.close() + reject(new Error("Authentication timed out")) + }, + 5 * 60 * 1000, + ) // 5 minutes + + server.listen(CLAUDE_CODE_OAUTH_CONFIG.callbackPort, () => { + if (this.pendingAuth) { + this.pendingAuth.server = server + } + }) + + // Clear timeout when server closes + server.on("close", () => { + clearTimeout(timeout) + }) + }) + } + + /** + * Cancel any pending authorization flow + */ + cancelAuthorizationFlow(): void { + if (this.pendingAuth?.server) { + this.pendingAuth.server.close() + } + this.pendingAuth = null + } + + /** + * Get the current credentials (for display purposes) + */ + getCredentials(): ClaudeCodeCredentials | null { + return this.credentials + } +} + +// Singleton instance +export const claudeCodeOAuthManager = new ClaudeCodeOAuthManager() diff --git a/src/integrations/claude-code/streaming-client.ts b/src/integrations/claude-code/streaming-client.ts new file mode 100644 index 00000000000..76664806697 --- /dev/null +++ b/src/integrations/claude-code/streaming-client.ts @@ -0,0 +1,883 @@ +import type { Anthropic } from "@anthropic-ai/sdk" +import type { ClaudeCodeRateLimitInfo } from "@roo-code/types" +import * as os from "os" + +/** + * Set of content block types that are valid for Anthropic API. + * Only these types will be passed through to the API. + * See: https://docs.anthropic.com/en/api/messages + */ +const VALID_ANTHROPIC_BLOCK_TYPES = new Set([ + "text", + "image", + "tool_use", + "tool_result", + "thinking", + "redacted_thinking", + "document", +]) + +/** + * Converts internal content blocks to proper Anthropic format for interleaved thinking. + * + * This handles the conversion of Roo Code's internal block types to Anthropic's API format: + * - `reasoning` blocks (with text) + `thoughtSignature` blocks -> `thinking` blocks with `signature` + * + * According to Anthropic docs: + * - During tool use, you must pass `thinking` blocks back to the API for the last assistant message + * - The `signature` field is used to verify that thinking blocks were generated by Claude + * - Include the complete unmodified block back to the API to maintain reasoning continuity + * + * IMPORTANT: In Task.ts, the message structure is: + * - reasoning block is PREPENDED (at the start) + * - text/tool_use blocks are in the middle + * - thoughtSignature block is APPENDED (at the end) + * + * So we need to: + * 1. Find the reasoning block and thoughtSignature block anywhere in the content + * 2. If both exist, combine them into a thinking block at the REASONING position + * 3. Remove the thoughtSignature block from its position + * 4. Pass through other valid blocks in their original positions + */ +/** + * Internal type for content blocks that may include non-Anthropic types like + * reasoning and thoughtSignature that are used internally by Roo Code. + */ +interface InternalContentBlock { + type: string + text?: string + thinking?: string + signature?: string + thoughtSignature?: string + summary?: unknown[] + [key: string]: unknown +} + +function convertToAnthropicThinkingBlocks( + content: Anthropic.Messages.ContentBlockParam[], +): Anthropic.Messages.ContentBlockParam[] { + // First pass: Find reasoning and thoughtSignature blocks (legacy format from older Task.ts) + // Note: New Task.ts stores thinking blocks directly with { type: "thinking", thinking, signature } + // which will pass through unchanged since "thinking" is in VALID_ANTHROPIC_BLOCK_TYPES + let reasoningIndex = -1 + let reasoningText: string | undefined + let thoughtSignatureIndex = -1 + let signature: string | undefined + + for (let i = 0; i < content.length; i++) { + const block = content[i] as unknown as InternalContentBlock + + // Handle legacy reasoning + thoughtSignature format + if (block.type === "reasoning" && typeof block.text === "string") { + reasoningIndex = i + reasoningText = block.text + } else if (block.type === "thoughtSignature" && typeof block.thoughtSignature === "string") { + thoughtSignatureIndex = i + signature = block.thoughtSignature + } + // Note: thinking blocks with { type: "thinking", thinking: "...", signature: "..." } + // are handled naturally since "thinking" is in VALID_ANTHROPIC_BLOCK_TYPES + } + + // Second pass: Build result with proper thinking block placement + const result: Anthropic.Messages.ContentBlockParam[] = [] + + for (let i = 0; i < content.length; i++) { + const block = content[i] as unknown as InternalContentBlock + + if (i === reasoningIndex) { + // At the reasoning position, insert a thinking block if we have both reasoning and signature (legacy format) + if (reasoningText && signature) { + result.push({ + type: "thinking", + thinking: reasoningText, + signature: signature, + } as unknown as Anthropic.Messages.ContentBlockParam) + } + // If we only have reasoning without signature, skip it (not valid for API) + continue + } + + if (i === thoughtSignatureIndex) { + // Skip the thoughtSignature block - it was combined with reasoning above + continue + } + + // Pass through valid Anthropic blocks (includes thinking blocks with proper format) + if (VALID_ANTHROPIC_BLOCK_TYPES.has(block.type)) { + result.push(content[i]) + } + } + + return result +} + +/** + * Filters out non-Anthropic content blocks from messages before sending to the API. + * Also converts internal reasoning + thoughtSignature blocks to proper Anthropic thinking blocks. + * + * Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept. + * This automatically filters out: + * - Internal "reasoning" blocks (Roo Code's internal representation) - unless combined with thoughtSignature + * - Gemini's "thoughtSignature" blocks (converted to thinking blocks when paired with reasoning) + * - Any other unknown block types + * + * IMPORTANT: This function also strips message-level fields that are not part of the Anthropic API: + * - `reasoning_details` (added by OpenRouter/Roo providers for Gemini/OpenAI reasoning) + * - Any other non-standard fields added by other providers + * + * We preserve ALL thinking blocks for these reasons: + * 1. Rewind functionality - users need to be able to go back in conversation history + * 2. Claude Opus 4.5+ preserves thinking blocks by default (per Anthropic docs) + * 3. Interleaved thinking requires thinking blocks to be passed back for tool use continuations + * + * The API will handle thinking blocks appropriately based on the model: + * - Claude Opus 4.5+: thinking blocks preserved (enables cache optimization) + * - Older models: thinking blocks stripped from prior turns automatically + */ +function filterNonAnthropicBlocks(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { + const result: Anthropic.Messages.MessageParam[] = [] + + for (const message of messages) { + // Extract ONLY the standard Anthropic message fields (role, content) + // This strips out any extra fields like `reasoning_details` that other providers + // may have added to the messages (e.g., OpenRouter adds reasoning_details for Gemini/o-series) + const { role, content } = message + + if (typeof content === "string") { + // Return a clean message with only role and content + result.push({ role, content }) + continue + } + + // Convert reasoning + thoughtSignature to proper thinking blocks + // and filter out any invalid block types + const convertedContent = convertToAnthropicThinkingBlocks(content) + + // If all content was filtered out, skip this message + if (convertedContent.length === 0) { + continue + } + + // Return a clean message with only role and content (no extra fields) + result.push({ + role, + content: convertedContent, + }) + } + + return result +} + +/** + * Adds cache_control breakpoints to the last two user messages for prompt caching. + * This follows Anthropic's recommended pattern: + * - Cache the system prompt (handled separately) + * - Cache the last text block of the second-to-last user message + * - Cache the last text block of the last user message + * + * According to Anthropic docs: + * - System prompts and tools remain cached despite thinking parameter changes + * - Message cache breakpoints are invalidated when thinking parameters change + * - When using extended thinking, thinking blocks from previous turns are stripped from context + */ +function addMessageCacheBreakpoints(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { + // Find indices of user messages + const userMsgIndices = messages.reduce( + (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), + [] as number[], + ) + + const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 + const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 + + return messages.map((message, index) => { + // Only add cache control to the last two user messages + if (index !== lastUserMsgIndex && index !== secondLastUserMsgIndex) { + return message + } + + // Handle string content + if (typeof message.content === "string") { + return { + ...message, + content: [ + { + type: "text" as const, + text: message.content, + cache_control: { type: "ephemeral" as const }, + }, + ], + } + } + + // Handle array content - add cache_control to the last text block + const contentWithCache = message.content.map((block, blockIndex) => { + // Find the last text block index + let lastTextIndex = -1 + for (let i = message.content.length - 1; i >= 0; i--) { + if ((message.content[i] as { type: string }).type === "text") { + lastTextIndex = i + break + } + } + + // Only add cache_control to text blocks (the last one specifically) + if (blockIndex === lastTextIndex && (block as { type: string }).type === "text") { + const textBlock = block as { type: "text"; text: string } + return { + type: "text" as const, + text: textBlock.text, + cache_control: { type: "ephemeral" as const }, + } + } + + return block + }) + + return { + ...message, + content: contentWithCache, + } + }) +} + +// API Configuration +export const CLAUDE_CODE_API_CONFIG = { + endpoint: "https://api.anthropic.com/v1/messages", + version: "2023-06-01", + defaultBetas: [ + "prompt-caching-2024-07-31", + "claude-code-20250219", + "oauth-2025-04-20", + "interleaved-thinking-2025-05-14", + "fine-grained-tool-streaming-2025-05-14", + ], + userAgent: "claude-cli/1.0.83 (external, cli)", +} as const + +/** + * Get Claude Code CLI headers - includes Stainless SDK headers and special CLI headers + */ +function getClaudeCodeCliHeaders(): Record { + const arch = os.arch() + const platform = os.platform() + + // Map platform to OS name - must match Claude CLI format exactly + const osMap: Record = { + darwin: "MacOS", // Note: Claude CLI uses "MacOS" not "macOS" + linux: "Linux", + win32: "Windows", + } + + return { + // Claude Code specific headers + "Anthropic-Dangerous-Direct-Browser-Access": "true", + "X-App": "cli", + // Stainless SDK headers as used by Claude CLI + "X-Stainless-Lang": "js", + "X-Stainless-Package-Version": "0.55.1", + "X-Stainless-OS": osMap[platform] || platform, + "X-Stainless-Arch": arch, + "X-Stainless-Runtime": "node", + "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Helper-Method": "stream", + "X-Stainless-Timeout": "60", + Connection: "keep-alive", + } +} + +/** + * SSE Event types from Anthropic streaming API + */ +export type SSEEventType = + | "message_start" + | "content_block_start" + | "content_block_delta" + | "content_block_stop" + | "message_delta" + | "message_stop" + | "ping" + | "error" + +export interface SSEEvent { + event: SSEEventType + data: unknown +} + +/** + * Thinking configuration for extended thinking mode + */ +export type ThinkingConfig = + | { + type: "enabled" + budget_tokens: number + } + | { + type: "disabled" + } + +/** + * Stream message request options + */ +export interface StreamMessageOptions { + accessToken: string + model: string + systemPrompt: string + messages: Anthropic.Messages.MessageParam[] + maxTokens?: number + thinking?: ThinkingConfig + tools?: Anthropic.Messages.Tool[] + toolChoice?: Anthropic.Messages.ToolChoice + metadata?: { + user_id?: string + } + signal?: AbortSignal +} + +/** + * SSE Parser state that persists across chunks + * This is necessary because SSE events can be split across multiple chunks + */ +interface SSEParserState { + buffer: string + currentEvent: string | null + currentData: string[] +} + +/** + * Creates initial SSE parser state + */ +function createSSEParserState(): SSEParserState { + return { + buffer: "", + currentEvent: null, + currentData: [], + } +} + +/** + * Parses SSE lines from a text chunk + * Returns parsed events and updates the state for the next chunk + * + * The state persists across chunks to handle events that span multiple chunks: + * - buffer: incomplete line from previous chunk + * - currentEvent: event type if we've seen "event:" but not the complete event + * - currentData: accumulated data lines for the current event + */ +function parseSSEChunk(chunk: string, state: SSEParserState): { events: SSEEvent[]; state: SSEParserState } { + const events: SSEEvent[] = [] + const lines = (state.buffer + chunk).split("\n") + + // Start with the accumulated state + let currentEvent = state.currentEvent + let currentData = [...state.currentData] + let remaining = "" + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // If this is the last line and doesn't end with newline, it might be incomplete + if (i === lines.length - 1 && !chunk.endsWith("\n") && line !== "") { + remaining = line + continue + } + + // Empty line signals end of event + if (line === "") { + if (currentEvent && currentData.length > 0) { + try { + const dataStr = currentData.join("\n") + const data = dataStr === "[DONE]" ? null : JSON.parse(dataStr) + events.push({ + event: currentEvent as SSEEventType, + data, + }) + } catch { + // Skip malformed events + console.error("[claude-code-streaming] Failed to parse SSE data:", currentData.join("\n")) + } + } + currentEvent = null + currentData = [] + continue + } + + // Parse event type + if (line.startsWith("event: ")) { + currentEvent = line.slice(7) + continue + } + + // Parse data + if (line.startsWith("data: ")) { + currentData.push(line.slice(6)) + continue + } + } + + // Return updated state for next chunk + return { + events, + state: { + buffer: remaining, + currentEvent, + currentData, + }, + } +} + +/** + * Stream chunk types that the handler can yield + */ +export interface StreamTextChunk { + type: "text" + text: string +} + +export interface StreamReasoningChunk { + type: "reasoning" + text: string +} + +/** + * A complete thinking block with signature, used for tool use continuations. + * According to Anthropic docs: + * - During tool use, you must pass thinking blocks back to the API for the last assistant message + * - Include the complete unmodified block back to the API to maintain reasoning continuity + * - The signature field is used to verify that thinking blocks were generated by Claude + */ +export interface StreamThinkingCompleteChunk { + type: "thinking_complete" + index: number + thinking: string + signature: string +} + +export interface StreamToolCallPartialChunk { + type: "tool_call_partial" + index: number + id?: string + name?: string + arguments?: string +} + +export interface StreamUsageChunk { + type: "usage" + inputTokens: number + outputTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + totalCost?: number +} + +export interface StreamErrorChunk { + type: "error" + error: string +} + +export type StreamChunk = + | StreamTextChunk + | StreamReasoningChunk + | StreamThinkingCompleteChunk + | StreamToolCallPartialChunk + | StreamUsageChunk + | StreamErrorChunk + +/** + * Creates a streaming message request to the Anthropic API using OAuth + */ +export async function* createStreamingMessage(options: StreamMessageOptions): AsyncGenerator { + const { accessToken, model, systemPrompt, messages, maxTokens, thinking, tools, toolChoice, metadata, signal } = + options + + // Filter out non-Anthropic blocks before processing + const sanitizedMessages = filterNonAnthropicBlocks(messages) + + // Add cache breakpoints to the last two user messages + // According to Anthropic docs: + // - System prompts and tools remain cached despite thinking parameter changes + // - Message cache breakpoints are invalidated when thinking parameters change + // - We cache the last two user messages for optimal cache hit rates + const messagesWithCache = addMessageCacheBreakpoints(sanitizedMessages) + + // Build request body - match Claude Code format exactly + const body: Record = { + model, + stream: true, + messages: messagesWithCache, + } + + // Only include max_tokens if explicitly provided + if (maxTokens !== undefined) { + body.max_tokens = maxTokens + } + + // Add thinking configuration for extended thinking mode + if (thinking) { + body.thinking = thinking + } + + // System prompt as array of content blocks (Claude Code format) + // Prepend Claude Code branding as required by the API + // Add cache_control to the last text block for prompt caching + // System prompt caching is preserved even when thinking parameters change + body.system = [ + { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, + ...(systemPrompt ? [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }] : []), + ] + + // Metadata with user_id is required for Claude Code + if (metadata) { + body.metadata = metadata + } + + if (tools && tools.length > 0) { + body.tools = tools + // Default tool_choice to "auto" when tools are provided (as per spec example) + body.tool_choice = toolChoice || { type: "auto" } + } else if (toolChoice) { + body.tool_choice = toolChoice + } + + // Build headers - match Claude Code CLI exactly + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, + "Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","), + Accept: "text/event-stream", + "Accept-Encoding": "gzip, deflate, br, zstd", + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, + ...getClaudeCodeCliHeaders(), + } + + // Make the request + const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal, + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `API request failed: ${response.status} ${response.statusText}` + try { + const errorJson = JSON.parse(errorText) + if (errorJson.error?.message) { + errorMessage = errorJson.error.message + } + } catch { + if (errorText) { + errorMessage += ` - ${errorText}` + } + } + yield { type: "error", error: errorMessage } + return + } + + if (!response.body) { + yield { type: "error", error: "No response body" } + return + } + + // Track usage across events + let totalInputTokens = 0 + let totalOutputTokens = 0 + let cacheReadTokens = 0 + let cacheWriteTokens = 0 + + // Track content blocks by index for proper assembly + // This is critical for interleaved thinking - we need to capture complete thinking blocks + // with their signatures so they can be passed back to the API for tool use continuations + const contentBlocks: Map< + number, + { + type: string + text: string + signature?: string + id?: string + name?: string + arguments?: string + } + > = new Map() + + // Read the stream + const reader = response.body.getReader() + const decoder = new TextDecoder() + let sseState = createSSEParserState() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + const result = parseSSEChunk(chunk, sseState) + sseState = result.state + const events = result.events + + for (const event of events) { + const eventData = event.data as Record | null + + if (!eventData) { + continue + } + + switch (event.event) { + case "message_start": { + const message = eventData.message as Record + if (!message) { + break + } + const usage = message.usage as Record | undefined + if (usage) { + totalInputTokens += usage.input_tokens || 0 + totalOutputTokens += usage.output_tokens || 0 + cacheReadTokens += usage.cache_read_input_tokens || 0 + cacheWriteTokens += usage.cache_creation_input_tokens || 0 + } + break + } + + case "content_block_start": { + const contentBlock = eventData.content_block as Record + const index = eventData.index as number + + if (contentBlock) { + switch (contentBlock.type) { + case "text": + // Initialize text block tracking + contentBlocks.set(index, { + type: "text", + text: (contentBlock.text as string) || "", + }) + if (contentBlock.text) { + yield { type: "text", text: contentBlock.text as string } + } + break + case "thinking": + // Initialize thinking block tracking - critical for interleaved thinking + // We need to accumulate the text and capture the signature + contentBlocks.set(index, { + type: "thinking", + text: (contentBlock.thinking as string) || "", + }) + if (contentBlock.thinking) { + yield { type: "reasoning", text: contentBlock.thinking as string } + } + break + case "tool_use": + contentBlocks.set(index, { + type: "tool_use", + text: "", + id: contentBlock.id as string, + name: contentBlock.name as string, + arguments: "", + }) + yield { + type: "tool_call_partial", + index, + id: contentBlock.id as string, + name: contentBlock.name as string, + arguments: undefined, + } + break + } + } + break + } + + case "content_block_delta": { + const delta = eventData.delta as Record + const index = eventData.index as number + const block = contentBlocks.get(index) + + if (delta) { + switch (delta.type) { + case "text_delta": + if (delta.text) { + // Accumulate text + if (block && block.type === "text") { + block.text += delta.text as string + } + yield { type: "text", text: delta.text as string } + } + break + case "thinking_delta": + if (delta.thinking) { + // Accumulate thinking text + if (block && block.type === "thinking") { + block.text += delta.thinking as string + } + yield { type: "reasoning", text: delta.thinking as string } + } + break + case "signature_delta": + // Capture the signature for the thinking block + // This is critical for interleaved thinking - the signature + // must be included when passing thinking blocks back to the API + if (delta.signature && block && block.type === "thinking") { + block.signature = delta.signature as string + } + break + case "input_json_delta": + if (block && block.type === "tool_use") { + block.arguments = (block.arguments || "") + (delta.partial_json as string) + } + yield { + type: "tool_call_partial", + index, + id: undefined, + name: undefined, + arguments: delta.partial_json as string, + } + break + } + } + break + } + + case "content_block_stop": { + // When a content block completes, emit complete thinking blocks + // This enables the caller to preserve them for tool use continuations + const index = eventData.index as number + const block = contentBlocks.get(index) + + if (block && block.type === "thinking" && block.signature) { + // Emit the complete thinking block with signature + // This is required for interleaved thinking with tool use + yield { + type: "thinking_complete", + index, + thinking: block.text, + signature: block.signature, + } + } + break + } + + case "message_delta": { + const usage = eventData.usage as Record | undefined + if (usage && usage.output_tokens !== undefined) { + // output_tokens in message_delta is the running total, not a delta + // So we replace rather than add + totalOutputTokens = usage.output_tokens + } + break + } + + case "message_stop": { + // Yield final usage chunk + yield { + type: "usage", + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + } + break + } + + case "error": { + const errorData = eventData.error as Record + yield { + type: "error", + error: (errorData?.message as string) || "Unknown streaming error", + } + break + } + } + } + } + } finally { + reader.releaseLock() + } +} + +/** + * Parse rate limit headers from a response into a structured format + */ +function parseRateLimitHeaders(headers: Headers): ClaudeCodeRateLimitInfo { + const getHeader = (name: string): string | null => headers.get(name) + const parseFloat = (val: string | null): number => (val ? Number.parseFloat(val) : 0) + const parseInt = (val: string | null): number => (val ? Number.parseInt(val, 10) : 0) + + return { + fiveHour: { + status: getHeader("anthropic-ratelimit-unified-5h-status") || "unknown", + utilization: parseFloat(getHeader("anthropic-ratelimit-unified-5h-utilization")), + resetTime: parseInt(getHeader("anthropic-ratelimit-unified-5h-reset")), + }, + weekly: { + status: getHeader("anthropic-ratelimit-unified-7d_sonnet-status") || "unknown", + utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d_sonnet-utilization")), + resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d_sonnet-reset")), + }, + weeklyUnified: { + status: getHeader("anthropic-ratelimit-unified-7d-status") || "unknown", + utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d-utilization")), + resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d-reset")), + }, + representativeClaim: getHeader("anthropic-ratelimit-unified-representative-claim") || undefined, + overage: { + status: getHeader("anthropic-ratelimit-unified-overage-status") || "unknown", + disabledReason: getHeader("anthropic-ratelimit-unified-overage-disabled-reason") || undefined, + }, + fallbackPercentage: parseFloat(getHeader("anthropic-ratelimit-unified-fallback-percentage")) || undefined, + organizationId: getHeader("anthropic-organization-id") || undefined, + fetchedAt: Date.now(), + } +} + +/** + * Fetch rate limit information by making a minimal API call + * Uses a small request to get the response headers containing rate limit data + */ +export async function fetchRateLimitInfo(accessToken: string): Promise { + // Build minimal request body - use haiku for speed and lowest cost + const body = { + model: "claude-haiku-4-5", + max_tokens: 1, + system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }], + messages: [{ role: "user", content: "hi" }], + } + + // Build headers - match Claude Code CLI exactly + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, + "Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","), + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, + ...getClaudeCodeCliHeaders(), + } + + // Make the request + const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `API request failed: ${response.status} ${response.statusText}` + try { + const errorJson = JSON.parse(errorText) + if (errorJson.error?.message) { + errorMessage = errorJson.error.message + } + } catch { + if (errorText) { + errorMessage += ` - ${errorText}` + } + } + throw new Error(errorMessage) + } + + // Parse rate limit headers from the response + return parseRateLimitHeaders(response.headers) +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 93528b8d564..20bb5759645 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -132,6 +132,7 @@ export interface ExtensionMessage { | "interactionRequired" | "browserSessionUpdate" | "browserSessionNavigate" + | "claudeCodeRateLimits" text?: string payload?: any // Add a generic payload for now, can refine later // Checkpoint warning message @@ -357,6 +358,7 @@ export type ExtensionState = Pick< remoteControlEnabled: boolean taskSyncEnabled: boolean featureRoomoteControlEnabled: boolean + claudeCodeIsAuthenticated?: boolean debug?: boolean } diff --git a/src/shared/WebviewMessage.ts b/src/shared/WebviewMessage.ts index eb109166c8c..a2ae6b199dc 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -127,6 +127,8 @@ export interface WebviewMessage { | "cloudLandingPageSignIn" | "rooCloudSignOut" | "rooCloudManualUrl" + | "claudeCodeSignIn" + | "claudeCodeSignOut" | "switchOrganization" | "condenseTaskContextRequest" | "requestIndexingStatus" @@ -176,6 +178,7 @@ export interface WebviewMessage { | "browserPanelDidLaunch" | "openDebugApiHistory" | "openDebugUiHistory" + | "requestClaudeCodeRateLimits" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 18162804f37..62172f885bd 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -140,7 +140,7 @@ const ApiOptions = ({ setErrorMessage, }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { organizationAllowList, cloudIsAuthenticated } = useExtensionState() + const { organizationAllowList, cloudIsAuthenticated, claudeCodeIsAuthenticated } = useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -567,6 +567,7 @@ const ApiOptions = ({ apiConfiguration={apiConfiguration} setApiConfigurationField={setApiConfigurationField} simplifySettings={fromWelcomeView} + claudeCodeIsAuthenticated={claudeCodeIsAuthenticated} /> )} @@ -779,7 +780,8 @@ const ApiOptions = ({ )} - {selectedProviderModels.length > 0 && ( + {/* Skip generic model picker for claude-code since it has its own in ClaudeCode.tsx */} + {selectedProviderModels.length > 0 && selectedProvider !== "claude-code" && ( <>
diff --git a/webview-ui/src/components/settings/ModelInfoView.tsx b/webview-ui/src/components/settings/ModelInfoView.tsx index 167ce85e3dd..e043f68f832 100644 --- a/webview-ui/src/components/settings/ModelInfoView.tsx +++ b/webview-ui/src/components/settings/ModelInfoView.tsx @@ -14,6 +14,7 @@ type ModelInfoViewProps = { modelInfo?: ModelInfo isDescriptionExpanded: boolean setIsDescriptionExpanded: (isExpanded: boolean) => void + hidePricing?: boolean } export const ModelInfoView = ({ @@ -22,6 +23,7 @@ export const ModelInfoView = ({ modelInfo, isDescriptionExpanded, setIsDescriptionExpanded, + hidePricing, }: ModelInfoViewProps) => { const { t } = useAppTranslation() @@ -95,7 +97,8 @@ export const ModelInfoView = ({ ), ].filter(Boolean) - const infoItems = shouldShowTierPricingTable ? baseInfoItems : [...baseInfoItems, ...priceInfoItems] + // Show pricing info unless hidePricing is set or tier pricing table is shown + const infoItems = shouldShowTierPricingTable || hidePricing ? baseInfoItems : [...baseInfoItems, ...priceInfoItems] return ( <> @@ -113,7 +116,7 @@ export const ModelInfoView = ({ ))}
- {shouldShowTierPricingTable && ( + {shouldShowTierPricingTable && !hidePricing && (
{t("settings:serviceTier.pricingTableTitle")} diff --git a/webview-ui/src/components/settings/ModelPicker.tsx b/webview-ui/src/components/settings/ModelPicker.tsx index b6a9201bcd1..4fe4c02dda5 100644 --- a/webview-ui/src/components/settings/ModelPicker.tsx +++ b/webview-ui/src/components/settings/ModelPicker.tsx @@ -51,9 +51,10 @@ interface ModelPickerProps { value: ProviderSettings[K], isUserAction?: boolean, ) => void - organizationAllowList: OrganizationAllowList + organizationAllowList?: OrganizationAllowList errorMessage?: string simplifySettings?: boolean + hidePricing?: boolean } export const ModelPicker = ({ @@ -67,6 +68,7 @@ export const ModelPicker = ({ organizationAllowList, errorMessage, simplifySettings, + hidePricing, }: ModelPickerProps) => { const { t } = useAppTranslation() @@ -262,20 +264,23 @@ export const ModelPicker = ({ modelInfo={selectedModelInfo} isDescriptionExpanded={isDescriptionExpanded} setIsDescriptionExpanded={setIsDescriptionExpanded} + hidePricing={hidePricing} /> )} -
- , - defaultModelLink: ( - onSelect(defaultModelId)} className="text-sm" /> - ), - }} - values={{ serviceName, defaultModelId }} - /> -
+ {!hidePricing && ( +
+ , + defaultModelLink: ( + onSelect(defaultModelId)} className="text-sm" /> + ), + }} + values={{ serviceName, defaultModelId }} + /> +
+ )}
)} diff --git a/webview-ui/src/components/settings/providers/ClaudeCode.tsx b/webview-ui/src/components/settings/providers/ClaudeCode.tsx index 706c51339f3..87072a9b976 100644 --- a/webview-ui/src/components/settings/providers/ClaudeCode.tsx +++ b/webview-ui/src/components/settings/providers/ClaudeCode.tsx @@ -1,63 +1,68 @@ import React from "react" -import { VSCodeTextField } from "@vscode/webview-ui-toolkit/react" -import { type ProviderSettings } from "@roo-code/types" +import { type ProviderSettings, claudeCodeDefaultModelId, claudeCodeModels } from "@roo-code/types" import { useAppTranslation } from "@src/i18n/TranslationContext" -import { Slider } from "@src/components/ui" +import { Button } from "@src/components/ui" +import { vscode } from "@src/utils/vscode" +import { ModelPicker } from "../ModelPicker" +import { ClaudeCodeRateLimitDashboard } from "./ClaudeCodeRateLimitDashboard" interface ClaudeCodeProps { apiConfiguration: ProviderSettings setApiConfigurationField: (field: keyof ProviderSettings, value: ProviderSettings[keyof ProviderSettings]) => void simplifySettings?: boolean + claudeCodeIsAuthenticated?: boolean } -export const ClaudeCode: React.FC = ({ apiConfiguration, setApiConfigurationField }) => { +export const ClaudeCode: React.FC = ({ + apiConfiguration, + setApiConfigurationField, + simplifySettings, + claudeCodeIsAuthenticated = false, +}) => { const { t } = useAppTranslation() - const handleInputChange = (e: Event | React.FormEvent) => { - const element = e.target as HTMLInputElement - setApiConfigurationField("claudeCodePath", element.value) - } - - const maxOutputTokens = apiConfiguration?.claudeCodeMaxOutputTokens || 8000 - return (
-
- - {t("settings:providers.claudeCode.pathLabel")} - - -

- {t("settings:providers.claudeCode.description")} -

+ {/* Authentication Section */} +
+ {claudeCodeIsAuthenticated ? ( +
+ +
+ ) : ( + + )}
-
-
{t("settings:providers.claudeCode.maxTokensLabel")}
-
- setApiConfigurationField("claudeCodeMaxOutputTokens", value)} - /> -
{maxOutputTokens}
-
-

- {t("settings:providers.claudeCode.maxTokensDescription")} -

-
+ {/* Rate Limit Dashboard - only shown when authenticated */} + + + {/* Model Picker */} +
) } diff --git a/webview-ui/src/components/settings/providers/ClaudeCodeRateLimitDashboard.tsx b/webview-ui/src/components/settings/providers/ClaudeCodeRateLimitDashboard.tsx new file mode 100644 index 00000000000..9b152c27177 --- /dev/null +++ b/webview-ui/src/components/settings/providers/ClaudeCodeRateLimitDashboard.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useState, useCallback } from "react" +import type { ClaudeCodeRateLimitInfo } from "@roo-code/types" +import { vscode } from "@src/utils/vscode" + +interface ClaudeCodeRateLimitDashboardProps { + isAuthenticated: boolean +} + +/** + * Formats a Unix timestamp reset time into a human-readable duration + */ +function formatResetTime(resetTimestamp: number): string { + if (!resetTimestamp) return "N/A" + + const now = Date.now() / 1000 // Current time in seconds + const diff = resetTimestamp - now + + if (diff <= 0) return "Now" + + const hours = Math.floor(diff / 3600) + const minutes = Math.floor((diff % 3600) / 60) + + if (hours > 24) { + const days = Math.floor(hours / 24) + const remainingHours = hours % 24 + return `${days}d ${remainingHours}h` + } + + if (hours > 0) { + return `${hours}h ${minutes}m` + } + + return `${minutes}m` +} + +/** + * Formats utilization as a percentage + */ +function formatUtilization(utilization: number): string { + return `${(utilization * 100).toFixed(1)}%` +} + +/** + * Progress bar component for displaying usage + */ +const UsageProgressBar: React.FC<{ utilization: number; label: string }> = ({ utilization, label }) => { + const percentage = Math.min(utilization * 100, 100) + const isWarning = percentage >= 70 + const isCritical = percentage >= 90 + + return ( +
+
{label}
+
+
+
+
+ ) +} + +export const ClaudeCodeRateLimitDashboard: React.FC = ({ isAuthenticated }) => { + const [rateLimits, setRateLimits] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchRateLimits = useCallback(() => { + if (!isAuthenticated) { + setRateLimits(null) + setError(null) + return + } + + setIsLoading(true) + setError(null) + vscode.postMessage({ type: "requestClaudeCodeRateLimits" }) + }, [isAuthenticated]) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "claudeCodeRateLimits") { + setIsLoading(false) + if (message.error) { + setError(message.error) + setRateLimits(null) + } else if (message.values) { + setRateLimits(message.values) + setError(null) + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + + // Fetch rate limits when authenticated + useEffect(() => { + if (isAuthenticated) { + fetchRateLimits() + } + }, [isAuthenticated, fetchRateLimits]) + + if (!isAuthenticated) { + return null + } + + if (isLoading && !rateLimits) { + return ( +
+
Loading rate limits...
+
+ ) + } + + if (error) { + return ( +
+
+
Failed to load rate limits
+ +
+
+ ) + } + + if (!rateLimits) { + return null + } + + return ( +
+
+
Usage Limits
+
+ +
+ {/* 5-hour limit */} +
+
+ + Limit: {rateLimits.representativeClaim || "5-hour"} + + + {formatUtilization(rateLimits.fiveHour.utilization)} used • resets in{" "} + {formatResetTime(rateLimits.fiveHour.resetTime)} + +
+ +
+ + {/* Weekly limit (if available) */} + {rateLimits.weeklyUnified && rateLimits.weeklyUnified.utilization > 0 && ( +
+
+ Weekly + + {formatUtilization(rateLimits.weeklyUnified.utilization)} used • resets in{" "} + {formatResetTime(rateLimits.weeklyUnified.resetTime)} + +
+ +
+ )} +
+
+ ) +} diff --git a/webview-ui/src/components/ui/select.tsx b/webview-ui/src/components/ui/select.tsx index 6e8bcb612e1..2a841930474 100644 --- a/webview-ui/src/components/ui/select.tsx +++ b/webview-ui/src/components/ui/select.tsx @@ -22,7 +22,7 @@ function SelectTrigger({ className, children, ...props }: React.ComponentProps Date: Sat, 13 Dec 2025 21:29:55 -0700 Subject: [PATCH 02/16] fix: correct maxTokens test expectations to match model definition The test expectations incorrectly expected maxTokens of 16384, but the claudeCodeModels definition specifies maxTokens of 32768 for all models. The handler code 'model.info.maxTokens ?? 16384' correctly evaluates to 32768 since the model definition provides the value. Updated 3 test expectations to use the correct value of 32768. --- src/api/providers/__tests__/claude-code.spec.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts index eefd1609f07..d6264bc8425 100644 --- a/src/api/providers/__tests__/claude-code.spec.ts +++ b/src/api/providers/__tests__/claude-code.spec.ts @@ -110,13 +110,13 @@ describe("ClaudeCodeHandler", () => { // Verify createStreamingMessage was called with correct parameters // Default model has reasoning effort of "medium" so thinking should be enabled - // With interleaved thinking, maxTokens comes from model definition, not reasoning config + // With interleaved thinking, maxTokens comes from model definition (32768 for claude-sonnet-4-5) expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ accessToken: "test-access-token", model: "claude-sonnet-4-5", systemPrompt, messages, - maxTokens: 16384, // model's maxTokens (interleaved thinking uses context window) + maxTokens: 32768, // model's maxTokens from claudeCodeModels definition thinking: { type: "enabled", budget_tokens: 32000, // medium reasoning budget_tokens @@ -159,7 +159,7 @@ describe("ClaudeCodeHandler", () => { model: "claude-sonnet-4-5", systemPrompt, messages, - maxTokens: 16384, // model default maxTokens + maxTokens: 32768, // model maxTokens from claudeCodeModels definition thinking: { type: "disabled" }, tools: undefined, toolChoice: undefined, @@ -194,13 +194,13 @@ describe("ClaudeCodeHandler", () => { await iterator.next() // Verify createStreamingMessage was called with high thinking config - // With interleaved thinking, maxTokens comes from model definition, not reasoning config + // With interleaved thinking, maxTokens comes from model definition (32768 for claude-sonnet-4-5) expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ accessToken: "test-access-token", model: "claude-sonnet-4-5", systemPrompt, messages, - maxTokens: 16384, // model's maxTokens (interleaved thinking uses context window) + maxTokens: 32768, // model's maxTokens from claudeCodeModels definition thinking: { type: "enabled", budget_tokens: 64000, // high reasoning budget_tokens From 1347c97692e5fc878bd0dae1c197db92ba707c6b Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 13 Dec 2025 21:41:20 -0700 Subject: [PATCH 03/16] test: update claude-code tests to reflect supportsImages and valid model IDs - Changed model ID from 'claude-sonnet-4-20250514' to 'claude-sonnet-4-5' (valid model) - Updated supportsImages expectation from false to true (Claude Code now supports images) - Updated maxTokens expectation from 64_000 to 32768 (actual model value) - Renamed test to 'should return claude-code model with correct model info' --- .../ui/hooks/__tests__/useSelectedModel.spec.ts | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index a7824b1fa28..118184857c4 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -407,7 +407,7 @@ describe("useSelectedModel", () => { }) describe("claude-code provider", () => { - it("should return claude-code model with supportsImages disabled", () => { + it("should return claude-code model with correct model info", () => { mockUseRouterModels.mockReturnValue({ data: { openrouter: {}, @@ -428,19 +428,19 @@ describe("useSelectedModel", () => { const apiConfiguration: ProviderSettings = { apiProvider: "claude-code", - apiModelId: "claude-sonnet-4-20250514", + apiModelId: "claude-sonnet-4-5", // Use valid claude-code model ID } const wrapper = createWrapper() const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) expect(result.current.provider).toBe("claude-code") - expect(result.current.id).toBe("claude-sonnet-4-20250514") + expect(result.current.id).toBe("claude-sonnet-4-5") expect(result.current.info).toBeDefined() - expect(result.current.info?.supportsImages).toBe(false) + expect(result.current.info?.supportsImages).toBe(true) // Claude Code now supports images expect(result.current.info?.supportsPromptCache).toBe(true) // Claude Code now supports prompt cache - // Verify it inherits other properties from anthropic models - expect(result.current.info?.maxTokens).toBe(64_000) + // Verify it inherits other properties from claude-code models + expect(result.current.info?.maxTokens).toBe(32768) expect(result.current.info?.contextWindow).toBe(200_000) }) @@ -473,7 +473,7 @@ describe("useSelectedModel", () => { expect(result.current.provider).toBe("claude-code") expect(result.current.id).toBe("claude-sonnet-4-5") // Default model expect(result.current.info).toBeDefined() - expect(result.current.info?.supportsImages).toBe(false) + expect(result.current.info?.supportsImages).toBe(true) // Claude Code now supports images }) }) From 8182724ef0450c52a0de3689084ce18bf16e81bf Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sat, 13 Dec 2025 21:50:16 -0700 Subject: [PATCH 04/16] fix: update claude-code-caching.spec.ts to mock OAuth and streaming client The caching tests were using outdated mocks for the deprecated runClaudeCode function. Updated to mock claudeCodeOAuthManager and createStreamingMessage instead, matching the pattern used in claude-code.spec.ts. --- .../__tests__/claude-code-caching.spec.ts | 292 +++++------------- 1 file changed, 78 insertions(+), 214 deletions(-) diff --git a/src/api/providers/__tests__/claude-code-caching.spec.ts b/src/api/providers/__tests__/claude-code-caching.spec.ts index 96b19964fcd..a0996ab244b 100644 --- a/src/api/providers/__tests__/claude-code-caching.spec.ts +++ b/src/api/providers/__tests__/claude-code-caching.spec.ts @@ -1,79 +1,57 @@ -import type { Anthropic } from "@anthropic-ai/sdk" - import { ClaudeCodeHandler } from "../claude-code" -import { runClaudeCode } from "../../../integrations/claude-code/run" import type { ApiHandlerOptions } from "../../../shared/api" -import type { ClaudeCodeMessage } from "../../../integrations/claude-code/types" +import type { StreamChunk } from "../../../integrations/claude-code/streaming-client" import type { ApiStreamUsageChunk } from "../../transform/stream" -// Mock the runClaudeCode function -vi.mock("../../../integrations/claude-code/run", () => ({ - runClaudeCode: vi.fn(), +// Mock the OAuth manager +vi.mock("../../../integrations/claude-code/oauth", () => ({ + claudeCodeOAuthManager: { + getAccessToken: vi.fn(), + getEmail: vi.fn(), + loadCredentials: vi.fn(), + saveCredentials: vi.fn(), + clearCredentials: vi.fn(), + isAuthenticated: vi.fn(), + }, + generateUserId: vi.fn(() => "user_abc123_account_def456_session_ghi789"), +})) + +// Mock the streaming client +vi.mock("../../../integrations/claude-code/streaming-client", () => ({ + createStreamingMessage: vi.fn(), })) +const { claudeCodeOAuthManager } = await import("../../../integrations/claude-code/oauth") +const { createStreamingMessage } = await import("../../../integrations/claude-code/streaming-client") + +const mockGetAccessToken = vi.mocked(claudeCodeOAuthManager.getAccessToken) +const mockCreateStreamingMessage = vi.mocked(createStreamingMessage) + describe("ClaudeCodeHandler - Caching Support", () => { let handler: ClaudeCodeHandler const mockOptions: ApiHandlerOptions = { - apiKey: "test-key", - apiModelId: "claude-3-5-sonnet-20241022", - claudeCodePath: "/test/path", + apiModelId: "claude-sonnet-4-5", } beforeEach(() => { handler = new ClaudeCodeHandler(mockOptions) vi.clearAllMocks() + mockGetAccessToken.mockResolvedValue("test-access-token") }) it("should collect cache read tokens from API response", async () => { - const mockStream = async function* (): AsyncGenerator { - // Initial system message + const mockStream = async function* (): AsyncGenerator { + yield { type: "text", text: "Hello!" } yield { - type: "system", - subtype: "init", - session_id: "test-session", - tools: [], - mcp_servers: [], - apiKeySource: "user", - } as ClaudeCodeMessage - - // Assistant message with cache tokens - const message: Anthropic.Messages.Message = { - id: "msg_123", - type: "message", - role: "assistant", - model: "claude-3-5-sonnet-20241022", - content: [{ type: "text", text: "Hello!", citations: [] }], - usage: { - input_tokens: 100, - output_tokens: 50, - cache_read_input_tokens: 80, // 80 tokens read from cache - cache_creation_input_tokens: 20, // 20 new tokens cached - }, - stop_reason: "end_turn", - stop_sequence: null, + type: "usage", + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 80, + cacheWriteTokens: 20, } - - yield { - type: "assistant", - message, - session_id: "test-session", - } as ClaudeCodeMessage - - // Result with cost - yield { - type: "result", - subtype: "success", - result: "success", - total_cost_usd: 0.001, - is_error: false, - duration_ms: 1000, - duration_api_ms: 900, - num_turns: 1, - session_id: "test-session", - } as ClaudeCodeMessage } - vi.mocked(runClaudeCode).mockReturnValue(mockStream()) + mockCreateStreamingMessage.mockReturnValue(mockStream()) const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }]) @@ -92,76 +70,29 @@ describe("ClaudeCodeHandler - Caching Support", () => { }) it("should accumulate cache tokens across multiple messages", async () => { - const mockStream = async function* (): AsyncGenerator { + // Note: The streaming client handles accumulation internally. + // Each usage chunk represents the accumulated totals for that point in the stream. + // This test verifies that we correctly pass through the accumulated values. + const mockStream = async function* (): AsyncGenerator { + yield { type: "text", text: "Part 1" } yield { - type: "system", - subtype: "init", - session_id: "test-session", - tools: [], - mcp_servers: [], - apiKeySource: "user", - } as ClaudeCodeMessage - - // First message chunk - const message1: Anthropic.Messages.Message = { - id: "msg_1", - type: "message", - role: "assistant", - model: "claude-3-5-sonnet-20241022", - content: [{ type: "text", text: "Part 1", citations: [] }], - usage: { - input_tokens: 50, - output_tokens: 25, - cache_read_input_tokens: 40, - cache_creation_input_tokens: 10, - }, - stop_reason: null, - stop_sequence: null, + type: "usage", + inputTokens: 50, + outputTokens: 25, + cacheReadTokens: 40, + cacheWriteTokens: 10, } - + yield { type: "text", text: "Part 2" } yield { - type: "assistant", - message: message1, - session_id: "test-session", - } as ClaudeCodeMessage - - // Second message chunk - const message2: Anthropic.Messages.Message = { - id: "msg_2", - type: "message", - role: "assistant", - model: "claude-3-5-sonnet-20241022", - content: [{ type: "text", text: "Part 2", citations: [] }], - usage: { - input_tokens: 50, - output_tokens: 25, - cache_read_input_tokens: 30, - cache_creation_input_tokens: 20, - }, - stop_reason: "end_turn", - stop_sequence: null, + type: "usage", + inputTokens: 100, // Accumulated: 50 + 50 + outputTokens: 50, // Accumulated: 25 + 25 + cacheReadTokens: 70, // Accumulated: 40 + 30 + cacheWriteTokens: 30, // Accumulated: 10 + 20 } - - yield { - type: "assistant", - message: message2, - session_id: "test-session", - } as ClaudeCodeMessage - - yield { - type: "result", - subtype: "success", - result: "success", - total_cost_usd: 0.002, - is_error: false, - duration_ms: 2000, - duration_api_ms: 1800, - num_turns: 1, - session_id: "test-session", - } as ClaudeCodeMessage } - vi.mocked(runClaudeCode).mockReturnValue(mockStream()) + mockCreateStreamingMessage.mockReturnValue(mockStream()) const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }]) @@ -170,62 +101,29 @@ describe("ClaudeCodeHandler - Caching Support", () => { chunks.push(chunk) } - const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined - expect(usageChunk).toBeDefined() - expect(usageChunk!.inputTokens).toBe(100) // 50 + 50 - expect(usageChunk!.outputTokens).toBe(50) // 25 + 25 - expect(usageChunk!.cacheReadTokens).toBe(70) // 40 + 30 - expect(usageChunk!.cacheWriteTokens).toBe(30) // 10 + 20 + // Get the last usage chunk which should have accumulated totals + const usageChunks = chunks.filter((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk[] + expect(usageChunks.length).toBe(2) + + const lastUsageChunk = usageChunks[usageChunks.length - 1] + expect(lastUsageChunk.inputTokens).toBe(100) // 50 + 50 + expect(lastUsageChunk.outputTokens).toBe(50) // 25 + 25 + expect(lastUsageChunk.cacheReadTokens).toBe(70) // 40 + 30 + expect(lastUsageChunk.cacheWriteTokens).toBe(30) // 10 + 20 }) it("should handle missing cache token fields gracefully", async () => { - const mockStream = async function* (): AsyncGenerator { + const mockStream = async function* (): AsyncGenerator { + yield { type: "text", text: "Hello!" } yield { - type: "system", - subtype: "init", - session_id: "test-session", - tools: [], - mcp_servers: [], - apiKeySource: "user", - } as ClaudeCodeMessage - - // Message without cache tokens - const message: Anthropic.Messages.Message = { - id: "msg_123", - type: "message", - role: "assistant", - model: "claude-3-5-sonnet-20241022", - content: [{ type: "text", text: "Hello!", citations: [] }], - usage: { - input_tokens: 100, - output_tokens: 50, - cache_read_input_tokens: null, - cache_creation_input_tokens: null, - }, - stop_reason: "end_turn", - stop_sequence: null, + type: "usage", + inputTokens: 100, + outputTokens: 50, + // No cache tokens provided } - - yield { - type: "assistant", - message, - session_id: "test-session", - } as ClaudeCodeMessage - - yield { - type: "result", - subtype: "success", - result: "success", - total_cost_usd: 0.001, - is_error: false, - duration_ms: 1000, - duration_api_ms: 900, - num_turns: 1, - session_id: "test-session", - } as ClaudeCodeMessage } - vi.mocked(runClaudeCode).mockReturnValue(mockStream()) + mockCreateStreamingMessage.mockReturnValue(mockStream()) const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }]) @@ -238,58 +136,24 @@ describe("ClaudeCodeHandler - Caching Support", () => { expect(usageChunk).toBeDefined() expect(usageChunk!.inputTokens).toBe(100) expect(usageChunk!.outputTokens).toBe(50) - expect(usageChunk!.cacheReadTokens).toBe(0) - expect(usageChunk!.cacheWriteTokens).toBe(0) + expect(usageChunk!.cacheReadTokens).toBeUndefined() + expect(usageChunk!.cacheWriteTokens).toBeUndefined() }) it("should report zero cost for subscription usage", async () => { - const mockStream = async function* (): AsyncGenerator { - // Subscription usage has apiKeySource: "none" + // Claude Code is always subscription-based, cost should always be 0 + const mockStream = async function* (): AsyncGenerator { + yield { type: "text", text: "Hello!" } yield { - type: "system", - subtype: "init", - session_id: "test-session", - tools: [], - mcp_servers: [], - apiKeySource: "none", - } as ClaudeCodeMessage - - const message: Anthropic.Messages.Message = { - id: "msg_123", - type: "message", - role: "assistant", - model: "claude-3-5-sonnet-20241022", - content: [{ type: "text", text: "Hello!", citations: [] }], - usage: { - input_tokens: 100, - output_tokens: 50, - cache_read_input_tokens: 80, - cache_creation_input_tokens: 20, - }, - stop_reason: "end_turn", - stop_sequence: null, + type: "usage", + inputTokens: 100, + outputTokens: 50, + cacheReadTokens: 80, + cacheWriteTokens: 20, } - - yield { - type: "assistant", - message, - session_id: "test-session", - } as ClaudeCodeMessage - - yield { - type: "result", - subtype: "success", - result: "success", - total_cost_usd: 0.001, // This should be ignored for subscription usage - is_error: false, - duration_ms: 1000, - duration_api_ms: 900, - num_turns: 1, - session_id: "test-session", - } as ClaudeCodeMessage } - vi.mocked(runClaudeCode).mockReturnValue(mockStream()) + mockCreateStreamingMessage.mockReturnValue(mockStream()) const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }]) @@ -300,6 +164,6 @@ describe("ClaudeCodeHandler - Caching Support", () => { const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined expect(usageChunk).toBeDefined() - expect(usageChunk!.totalCost).toBe(0) // Should be 0 for subscription usage + expect(usageChunk!.totalCost).toBe(0) // Should always be 0 for Claude Code (subscription-based) }) }) From 7c72b855262b6bb455841a4b08a563541027cfdd Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sun, 14 Dec 2025 15:33:00 -0700 Subject: [PATCH 05/16] feat(claude-code): add completePrompt for context condensing and prompt enhancement - Add SingleCompletionHandler interface to ClaudeCodeHandler - Implement completePrompt method that uses createStreamingMessage - Claude Code branding is automatically prepended by streaming-client - Add tests for completePrompt method (5 new tests, 22 total passing) --- .../providers/__tests__/claude-code.spec.ts | 111 ++++++++++++++++++ src/api/providers/claude-code.ts | 67 ++++++++++- 2 files changed, 176 insertions(+), 2 deletions(-) diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts index d6264bc8425..5b5bdca65ae 100644 --- a/src/api/providers/__tests__/claude-code.spec.ts +++ b/src/api/providers/__tests__/claude-code.spec.ts @@ -24,6 +24,7 @@ const { claudeCodeOAuthManager } = await import("../../../integrations/claude-co const { createStreamingMessage } = await import("../../../integrations/claude-code/streaming-client") const mockGetAccessToken = vi.mocked(claudeCodeOAuthManager.getAccessToken) +const mockGetEmail = vi.mocked(claudeCodeOAuthManager.getEmail) const mockCreateStreamingMessage = vi.mocked(createStreamingMessage) describe("ClaudeCodeHandler", () => { @@ -483,4 +484,114 @@ describe("ClaudeCodeHandler", () => { }), ) }) + + describe("completePrompt", () => { + test("should throw error when not authenticated", async () => { + mockGetAccessToken.mockResolvedValue(null) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow(/not authenticated/i) + }) + + test("should complete prompt and return text response", async () => { + mockGetAccessToken.mockResolvedValue("test-access-token") + mockGetEmail.mockResolvedValue("test@example.com") + + // Mock async generator that yields text chunks + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "text", text: "Hello " } + yield { type: "text", text: "world!" } + yield { type: "usage", inputTokens: 10, outputTokens: 5 } + } + + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) + + const result = await handler.completePrompt("Say hello") + + expect(result).toBe("Hello world!") + }) + + test("should call createStreamingMessage with empty system prompt and thinking disabled", async () => { + mockGetAccessToken.mockResolvedValue("test-access-token") + mockGetEmail.mockResolvedValue("test@example.com") + + // Mock empty async generator + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "text", text: "Response" } + } + + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) + + await handler.completePrompt("Test prompt") + + // Verify createStreamingMessage was called with correct parameters + // System prompt is empty because the prompt text contains all context + // createStreamingMessage will still prepend the Claude Code branding + expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ + accessToken: "test-access-token", + model: "claude-sonnet-4-5", + systemPrompt: "", // Empty - branding is added by createStreamingMessage + messages: [{ role: "user", content: "Test prompt" }], + maxTokens: 32768, + thinking: { type: "disabled" }, // No thinking for simple completions + metadata: { + user_id: "user_abc123_account_def456_session_ghi789", + }, + }) + }) + + test("should handle API errors from streaming", async () => { + mockGetAccessToken.mockResolvedValue("test-access-token") + mockGetEmail.mockResolvedValue("test@example.com") + + // Mock async generator that yields an error + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "error", error: "API rate limit exceeded" } + } + + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) + + await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API rate limit exceeded") + }) + + test("should return empty string when no text chunks received", async () => { + mockGetAccessToken.mockResolvedValue("test-access-token") + mockGetEmail.mockResolvedValue("test@example.com") + + // Mock async generator that only yields usage + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "usage", inputTokens: 10, outputTokens: 0 } + } + + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) + + const result = await handler.completePrompt("Test prompt") + + expect(result).toBe("") + }) + + test("should use opus model maxTokens when configured", async () => { + const options: ApiHandlerOptions = { + apiModelId: "claude-opus-4-5", + } + const handlerOpus = new ClaudeCodeHandler(options) + + mockGetAccessToken.mockResolvedValue("test-access-token") + mockGetEmail.mockResolvedValue("test@example.com") + + const mockGenerator = async function* (): AsyncGenerator { + yield { type: "text", text: "Response" } + } + + mockCreateStreamingMessage.mockReturnValue(mockGenerator()) + + await handlerOpus.completePrompt("Test prompt") + + expect(mockCreateStreamingMessage).toHaveBeenCalledWith( + expect.objectContaining({ + model: "claude-opus-4-5", + maxTokens: 32768, // opus model maxTokens + }), + ) + }) + }) }) diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index 7f4b0284001..3808b88e727 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -8,7 +8,7 @@ import { type ClaudeCodeReasoningLevel, type ModelInfo, } from "@roo-code/types" -import { type ApiHandler, ApiHandlerCreateMessageMetadata } from ".." +import { type ApiHandler, ApiHandlerCreateMessageMetadata, type SingleCompletionHandler } from ".." import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream" import { claudeCodeOAuthManager, generateUserId } from "../../integrations/claude-code/oauth" import { @@ -64,7 +64,7 @@ function convertOpenAIToolChoice( return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } } -export class ClaudeCodeHandler implements ApiHandler { +export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler { private options: ApiHandlerOptions /** * Store the last thinking block signature for interleaved thinking with tool use. @@ -288,4 +288,67 @@ export class ClaudeCodeHandler implements ApiHandler { } return countTokens(content, { useWorker: true }) } + + /** + * Completes a prompt using the Claude Code API. + * This is used for context condensing and prompt enhancement. + * The Claude Code branding is automatically prepended by createStreamingMessage. + */ + async completePrompt(prompt: string): Promise { + // Get access token from OAuth manager + const accessToken = await claudeCodeOAuthManager.getAccessToken() + + if (!accessToken) { + throw new Error( + t("common:errors.claudeCode.notAuthenticated", { + defaultValue: + "Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.", + }), + ) + } + + // Get user email for generating user_id metadata + const email = await claudeCodeOAuthManager.getEmail() + + const model = this.getModel() + + // Validate that the model ID is a valid ClaudeCodeModelId + const modelId = model.id in claudeCodeModels ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId + + // Generate user_id metadata in the format required by Claude Code API + const userId = generateUserId(email || undefined) + + // Use maxTokens from model info for completion + const maxTokens = model.info.maxTokens ?? 16384 + + // Create streaming request using OAuth + // The system prompt is empty here since the prompt itself contains all context + // createStreamingMessage will still prepend the Claude Code branding + const stream = createStreamingMessage({ + accessToken, + model: modelId, + systemPrompt: "", // Empty system prompt - the prompt text contains all necessary context + messages: [{ role: "user", content: prompt }], + maxTokens, + thinking: { type: "disabled" }, // No thinking for simple completions + metadata: { + user_id: userId, + }, + }) + + // Collect all text chunks into a single response + let result = "" + + for await (const chunk of stream) { + switch (chunk.type) { + case "text": + result += chunk.text + break + case "error": + throw new Error(chunk.error) + } + } + + return result + } } From 7749af4bebdec112bd8dc55b944f0463adffa25e Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sun, 14 Dec 2025 15:42:15 -0700 Subject: [PATCH 06/16] feat(claude-code): add backward compatibility for legacy model names MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add normalizeClaudeCodeModelId() function that maps legacy Claude Code model IDs to their current counterparts, preventing breaking changes when models are updated: - Sonnet models (claude-sonnet-4-5-*, claude-sonnet-4-*, claude-3-7-sonnet-*, claude-3-5-sonnet-*) → claude-sonnet-4-5 - Opus models (claude-opus-4-5-*, claude-opus-4-1-*, claude-opus-4-*) → claude-opus-4-5 - Haiku models (claude-haiku-4-5-*, claude-3-5-haiku-*) → claude-haiku-4-5 The normalization is applied in useSelectedModel for the claude-code provider. --- .../providers/__tests__/claude-code.spec.ts | 47 +++++- packages/types/src/providers/claude-code.ts | 136 +++++++++++++----- .../components/ui/hooks/useSelectedModel.ts | 9 +- 3 files changed, 150 insertions(+), 42 deletions(-) diff --git a/packages/types/src/providers/__tests__/claude-code.spec.ts b/packages/types/src/providers/__tests__/claude-code.spec.ts index 51ac07ab63e..26b6267e8b1 100644 --- a/packages/types/src/providers/__tests__/claude-code.spec.ts +++ b/packages/types/src/providers/__tests__/claude-code.spec.ts @@ -1,4 +1,4 @@ -import { convertModelNameForVertex, getClaudeCodeModelId } from "../claude-code.js" +import { convertModelNameForVertex, getClaudeCodeModelId, normalizeClaudeCodeModelId } from "../claude-code.js" describe("convertModelNameForVertex", () => { test("should convert hyphen-date format to @date format", () => { @@ -40,3 +40,48 @@ describe("getClaudeCodeModelId", () => { expect(getClaudeCodeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4-5") }) }) + +describe("normalizeClaudeCodeModelId", () => { + test("should return valid model IDs unchanged", () => { + expect(normalizeClaudeCodeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4-5") + expect(normalizeClaudeCodeModelId("claude-opus-4-5")).toBe("claude-opus-4-5") + expect(normalizeClaudeCodeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5") + }) + + test("should normalize sonnet models with date suffix to claude-sonnet-4-5", () => { + // Sonnet 4.5 with date + expect(normalizeClaudeCodeModelId("claude-sonnet-4-5-20250929")).toBe("claude-sonnet-4-5") + // Sonnet 4 (legacy) + expect(normalizeClaudeCodeModelId("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-5") + // Claude 3.7 Sonnet + expect(normalizeClaudeCodeModelId("claude-3-7-sonnet-20250219")).toBe("claude-sonnet-4-5") + // Claude 3.5 Sonnet + expect(normalizeClaudeCodeModelId("claude-3-5-sonnet-20241022")).toBe("claude-sonnet-4-5") + }) + + test("should normalize opus models with date suffix to claude-opus-4-5", () => { + // Opus 4.5 with date + expect(normalizeClaudeCodeModelId("claude-opus-4-5-20251101")).toBe("claude-opus-4-5") + // Opus 4.1 (legacy) + expect(normalizeClaudeCodeModelId("claude-opus-4-1-20250805")).toBe("claude-opus-4-5") + // Opus 4 (legacy) + expect(normalizeClaudeCodeModelId("claude-opus-4-20250514")).toBe("claude-opus-4-5") + }) + + test("should normalize haiku models with date suffix to claude-haiku-4-5", () => { + // Haiku 4.5 with date + expect(normalizeClaudeCodeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5") + // Claude 3.5 Haiku + expect(normalizeClaudeCodeModelId("claude-3-5-haiku-20241022")).toBe("claude-haiku-4-5") + }) + + test("should handle case-insensitive model family matching", () => { + expect(normalizeClaudeCodeModelId("Claude-Sonnet-4-5-20250929")).toBe("claude-sonnet-4-5") + expect(normalizeClaudeCodeModelId("CLAUDE-OPUS-4-5-20251101")).toBe("claude-opus-4-5") + }) + + test("should fallback to default for unrecognized models", () => { + expect(normalizeClaudeCodeModelId("unknown-model")).toBe("claude-sonnet-4-5") + expect(normalizeClaudeCodeModelId("gpt-4")).toBe("claude-sonnet-4-5") + }) +}) diff --git a/packages/types/src/providers/claude-code.ts b/packages/types/src/providers/claude-code.ts index dcf93b693c8..39b240706a7 100644 --- a/packages/types/src/providers/claude-code.ts +++ b/packages/types/src/providers/claude-code.ts @@ -40,6 +40,9 @@ export interface ClaudeCodeRateLimitInfo { // Regex pattern to match 8-digit date at the end of model names const VERTEX_DATE_PATTERN = /-(\d{8})$/ +// Regex pattern to strip date suffix from model names +const DATE_SUFFIX_PATTERN = /-\d{8}$/ + /** * Converts Claude model names from hyphen-date format to Vertex AI's @-date format. * @@ -55,44 +58,6 @@ export function convertModelNameForVertex(modelName: string): string { return modelName.replace(VERTEX_DATE_PATTERN, "@$1") } -// Claude Code - Only models that work with Claude Code OAuth tokens -export type ClaudeCodeModelId = keyof typeof claudeCodeModels -export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5" -export const CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS = 16000 - -/** - * Reasoning effort configuration for Claude Code thinking mode. - * Maps reasoning effort level to budget_tokens for the thinking process. - * - * Note: With interleaved thinking (enabled via beta header), budget_tokens - * can exceed max_tokens as the token limit becomes the entire context window. - * The max_tokens is drawn from the model's maxTokens definition. - * - * @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking - */ -export const claudeCodeReasoningConfig = { - low: { budgetTokens: 16_000 }, - medium: { budgetTokens: 32_000 }, - high: { budgetTokens: 64_000 }, -} as const - -export type ClaudeCodeReasoningLevel = keyof typeof claudeCodeReasoningConfig - -/** - * Gets the appropriate model ID based on whether Vertex AI is being used. - * - * @param baseModelId - The base Claude Code model ID - * @param useVertex - Whether to format the model ID for Vertex AI (default: false) - * @returns The model ID, potentially formatted for Vertex AI - * - * @example - * getClaudeCodeModelId("claude-sonnet-4-20250514", true) // returns "claude-sonnet-4@20250514" - * getClaudeCodeModelId("claude-sonnet-4-20250514", false) // returns "claude-sonnet-4-20250514" - */ -export function getClaudeCodeModelId(baseModelId: ClaudeCodeModelId, useVertex = false): string { - return useVertex ? convertModelNameForVertex(baseModelId) : baseModelId -} - // Models that work with Claude Code OAuth tokens // See: https://docs.anthropic.com/en/docs/claude-code // NOTE: Claude Code is subscription-based with no per-token cost - pricing fields are 0 @@ -131,3 +96,98 @@ export const claudeCodeModels = { description: "Claude Opus 4.5 - Most capable with thinking", }, } as const satisfies Record + +// Claude Code - Only models that work with Claude Code OAuth tokens +export type ClaudeCodeModelId = keyof typeof claudeCodeModels +export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5" +export const CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS = 16000 + +/** + * Model family patterns for normalization. + * Maps regex patterns to their canonical Claude Code model IDs. + * + * Order matters - more specific patterns should come first. + */ +const MODEL_FAMILY_PATTERNS: Array<{ pattern: RegExp; target: ClaudeCodeModelId }> = [ + // Opus models (any version) → claude-opus-4-5 + { pattern: /opus/i, target: "claude-opus-4-5" }, + // Haiku models (any version) → claude-haiku-4-5 + { pattern: /haiku/i, target: "claude-haiku-4-5" }, + // Sonnet models (any version) → claude-sonnet-4-5 + { pattern: /sonnet/i, target: "claude-sonnet-4-5" }, +] + +/** + * Normalizes a Claude model ID to a valid Claude Code model ID. + * + * This function handles backward compatibility for legacy model names + * that may include version numbers or date suffixes. It maps: + * - claude-sonnet-4-5-20250929, claude-sonnet-4-20250514, claude-3-7-sonnet-20250219, claude-3-5-sonnet-20241022 → claude-sonnet-4-5 + * - claude-opus-4-5-20251101, claude-opus-4-1-20250805, claude-opus-4-20250514 → claude-opus-4-5 + * - claude-haiku-4-5-20251001, claude-3-5-haiku-20241022 → claude-haiku-4-5 + * + * @param modelId - The model ID to normalize (may be a legacy format) + * @returns A valid ClaudeCodeModelId, or the original ID if already valid + * + * @example + * normalizeClaudeCodeModelId("claude-sonnet-4-5") // returns "claude-sonnet-4-5" + * normalizeClaudeCodeModelId("claude-3-5-sonnet-20241022") // returns "claude-sonnet-4-5" + * normalizeClaudeCodeModelId("claude-opus-4-1-20250805") // returns "claude-opus-4-5" + */ +export function normalizeClaudeCodeModelId(modelId: string): ClaudeCodeModelId { + // If already a valid model ID, return as-is + if (modelId in claudeCodeModels) { + return modelId as ClaudeCodeModelId + } + + // Strip date suffix if present (e.g., -20250514) + const withoutDate = modelId.replace(DATE_SUFFIX_PATTERN, "") + + // Check if stripping the date makes it valid + if (withoutDate in claudeCodeModels) { + return withoutDate as ClaudeCodeModelId + } + + // Match by model family + for (const { pattern, target } of MODEL_FAMILY_PATTERNS) { + if (pattern.test(modelId)) { + return target + } + } + + // Fallback to default if no match (shouldn't happen with valid Claude models) + return claudeCodeDefaultModelId +} + +/** + * Reasoning effort configuration for Claude Code thinking mode. + * Maps reasoning effort level to budget_tokens for the thinking process. + * + * Note: With interleaved thinking (enabled via beta header), budget_tokens + * can exceed max_tokens as the token limit becomes the entire context window. + * The max_tokens is drawn from the model's maxTokens definition. + * + * @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking + */ +export const claudeCodeReasoningConfig = { + low: { budgetTokens: 16_000 }, + medium: { budgetTokens: 32_000 }, + high: { budgetTokens: 64_000 }, +} as const + +export type ClaudeCodeReasoningLevel = keyof typeof claudeCodeReasoningConfig + +/** + * Gets the appropriate model ID based on whether Vertex AI is being used. + * + * @param baseModelId - The base Claude Code model ID + * @param useVertex - Whether to format the model ID for Vertex AI (default: false) + * @returns The model ID, potentially formatted for Vertex AI + * + * @example + * getClaudeCodeModelId("claude-sonnet-4-20250514", true) // returns "claude-sonnet-4@20250514" + * getClaudeCodeModelId("claude-sonnet-4-20250514", false) // returns "claude-sonnet-4-20250514" + */ +export function getClaudeCodeModelId(baseModelId: ClaudeCodeModelId, useVertex = false): string { + return useVertex ? convertModelNameForVertex(baseModelId) : baseModelId +} diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 010adc3155b..456c7d824e5 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -18,6 +18,7 @@ import { vscodeLlmModels, vscodeLlmDefaultModelId, claudeCodeModels, + normalizeClaudeCodeModelId, sambaNovaModels, doubaoModels, internationalZAiModels, @@ -314,9 +315,11 @@ function getSelectedModel({ } case "claude-code": { // Claude Code models extend anthropic models but with images and prompt caching disabled - const id = apiConfiguration.apiModelId ?? defaultModelId - const info = claudeCodeModels[id as keyof typeof claudeCodeModels] - return { id, info: { ...openAiModelInfoSaneDefaults, ...info } } + // Normalize legacy model IDs to current canonical model IDs for backward compatibility + const rawId = apiConfiguration.apiModelId ?? defaultModelId + const normalizedId = normalizeClaudeCodeModelId(rawId) + const info = claudeCodeModels[normalizedId] + return { id: normalizedId, info: { ...openAiModelInfoSaneDefaults, ...info } } } case "cerebras": { const id = apiConfiguration.apiModelId ?? defaultModelId From 1933f1a65cca74def2dfff4a345ce1f36a43ee75 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sun, 14 Dec 2025 16:13:47 -0700 Subject: [PATCH 07/16] feat: improve Claude Code auth error message with settings link - Detect Claude Code authentication errors in ChatRow and show user-friendly message - Add internal URL handling (roocode://settings) to navigate to settings panel - Add i18n strings for Claude Code auth error and Settings link text - Replace generic 'Unknown API error' with actionable guidance --- webview-ui/src/components/chat/ChatRow.tsx | 46 ++++++++++++--------- webview-ui/src/components/chat/ErrorRow.tsx | 13 +++++- webview-ui/src/i18n/locales/en/chat.json | 4 +- 3 files changed, 40 insertions(+), 23 deletions(-) diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index 22ca6e43576..fa88e54912d 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1090,30 +1090,36 @@ export const ChatRowContent = ({ let body = t(`chat:apiRequest.failed`) let retryInfo, rawError, code, docsURL if (message.text !== undefined) { - // Try to show richer error message for that code, if available - const potentialCode = parseInt(message.text.substring(0, 3)) - if (!isNaN(potentialCode) && potentialCode >= 400) { - code = potentialCode - const stringForError = `chat:apiRequest.errorMessage.${code}` - if (i18n.exists(stringForError)) { - body = t(stringForError) - // Fill this out in upcoming PRs - // Do not remove this - // switch(code) { - // case ERROR_CODE: - // docsURL = ??? - // break; - // } + // Check for Claude Code authentication error first + if (message.text.includes("Not authenticated with Claude Code")) { + body = t("chat:apiRequest.errorMessage.claudeCodeNotAuthenticated") + docsURL = "roocode://settings?provider=claude-code" + } else { + // Try to show richer error message for that code, if available + const potentialCode = parseInt(message.text.substring(0, 3)) + if (!isNaN(potentialCode) && potentialCode >= 400) { + code = potentialCode + const stringForError = `chat:apiRequest.errorMessage.${code}` + if (i18n.exists(stringForError)) { + body = t(stringForError) + // Fill this out in upcoming PRs + // Do not remove this + // switch(code) { + // case ERROR_CODE: + // docsURL = ??? + // break; + // } + } else { + body = t("chat:apiRequest.errorMessage.unknown") + docsURL = "mailto:support@roocode.com?subject=Unknown API Error" + } + } else if (message.text.indexOf("Connection error") === 0) { + body = t("chat:apiRequest.errorMessage.connection") } else { + // Non-HTTP-status-code error message - store full text as errorDetails body = t("chat:apiRequest.errorMessage.unknown") docsURL = "mailto:support@roocode.com?subject=Unknown API Error" } - } else if (message.text.indexOf("Connection error") === 0) { - body = t("chat:apiRequest.errorMessage.connection") - } else { - // Non-HTTP-status-code error message - store full text as errorDetails - body = t("chat:apiRequest.errorMessage.unknown") - docsURL = "mailto:support@roocode.com?subject=Unknown API Error" } // This isn't pretty, but since the retry logic happens at a lower level diff --git a/webview-ui/src/components/chat/ErrorRow.tsx b/webview-ui/src/components/chat/ErrorRow.tsx index a5647867dca..3c17d0678ac 100644 --- a/webview-ui/src/components/chat/ErrorRow.tsx +++ b/webview-ui/src/components/chat/ErrorRow.tsx @@ -223,10 +223,19 @@ export const ErrorRow = memo( className="text-sm flex items-center gap-1 transition-opacity opacity-0 group-hover:opacity-100" onClick={(e) => { e.preventDefault() - vscode.postMessage({ type: "openExternal", url: docsURL }) + // Handle internal navigation to settings + if (docsURL.startsWith("roocode://settings")) { + vscode.postMessage({ type: "switchTab", tab: "settings" }) + } else { + vscode.postMessage({ type: "openExternal", url: docsURL }) + } }}> - {t("chat:apiRequest.errorMessage.docs")} + {docsURL.startsWith("roocode://settings") + ? t("chat:apiRequest.errorMessage.goToSettings", { + defaultValue: "Settings", + }) + : t("chat:apiRequest.errorMessage.docs")} )} {formattedErrorDetails && ( diff --git a/webview-ui/src/i18n/locales/en/chat.json b/webview-ui/src/i18n/locales/en/chat.json index 0bd258e88b4..4ec1cc52aa0 100644 --- a/webview-ui/src/i18n/locales/en/chat.json +++ b/webview-ui/src/i18n/locales/en/chat.json @@ -147,6 +147,7 @@ "errorTitle": "API Error {{code}}", "errorMessage": { "docs": "Docs", + "goToSettings": "Settings", "400": "The provider couldn't process the request as made. Stop the task and try a different approach.", "401": "Couldn't authenticate with provider. Please check your API key configuration.", "402": "You seem to have run out of funds/credits in your account. Go to your provider and add more to continue.", @@ -154,7 +155,8 @@ "429": "Too many requests. You're being rate-limited by the provider. Please wait a bit before your next API call.", "500": "Provider server error. Something is wrong on the provider side, there's nothing wrong with your request.", "connection": "Connection error. Make sure you have a working internet connection.", - "unknown": "Unknown API error. Please contact Roo Code support." + "unknown": "Unknown API error. Please contact Roo Code support.", + "claudeCodeNotAuthenticated": "You need to sign in to use Claude Code. Go to Settings and click \"Sign in to Claude Code\" to authenticate." } }, "checkpoint": { From 0219766012e7a832d6012e2ee4e183e7386c98ec Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sun, 14 Dec 2025 16:34:20 -0700 Subject: [PATCH 08/16] fix: use Object.hasOwn instead of in operator for model validation - Fixes normalizeClaudeCodeModelId() to avoid using 'in' operator which can match inherited properties like 'toString' - Adds missing translations for Claude Code authentication error messages to all 17 locales --- packages/types/src/providers/claude-code.ts | 5 +++-- webview-ui/src/i18n/locales/ca/chat.json | 4 +++- webview-ui/src/i18n/locales/de/chat.json | 4 +++- webview-ui/src/i18n/locales/es/chat.json | 4 +++- webview-ui/src/i18n/locales/fr/chat.json | 4 +++- webview-ui/src/i18n/locales/hi/chat.json | 4 +++- webview-ui/src/i18n/locales/id/chat.json | 4 +++- webview-ui/src/i18n/locales/it/chat.json | 4 +++- webview-ui/src/i18n/locales/ja/chat.json | 4 +++- webview-ui/src/i18n/locales/ko/chat.json | 4 +++- webview-ui/src/i18n/locales/nl/chat.json | 4 +++- webview-ui/src/i18n/locales/pl/chat.json | 4 +++- webview-ui/src/i18n/locales/pt-BR/chat.json | 4 +++- webview-ui/src/i18n/locales/ru/chat.json | 4 +++- webview-ui/src/i18n/locales/tr/chat.json | 4 +++- webview-ui/src/i18n/locales/vi/chat.json | 4 +++- webview-ui/src/i18n/locales/zh-CN/chat.json | 4 +++- webview-ui/src/i18n/locales/zh-TW/chat.json | 4 +++- 18 files changed, 54 insertions(+), 19 deletions(-) diff --git a/packages/types/src/providers/claude-code.ts b/packages/types/src/providers/claude-code.ts index 39b240706a7..194cd2fa27d 100644 --- a/packages/types/src/providers/claude-code.ts +++ b/packages/types/src/providers/claude-code.ts @@ -136,7 +136,8 @@ const MODEL_FAMILY_PATTERNS: Array<{ pattern: RegExp; target: ClaudeCodeModelId */ export function normalizeClaudeCodeModelId(modelId: string): ClaudeCodeModelId { // If already a valid model ID, return as-is - if (modelId in claudeCodeModels) { + // Use Object.hasOwn() instead of 'in' operator to avoid matching inherited properties like 'toString' + if (Object.hasOwn(claudeCodeModels, modelId)) { return modelId as ClaudeCodeModelId } @@ -144,7 +145,7 @@ export function normalizeClaudeCodeModelId(modelId: string): ClaudeCodeModelId { const withoutDate = modelId.replace(DATE_SUFFIX_PATTERN, "") // Check if stripping the date makes it valid - if (withoutDate in claudeCodeModels) { + if (Object.hasOwn(claudeCodeModels, withoutDate)) { return withoutDate as ClaudeCodeModelId } diff --git a/webview-ui/src/i18n/locales/ca/chat.json b/webview-ui/src/i18n/locales/ca/chat.json index 636a418286a..07032131996 100644 --- a/webview-ui/src/i18n/locales/ca/chat.json +++ b/webview-ui/src/i18n/locales/ca/chat.json @@ -141,6 +141,7 @@ "errorTitle": "Error API {{code}}", "errorMessage": { "docs": "Documentació", + "goToSettings": "Configuració", "400": "El proveïdor no ha pogut processar la sol·licitud tal com es va fer. Interromp la tasca i prova una abordatge diferent.", "401": "No s'ha pogut autenticar amb el proveïdor. Si us plau, verifica la teva configuració de clau API.", "402": "Sembla que se t'han acabat els fons/crèdits al teu compte. Vés al teu proveïdor i afegeix més per continuar.", @@ -148,7 +149,8 @@ "429": "Massa sol·licituds. Estàs sent limitat pel proveïdor. Si us plau espera una mica abans de la teva propera crida API.", "500": "Error del servidor del proveïdor. Quelcom va malament del costat del proveïdor, no hi ha res de malament amb la teva sol·licitud.", "unknown": "Error API desconegut. Si us plau contacta amb el suport de Roo Code.", - "connection": "Error de connexió. Assegureu-vos que teniu una connexió a Internet funcional." + "connection": "Error de connexió. Assegureu-vos que teniu una connexió a Internet funcional.", + "claudeCodeNotAuthenticated": "Has d'iniciar sessió per utilitzar Claude Code. Vés a Configuració i fes clic a \"Iniciar sessió a Claude Code\" per autenticar-te." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/de/chat.json b/webview-ui/src/i18n/locales/de/chat.json index 77102829833..6a8e01d5ed4 100644 --- a/webview-ui/src/i18n/locales/de/chat.json +++ b/webview-ui/src/i18n/locales/de/chat.json @@ -141,6 +141,7 @@ "errorTitle": "API-Fehler {{code}}", "errorMessage": { "docs": "Dokumentation", + "goToSettings": "Einstellungen", "400": "Der Anbieter konnte die Anfrage nicht wie gestellt verarbeiten. Beende die Aufgabe und versuche einen anderen Ansatz.", "401": "Authentifizierung beim Anbieter fehlgeschlagen. Bitte überprüfe deine API-Schlüssel-Konfiguration.", "402": "Es sieht so aus, als ob dir die Guthaben/Credits in deinem Konto ausgegangen sind. Gehe zu deinem Anbieter und füge mehr hinzu, um fortzufahren.", @@ -148,7 +149,8 @@ "429": "Zu viele Anfragen. Du wirst vom Anbieter rate-limitiert. Bitte warte ein wenig, bevor du den nächsten API-Aufruf machst.", "500": "Fehler auf dem Server des Anbieters. Es stimmt etwas mit der Anbieterseite nicht, mit deiner Anfrage stimmt alles.", "unknown": "Unbekannter API-Fehler. Bitte kontaktiere den Roo Code Support.", - "connection": "Verbindungsfehler. Stelle sicher, dass du eine funktionierende Internetverbindung hast." + "connection": "Verbindungsfehler. Stelle sicher, dass du eine funktionierende Internetverbindung hast.", + "claudeCodeNotAuthenticated": "Du musst dich anmelden, um Claude Code zu verwenden. Gehe zu den Einstellungen und klicke auf \"Bei Claude Code anmelden\", um dich zu authentifizieren." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/es/chat.json b/webview-ui/src/i18n/locales/es/chat.json index 84d549fadab..f807e27f1dc 100644 --- a/webview-ui/src/i18n/locales/es/chat.json +++ b/webview-ui/src/i18n/locales/es/chat.json @@ -141,6 +141,7 @@ "errorTitle": "Error API {{code}}", "errorMessage": { "docs": "Documentación", + "goToSettings": "Configuración", "400": "El proveedor no pudo procesar la solicitud tal como se hizo. Detén la tarea e intenta un enfoque diferente.", "401": "No se pudo autenticar con el proveedor. Por favor verifica tu configuración de clave API.", "402": "Parece que se te han acabado los fondos/créditos en tu cuenta. Ve a tu proveedor y agrega más para continuar.", @@ -148,7 +149,8 @@ "429": "Demasiadas solicitudes. Te estás viendo limitado por el proveedor. Por favor espera un poco antes de tu próxima llamada API.", "500": "Error del servidor del proveedor. Algo está mal en el lado del proveedor, no hay nada mal con tu solicitud.", "unknown": "Error API desconocido. Por favor contacta al soporte de Roo Code.", - "connection": "Error de conexión. Asegúrate de tener una conexión a Internet funcional." + "connection": "Error de conexión. Asegúrate de tener una conexión a Internet funcional.", + "claudeCodeNotAuthenticated": "Debes iniciar sesión para usar Claude Code. Ve a Configuración y haz clic en \"Iniciar sesión en Claude Code\" para autenticarte." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/fr/chat.json b/webview-ui/src/i18n/locales/fr/chat.json index 5d0b1cd3e4d..6c2926f1d76 100644 --- a/webview-ui/src/i18n/locales/fr/chat.json +++ b/webview-ui/src/i18n/locales/fr/chat.json @@ -141,6 +141,7 @@ "errorTitle": "Erreur API {{code}}", "errorMessage": { "docs": "Documentation", + "goToSettings": "Paramètres", "400": "Le fournisseur n'a pas pu traiter la demande telle que présentée. Arrête la tâche et essaie une approche différente.", "401": "Impossible de s'authentifier auprès du fournisseur. Veuillez vérifier la configuration de votre clé API.", "402": "Il semble que vous ayez épuisé vos fonds/crédits sur votre compte. Allez chez votre fournisseur et ajoutez-en plus pour continuer.", @@ -148,7 +149,8 @@ "429": "Trop de demandes. Vous êtes limité par le fournisseur. Veuillez attendre un peu avant votre prochain appel API.", "500": "Erreur du serveur du fournisseur. Quelque chose ne va pas du côté du fournisseur, il n'y a rien de mal avec votre demande.", "unknown": "Erreur API inconnue. Veuillez contacter le support Roo Code.", - "connection": "Erreur de connexion. Assurez-vous que vous avez une connexion Internet fonctionnelle." + "connection": "Erreur de connexion. Assurez-vous que vous avez une connexion Internet fonctionnelle.", + "claudeCodeNotAuthenticated": "Vous devez vous connecter pour utiliser Claude Code. Allez dans les Paramètres et cliquez sur \"Se connecter à Claude Code\" pour vous authentifier." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/hi/chat.json b/webview-ui/src/i18n/locales/hi/chat.json index 02ba2a9ec50..05540bffe70 100644 --- a/webview-ui/src/i18n/locales/hi/chat.json +++ b/webview-ui/src/i18n/locales/hi/chat.json @@ -141,6 +141,7 @@ "errorTitle": "API त्रुटि {{code}}", "errorMessage": { "docs": "डॉक्स", + "goToSettings": "सेटिंग्स", "400": "प्रदाता अनुरोध को जैसे बनाया गया था उसे प्रोसेस नहीं कर सका। कार्य को रोकें और एक अलग तरीका आजमाएं।", "401": "प्रदाता के साथ प्रमाणित नहीं किए जा सके। अपने API कुंजी कॉन्फ़िगरेशन की जाँच करें।", "402": "ऐसा लगता है कि आपके खाते में फंड/क्रेडिट समाप्त हो गए हैं। अपने प्रदाता के पास जाएं और जारी रखने के लिए और अधिक जोड़ें।", @@ -148,7 +149,8 @@ "429": "बहुत सारे अनुरोध। आप प्रदाता द्वारा दर-सीमित हो रहे हैं। कृपया अपनी अगली API कॉल से पहले थोड़ी देर प्रतीक्षा करें।", "500": "प्रदाता सर्वर त्रुटि। प्रदाता की ओर से कुछ गलत है, आपके अनुरोध में कुछ गलत नहीं है।", "unknown": "अज्ञात API त्रुटि। कृपया Roo Code सहायता से संपर्क करें।", - "connection": "कनेक्शन त्रुटि। सुनिश्चित करें कि आपके पास कार्यशील इंटरनेट कनेक्शन है।" + "connection": "कनेक्शन त्रुटि। सुनिश्चित करें कि आपके पास कार्यशील इंटरनेट कनेक्शन है।", + "claudeCodeNotAuthenticated": "Claude Code का उपयोग करने के लिए आपको साइन इन करना होगा। सेटिंग्स में जाएं और प्रमाणित करने के लिए \"Claude Code में साइन इन करें\" पर क्लिक करें।" } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/id/chat.json b/webview-ui/src/i18n/locales/id/chat.json index 502f2299394..042dfae0fb7 100644 --- a/webview-ui/src/i18n/locales/id/chat.json +++ b/webview-ui/src/i18n/locales/id/chat.json @@ -150,6 +150,7 @@ "errorTitle": "Kesalahan API {{code}}", "errorMessage": { "docs": "Dokumentasi", + "goToSettings": "Pengaturan", "400": "Penyedia tidak dapat memproses permintaan seperti yang dibuat. Hentikan tugas dan coba pendekatan berbeda.", "401": "Tidak dapat mengautentikasi dengan penyedia. Harap periksa konfigurasi kunci API Anda.", "402": "Tampaknya Anda telah kehabisan dana/kredit di akun Anda. Pergi ke penyedia Anda dan tambahkan lebih banyak untuk melanjutkan.", @@ -157,7 +158,8 @@ "429": "Terlalu banyak permintaan. Anda dibatasi tingkat oleh penyedia. Harap tunggu sebentar sebelum panggilan API berikutnya Anda.", "500": "Kesalahan server penyedia. Ada yang salah di sisi penyedia, tidak ada yang salah dengan permintaan Anda.", "unknown": "Kesalahan API yang tidak diketahui. Harap hubungi dukungan Roo Code.", - "connection": "Kesalahan koneksi. Pastikan Anda memiliki koneksi internet yang berfungsi." + "connection": "Kesalahan koneksi. Pastikan Anda memiliki koneksi internet yang berfungsi.", + "claudeCodeNotAuthenticated": "Anda perlu masuk untuk menggunakan Claude Code. Buka Pengaturan dan klik \"Masuk ke Claude Code\" untuk mengautentikasi." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/it/chat.json b/webview-ui/src/i18n/locales/it/chat.json index 4058d9bd85d..f09040f14e4 100644 --- a/webview-ui/src/i18n/locales/it/chat.json +++ b/webview-ui/src/i18n/locales/it/chat.json @@ -144,6 +144,7 @@ "errorTitle": "Errore API {{code}}", "errorMessage": { "docs": "Documentazione", + "goToSettings": "Impostazioni", "400": "Il provider non ha potuto elaborare la richiesta come presentata. Interrompi l'attività e prova un approccio diverso.", "401": "Impossibile autenticare con il provider. Verifica la configurazione della tua chiave API.", "402": "Sembra che tu abbia esaurito i fondi/crediti nel tuo account. Vai al tuo provider e aggiungi altro per continuare.", @@ -151,7 +152,8 @@ "429": "Troppe richieste. Sei limitato dal provider. Attendi un po' prima della tua prossima chiamata API.", "500": "Errore del server del provider. C'è qualcosa di sbagliato dal lato del provider, non c'è nulla di sbagliato nella tua richiesta.", "unknown": "Errore API sconosciuto. Contatta il supporto di Roo Code.", - "connection": "Errore di connessione. Assicurati di avere una connessione Internet funzionante." + "connection": "Errore di connessione. Assicurati di avere una connessione Internet funzionante.", + "claudeCodeNotAuthenticated": "Devi accedere per utilizzare Claude Code. Vai su Impostazioni e clicca su \"Accedi a Claude Code\" per autenticarti." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/ja/chat.json b/webview-ui/src/i18n/locales/ja/chat.json index 26f4c656af3..8e4a488daf7 100644 --- a/webview-ui/src/i18n/locales/ja/chat.json +++ b/webview-ui/src/i18n/locales/ja/chat.json @@ -141,6 +141,7 @@ "errorTitle": "APIエラー {{code}}", "errorMessage": { "docs": "ドキュメント", + "goToSettings": "設定", "400": "プロバイダーはリクエストをそのまま処理できませんでした。タスクを停止して別のアプローチを試してください。", "401": "プロバイダーで認証できませんでした。API キーの設定を確認してください。", "402": "アカウントの資金/クレジットが不足しているようです。プロバイダーに移動してさらに追加してください。", @@ -148,7 +149,8 @@ "429": "リクエストが多すぎます。プロバイダーによってレート制限されています。次の API 呼び出しの前にお待ちください。", "500": "プロバイダー サーバー エラー。プロバイダー側に問題があり、リクエスト自体に問題はありません。", "unknown": "不明な API エラー。Roo Code のサポートにお問い合わせください。", - "connection": "接続エラー。インターネット接続が機能していることを確認してください。" + "connection": "接続エラー。インターネット接続が機能していることを確認してください。", + "claudeCodeNotAuthenticated": "Claude Codeを使用するにはサインインが必要です。設定に移動して「Claude Codeにサインイン」をクリックして認証してください。" } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/ko/chat.json b/webview-ui/src/i18n/locales/ko/chat.json index 9a0d0802c47..10d88091ec8 100644 --- a/webview-ui/src/i18n/locales/ko/chat.json +++ b/webview-ui/src/i18n/locales/ko/chat.json @@ -141,6 +141,7 @@ "errorTitle": "API 오류 {{code}}", "errorMessage": { "docs": "문서", + "goToSettings": "설정", "400": "공급자가 요청을 처리할 수 없습니다. 작업을 중지하고 다른 방법을 시도하세요.", "401": "공급자로 인증할 수 없습니다. API 키 구성을 확인하세요.", "402": "계정의 자금/크레딧이 부족한 것 같습니다. 공급자에게 가서 더 추가하세요.", @@ -148,7 +149,8 @@ "429": "너무 많은 요청입니다. 공급자에 의해 요청 제한이 적용되고 있습니다. 다음 API 호출 전에 잠깐 기다려주세요.", "500": "공급자 서버 오류입니다. 공급자 쪽에 문제가 있으며 요청에는 문제가 없습니다.", "unknown": "알 수 없는 API 오류입니다. Roo Code 지원팀에 문의하세요.", - "connection": "연결 오류입니다. 인터넷 연결이 제대로 작동하는지 확인하세요." + "connection": "연결 오류입니다. 인터넷 연결이 제대로 작동하는지 확인하세요.", + "claudeCodeNotAuthenticated": "Claude Code를 사용하려면 로그인해야 합니다. 설정으로 이동하여 \"Claude Code에 로그인\"을 클릭하여 인증하세요." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/nl/chat.json b/webview-ui/src/i18n/locales/nl/chat.json index 8c22bc5bb55..cd2fa897d51 100644 --- a/webview-ui/src/i18n/locales/nl/chat.json +++ b/webview-ui/src/i18n/locales/nl/chat.json @@ -136,6 +136,7 @@ "errorTitle": "API-fout {{code}}", "errorMessage": { "docs": "Documentatie", + "goToSettings": "Instellingen", "400": "De provider kon het verzoek niet verwerken zoals ingediend. Stop de taak en probeer een ander benadering.", "401": "Kon niet authenticeren met provider. Controleer je API-sleutelconfiguratie.", "402": "Het lijkt erop dat je funds/credits op je account op zijn. Ga naar je provider en voeg meer toe om door te gaan.", @@ -143,7 +144,8 @@ "429": "Te veel verzoeken. Je bent rate-gelimiteerd door de provider. Wacht alsjeblieft even voor je volgende API-aanroep.", "500": "Provider-serverfout. Er is iets mis aan de kant van de provider, er is niets mis met je verzoek.", "unknown": "Onbekende API-fout. Neem alsjeblieft contact op met Roo Code-ondersteuning.", - "connection": "Verbindingsfout. Zorg ervoor dat je een werkende internetverbinding hebt." + "connection": "Verbindingsfout. Zorg ervoor dat je een werkende internetverbinding hebt.", + "claudeCodeNotAuthenticated": "Je moet inloggen om Claude Code te gebruiken. Ga naar Instellingen en klik op \"Inloggen bij Claude Code\" om te authenticeren." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/pl/chat.json b/webview-ui/src/i18n/locales/pl/chat.json index b317204d446..67a30448b89 100644 --- a/webview-ui/src/i18n/locales/pl/chat.json +++ b/webview-ui/src/i18n/locales/pl/chat.json @@ -141,6 +141,7 @@ "errorTitle": "Błąd API {{code}}", "errorMessage": { "docs": "Dokumentacja", + "goToSettings": "Ustawienia", "400": "Dostawca nie mógł przetworzyć żądania. Zatrzymaj zadanie i spróbuj innego podejścia.", "401": "Nie można uwierzytelnić u dostawcy. Sprawdź konfigurację klucza API.", "402": "Wygląda na to, że wyczerpałeś środki/kredyty na swoim koncie. Przejdź do dostawcy i dodaj więcej, aby kontynuować.", @@ -148,7 +149,8 @@ "429": "Zbyt wiele żądań. Dostawca ogranicza Ci szybkość żądań. Poczekaj chwilę przed następnym wywołaniem API.", "500": "Błąd serwera dostawcy. Po stronie dostawcy coś się nie powiodło, w Twoim żądaniu nie ma nic złego.", "unknown": "Nieznany błąd API. Skontaktuj się z pomocą techniczną Roo Code.", - "connection": "Błąd połączenia. Upewnij się, że masz działające połączenie internetowe." + "connection": "Błąd połączenia. Upewnij się, że masz działające połączenie internetowe.", + "claudeCodeNotAuthenticated": "Musisz się zalogować, aby korzystać z Claude Code. Przejdź do Ustawień i kliknij \"Zaloguj się do Claude Code\", aby się uwierzytelnić." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/pt-BR/chat.json b/webview-ui/src/i18n/locales/pt-BR/chat.json index 9d1049d497e..74507d2e29a 100644 --- a/webview-ui/src/i18n/locales/pt-BR/chat.json +++ b/webview-ui/src/i18n/locales/pt-BR/chat.json @@ -141,6 +141,7 @@ "errorTitle": "Erro API {{code}}", "errorMessage": { "docs": "Documentação", + "goToSettings": "Configurações", "400": "O provedor não conseguiu processar a solicitação conforme feita. Interrompa a tarefa e tente uma abordagem diferente.", "401": "Não foi possível autenticar com o provedor. Por favor, verifique a configuração da sua chave API.", "402": "Parece que você ficou sem fundos/créditos em sua conta. Vá ao seu provedor e adicione mais para continuar.", @@ -148,7 +149,8 @@ "429": "Muitas solicitações. Você está sendo limitado pelo provedor. Por favor, aguarde um pouco antes de sua próxima chamada de API.", "500": "Erro do servidor do provedor. Algo está errado do lado do provedor, não há nada de errado com sua solicitação.", "unknown": "Erro de API desconhecido. Por favor, entre em contato com o suporte do Roo Code.", - "connection": "Erro de conexão. Certifique-se de que você tem uma conexão de internet funcionando." + "connection": "Erro de conexão. Certifique-se de que você tem uma conexão de internet funcionando.", + "claudeCodeNotAuthenticated": "Você precisa fazer login para usar o Claude Code. Vá para Configurações e clique em \"Entrar no Claude Code\" para autenticar." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/ru/chat.json b/webview-ui/src/i18n/locales/ru/chat.json index e2cc7e76f9c..91a713705c0 100644 --- a/webview-ui/src/i18n/locales/ru/chat.json +++ b/webview-ui/src/i18n/locales/ru/chat.json @@ -136,6 +136,7 @@ "errorTitle": "Ошибка API {{code}}", "errorMessage": { "docs": "Документация", + "goToSettings": "Настройки", "400": "Провайдер не смог обработать запрос. Остановите задачу и попробуйте другой подход.", "401": "Не удалось аутентифицироваться у провайдера. Проверьте конфигурацию ключа API.", "402": "Похоже, у вас закончились средства/кредиты на вашем счете. Перейдите к провайдеру и пополните счет, чтобы продолжить.", @@ -143,7 +144,8 @@ "429": "Слишком много запросов. Провайдер ограничивает частоту ваших запросов. Пожалуйста, подождите немного перед следующим вызовом API.", "500": "Ошибка сервера провайдера. На стороне провайдера что-то пошло не так, с вашим запросом все в порядке.", "unknown": "Неизвестная ошибка API. Пожалуйста, свяжитесь с поддержкой Roo Code.", - "connection": "Ошибка подключения. Убедитесь, что у вас есть рабочее подключение к Интернету." + "connection": "Ошибка подключения. Убедитесь, что у вас есть рабочее подключение к Интернету.", + "claudeCodeNotAuthenticated": "Вам необходимо войти в систему, чтобы использовать Claude Code. Перейдите в Настройки и нажмите «Войти в Claude Code» для аутентификации." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/tr/chat.json b/webview-ui/src/i18n/locales/tr/chat.json index e709bbfb065..886e7ed6a65 100644 --- a/webview-ui/src/i18n/locales/tr/chat.json +++ b/webview-ui/src/i18n/locales/tr/chat.json @@ -141,6 +141,7 @@ "errorTitle": "API Hatası {{code}}", "errorMessage": { "docs": "Belgeler", + "goToSettings": "Ayarlar", "400": "Sağlayıcı isteği bu şekilde işleyemedi. Görevi durdur ve farklı bir yaklaşım dene.", "401": "Sağlayıcı ile kimlik doğrulaması yapılamadı. Lütfen API anahtarı yapılandırmanızı kontrol edin.", "402": "Hesabınızda para/kredi bitti gibi görünüyor. Sağlayıcıya git ve devam etmek için daha fazla ekle.", @@ -148,7 +149,8 @@ "429": "Çok fazla istek. Sağlayıcı tarafından oran sınırlaması uygulanıyor. Lütfen sonraki API çağrısından önce biraz bekle.", "500": "Sağlayıcı sunucu hatası. Sağlayıcı tarafında bir sorun var, isteğinizde sorun yok.", "unknown": "Bilinmeyen API hatası. Lütfen Roo Code desteğiyle iletişime geç.", - "connection": "Bağlantı hatası. Çalışan bir internet bağlantınız olduğundan emin olun." + "connection": "Bağlantı hatası. Çalışan bir internet bağlantınız olduğundan emin olun.", + "claudeCodeNotAuthenticated": "Claude Code'u kullanmak için oturum açmanız gerekiyor. Ayarlar'a gidin ve kimlik doğrulaması yapmak için \"Claude Code'da Oturum Aç\" seçeneğine tıklayın." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/vi/chat.json b/webview-ui/src/i18n/locales/vi/chat.json index 9c4f9b5a43f..633935d56dc 100644 --- a/webview-ui/src/i18n/locales/vi/chat.json +++ b/webview-ui/src/i18n/locales/vi/chat.json @@ -141,6 +141,7 @@ "errorTitle": "Lỗi API {{code}}", "errorMessage": { "docs": "Tài liệu", + "goToSettings": "Cài đặt", "400": "Nhà cung cấp không thể xử lý yêu cầu theo cách này. Hãy dừng nhiệm vụ và thử một cách tiếp cận khác.", "401": "Không thể xác thực với nhà cung cấp. Vui lòng kiểm tra cấu hình khóa API của bạn.", "402": "Có vẻ như bạn đã hết tiền/tín dụng trong tài khoản. Hãy truy cập nhà cung cấp và thêm tiền để tiếp tục.", @@ -148,7 +149,8 @@ "429": "Quá nhiều yêu cầu. Nhà cung cấp đang giới hạn tốc độ yêu cầu của bạn. Vui lòng chờ một chút trước khi gọi API tiếp theo.", "500": "Lỗi máy chủ của nhà cung cấp. Có sự cố ở phía nhà cung cấp, không có gì sai với yêu cầu của bạn.", "unknown": "Lỗi API không xác định. Vui lòng liên hệ hỗ trợ Roo Code.", - "connection": "Lỗi kết nối. Đảm bảo rằng bạn có kết nối Internet hoạt động." + "connection": "Lỗi kết nối. Đảm bảo rằng bạn có kết nối Internet hoạt động.", + "claudeCodeNotAuthenticated": "Bạn cần đăng nhập để sử dụng Claude Code. Vào Cài đặt và nhấp vào \"Đăng nhập vào Claude Code\" để xác thực." } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/zh-CN/chat.json b/webview-ui/src/i18n/locales/zh-CN/chat.json index 807a278e3c6..333d08735aa 100644 --- a/webview-ui/src/i18n/locales/zh-CN/chat.json +++ b/webview-ui/src/i18n/locales/zh-CN/chat.json @@ -141,6 +141,7 @@ "errorTitle": "API 错误 {{code}}", "errorMessage": { "docs": "文档", + "goToSettings": "设置", "400": "提供商无法按此方式处理请求。请停止任务并尝试不同方法。", "401": "无法向提供商进行身份验证。请检查您的 API 密钥配置。", "402": "您的账户余额/积分似乎已用尽。请前往提供商处充值以继续。", @@ -148,7 +149,8 @@ "429": "请求过于频繁。提供商已对您的请求进行速率限制。请在下一次 API 调用前稍候。", "500": "提供商服务器错误。提供商端出现问题,您的请求无问题。", "unknown": "未知 API 错误。请联系 Roo Code 支持。", - "connection": "连接错误。确保您有可用的互联网连接。" + "connection": "连接错误。确保您有可用的互联网连接。", + "claudeCodeNotAuthenticated": "你需要登录才能使用 Claude Code。前往设置并点击「登录到 Claude Code」进行身份验证。" } }, "checkpoint": { diff --git a/webview-ui/src/i18n/locales/zh-TW/chat.json b/webview-ui/src/i18n/locales/zh-TW/chat.json index 24196697354..f352bf3fdee 100644 --- a/webview-ui/src/i18n/locales/zh-TW/chat.json +++ b/webview-ui/src/i18n/locales/zh-TW/chat.json @@ -147,6 +147,7 @@ "errorTitle": "API 錯誤 {{code}}", "errorMessage": { "docs": "文件", + "goToSettings": "設定", "400": "提供商無法按照此方式處理請求。請停止工作並嘗試其他方法。", "401": "無法向提供商進行身份驗證。請檢查您的 API 金鑰設定。", "402": "您的帳戶資金/額度似乎已用盡。請前往提供商增加額度以繼續。", @@ -154,7 +155,8 @@ "429": "請求次數過多。提供商已對您的請求進行速率限制。請在下一次 API 呼叫前稍候。", "500": "提供商伺服器錯誤。提供商端發生問題,您的請求沒有問題。", "unknown": "未知 API 錯誤。請聯絡 Roo Code 支援。", - "connection": "連線錯誤。請確保您有可用的網際網路連線。" + "connection": "連線錯誤。請確保您有可用的網際網路連線。", + "claudeCodeNotAuthenticated": "你需要登入才能使用 Claude Code。前往設定並點擊「登入 Claude Code」以進行驗證。" } }, "checkpoint": { From f6be2db1eec30569fee93efb51c5676b04c7f85c Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Sun, 14 Dec 2025 16:55:46 -0700 Subject: [PATCH 09/16] fix: pass section=providers when navigating to settings from Claude Code auth error --- webview-ui/src/components/chat/ErrorRow.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/webview-ui/src/components/chat/ErrorRow.tsx b/webview-ui/src/components/chat/ErrorRow.tsx index 3c17d0678ac..8cb2e2d4f48 100644 --- a/webview-ui/src/components/chat/ErrorRow.tsx +++ b/webview-ui/src/components/chat/ErrorRow.tsx @@ -225,7 +225,11 @@ export const ErrorRow = memo( e.preventDefault() // Handle internal navigation to settings if (docsURL.startsWith("roocode://settings")) { - vscode.postMessage({ type: "switchTab", tab: "settings" }) + vscode.postMessage({ + type: "switchTab", + tab: "settings", + values: { section: "providers" }, + }) } else { vscode.postMessage({ type: "openExternal", url: docsURL }) } From 33210aa076e672900eec86be3a99cabfd8310436 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 16 Dec 2025 20:28:01 -0700 Subject: [PATCH 10/16] Remove Claude Code CLI wrapper and legacy fallbacks --- packages/types/src/provider-settings.ts | 5 +- src/core/config/ContextProxy.ts | 17 +- src/core/config/ProviderSettingsManager.ts | 23 + src/i18n/locales/ca/common.json | 10 +- src/i18n/locales/de/common.json | 10 +- src/i18n/locales/en/common.json | 3 +- src/i18n/locales/es/common.json | 10 +- src/i18n/locales/fr/common.json | 10 +- src/i18n/locales/hi/common.json | 9 +- src/i18n/locales/id/common.json | 10 +- src/i18n/locales/it/common.json | 10 +- src/i18n/locales/ja/common.json | 10 +- src/i18n/locales/ko/common.json | 10 +- src/i18n/locales/nl/common.json | 10 +- src/i18n/locales/pl/common.json | 10 +- src/i18n/locales/pt-BR/common.json | 10 +- src/i18n/locales/ru/common.json | 10 +- src/i18n/locales/tr/common.json | 10 +- src/i18n/locales/vi/common.json | 10 +- src/i18n/locales/zh-CN/common.json | 10 +- src/i18n/locales/zh-TW/common.json | 10 +- .../claude-code/__tests__/run.spec.ts | 521 ------------------ .../__tests__/streaming-client.spec.ts | 3 +- src/integrations/claude-code/run.ts | 275 --------- .../claude-code/streaming-client.ts | 42 +- src/shared/__tests__/api.spec.ts | 37 +- src/shared/api.ts | 6 - 27 files changed, 81 insertions(+), 1020 deletions(-) delete mode 100644 src/integrations/claude-code/__tests__/run.spec.ts delete mode 100644 src/integrations/claude-code/run.ts diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 873692b809d..024293ddafc 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -200,10 +200,7 @@ const anthropicSchema = apiModelIdProviderModelSchema.extend({ anthropicBeta1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. }) -const claudeCodeSchema = apiModelIdProviderModelSchema.extend({ - claudeCodePath: z.string().optional(), - claudeCodeMaxOutputTokens: z.number().int().min(1).max(200000).optional(), -}) +const claudeCodeSchema = apiModelIdProviderModelSchema.extend({}) const openRouterSchema = baseProviderSettingsSchema.extend({ openRouterApiKey: z.string().optional(), diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index d570ccc7c67..64baf546bd5 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -316,13 +316,26 @@ export class ContextProxy { * This prevents schema validation errors for removed providers. */ private sanitizeProviderValues(values: RooCodeSettings): RooCodeSettings { + // Remove legacy Claude Code CLI wrapper keys that may still exist in global state. + // These keys were used by a removed local CLI runner and are no longer part of ProviderSettings. + const legacyKeys = ["claudeCodePath", "claudeCodeMaxOutputTokens"] as const + + let sanitizedValues = values + for (const key of legacyKeys) { + if (key in sanitizedValues) { + const copy = { ...sanitizedValues } as Record + delete copy[key as string] + sanitizedValues = copy as RooCodeSettings + } + } + if (values.apiProvider !== undefined && !isProviderName(values.apiProvider)) { logger.info(`[ContextProxy] Sanitizing invalid provider "${values.apiProvider}" - resetting to undefined`) // Return a new values object without the invalid apiProvider - const { apiProvider, ...restValues } = values + const { apiProvider, ...restValues } = sanitizedValues return restValues as RooCodeSettings } - return values + return sanitizedValues } public async setProviderSettings(values: ProviderSettings) { diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 43017882fae..420ab332b24 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -47,6 +47,7 @@ export const providerProfilesSchema = z.object({ openAiHeadersMigrated: z.boolean().optional(), consecutiveMistakeLimitMigrated: z.boolean().optional(), todoListEnabledMigrated: z.boolean().optional(), + claudeCodeLegacySettingsMigrated: z.boolean().optional(), }) .optional(), }) @@ -71,6 +72,7 @@ export class ProviderSettingsManager { openAiHeadersMigrated: true, // Mark as migrated on fresh installs consecutiveMistakeLimitMigrated: true, // Mark as migrated on fresh installs todoListEnabledMigrated: true, // Mark as migrated on fresh installs + claudeCodeLegacySettingsMigrated: true, // Mark as migrated on fresh installs }, } @@ -143,6 +145,7 @@ export class ProviderSettingsManager { openAiHeadersMigrated: false, consecutiveMistakeLimitMigrated: false, todoListEnabledMigrated: false, + claudeCodeLegacySettingsMigrated: false, } // Initialize with default values isDirty = true } @@ -177,6 +180,26 @@ export class ProviderSettingsManager { isDirty = true } + if (!providerProfiles.migrations.claudeCodeLegacySettingsMigrated) { + // These keys were used by the removed local Claude Code CLI wrapper. + for (const apiConfig of Object.values(providerProfiles.apiConfigs)) { + if (apiConfig.apiProvider !== "claude-code") continue + + const config = apiConfig as unknown as Record + if ("claudeCodePath" in config) { + delete config.claudeCodePath + isDirty = true + } + if ("claudeCodeMaxOutputTokens" in config) { + delete config.claudeCodeMaxOutputTokens + isDirty = true + } + } + + providerProfiles.migrations.claudeCodeLegacySettingsMigrated = true + isDirty = true + } + if (isDirty) { await this.store(providerProfiles) } diff --git a/src/i18n/locales/ca/common.json b/src/i18n/locales/ca/common.json index 2395cfd83ae..576594a85ca 100644 --- a/src/i18n/locales/ca/common.json +++ b/src/i18n/locales/ca/common.json @@ -94,8 +94,7 @@ "errorOutput": "Sortida d'error: {{output}}", "processExitedWithError": "El procés Claude Code ha sortit amb codi {{exitCode}}. Sortida d'error: {{output}}", "stoppedWithReason": "Claude Code s'ha aturat per la raó: {{reason}}", - "apiKeyModelPlanMismatch": "Les claus API i els plans de subscripció permeten models diferents. Assegura't que el model seleccionat estigui inclòs al teu pla.", - "notFound": "No s'ha trobat l'executable Claude Code '{{claudePath}}'.\n\nInstal·la Claude Code CLI:\n1. Visita {{installationUrl}} per descarregar Claude Code\n2. Segueix les instruccions d'instal·lació per al teu sistema operatiu\n3. Assegura't que la comanda 'claude' estigui disponible al teu PATH\n4. Alternativament, configura una ruta personalitzada a la configuració de Roo sota 'Ruta de Claude Code'\n\nError original: {{originalError}}" + "apiKeyModelPlanMismatch": "Les claus API i els plans de subscripció permeten models diferents. Assegura't que el model seleccionat estigui inclòs al teu pla." }, "message": { "no_active_task_to_delete": "No hi ha cap tasca activa de la qual eliminar missatges", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Clau API de Groq", - "getGroqApiKey": "Obté la clau API de Groq", - "claudeCode": { - "pathLabel": "Ruta de Claude Code", - "description": "Ruta opcional a la teva CLI de Claude Code. Per defecte 'claude' si no s'estableix.", - "placeholder": "Per defecte: claude" - } + "getGroqApiKey": "Obté la clau API de Groq" } }, "customModes": { diff --git a/src/i18n/locales/de/common.json b/src/i18n/locales/de/common.json index bce64888b45..e3ff5b04c9f 100644 --- a/src/i18n/locales/de/common.json +++ b/src/i18n/locales/de/common.json @@ -91,8 +91,7 @@ "errorOutput": "Fehlerausgabe: {{output}}", "processExitedWithError": "Claude Code Prozess wurde mit Code {{exitCode}} beendet. Fehlerausgabe: {{output}}", "stoppedWithReason": "Claude Code wurde mit Grund gestoppt: {{reason}}", - "apiKeyModelPlanMismatch": "API-Schlüssel und Abonnement-Pläne erlauben verschiedene Modelle. Stelle sicher, dass das ausgewählte Modell in deinem Plan enthalten ist.", - "notFound": "Claude Code ausführbare Datei '{{claudePath}}' nicht gefunden.\n\nBitte installiere Claude Code CLI:\n1. Besuche {{installationUrl}} um Claude Code herunterzuladen\n2. Folge den Installationsanweisungen für dein Betriebssystem\n3. Stelle sicher, dass der 'claude' Befehl in deinem PATH verfügbar ist\n4. Alternativ konfiguriere einen benutzerdefinierten Pfad in den Roo-Einstellungen unter 'Claude Code Pfad'\n\nUrsprünglicher Fehler: {{originalError}}" + "apiKeyModelPlanMismatch": "API-Schlüssel und Abonnement-Pläne erlauben verschiedene Modelle. Stelle sicher, dass das ausgewählte Modell in deinem Plan enthalten ist." }, "message": { "no_active_task_to_delete": "Keine aktive Aufgabe, aus der Nachrichten gelöscht werden können", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Groq API-Schlüssel", - "getGroqApiKey": "Groq API-Schlüssel erhalten", - "claudeCode": { - "pathLabel": "Claude Code Pfad", - "description": "Optionaler Pfad zu deiner Claude Code CLI. Standardmäßig 'claude', falls nicht festgelegt.", - "placeholder": "Standard: claude" - } + "getGroqApiKey": "Groq API-Schlüssel erhalten" } }, "customModes": { diff --git a/src/i18n/locales/en/common.json b/src/i18n/locales/en/common.json index ae2e20a292b..2d783275119 100644 --- a/src/i18n/locales/en/common.json +++ b/src/i18n/locales/en/common.json @@ -91,8 +91,7 @@ "errorOutput": "Error output: {{output}}", "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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "No active task to delete messages from", diff --git a/src/i18n/locales/es/common.json b/src/i18n/locales/es/common.json index 308505e9b03..e4c1059a6c4 100644 --- a/src/i18n/locales/es/common.json +++ b/src/i18n/locales/es/common.json @@ -91,8 +91,7 @@ "errorOutput": "Salida de error: {{output}}", "processExitedWithError": "El proceso de Claude Code terminó con código {{exitCode}}. Salida de error: {{output}}", "stoppedWithReason": "Claude Code se detuvo por la razón: {{reason}}", - "apiKeyModelPlanMismatch": "Las claves API y los planes de suscripción permiten diferentes modelos. Asegúrate de que el modelo seleccionado esté incluido en tu plan.", - "notFound": "Ejecutable de Claude Code '{{claudePath}}' no encontrado.\n\nPor favor instala Claude Code CLI:\n1. Visita {{installationUrl}} para descargar Claude Code\n2. Sigue las instrucciones de instalación para tu sistema operativo\n3. Asegúrate de que el comando 'claude' esté disponible en tu PATH\n4. Alternativamente, configura una ruta personalizada en la configuración de Roo bajo 'Ruta de Claude Code'\n\nError original: {{originalError}}" + "apiKeyModelPlanMismatch": "Las claves API y los planes de suscripción permiten diferentes modelos. Asegúrate de que el modelo seleccionado esté incluido en tu plan." }, "message": { "no_active_task_to_delete": "No hay tarea activa de la cual eliminar mensajes", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Clave API de Groq", - "getGroqApiKey": "Obtener clave API de Groq", - "claudeCode": { - "pathLabel": "Ruta de Claude Code", - "description": "Ruta opcional a tu CLI de Claude Code. Por defecto 'claude' si no se establece.", - "placeholder": "Por defecto: claude" - } + "getGroqApiKey": "Obtener clave API de Groq" } }, "customModes": { diff --git a/src/i18n/locales/fr/common.json b/src/i18n/locales/fr/common.json index d1d71a9a2cf..fe5f62fbb42 100644 --- a/src/i18n/locales/fr/common.json +++ b/src/i18n/locales/fr/common.json @@ -91,8 +91,7 @@ "errorOutput": "Sortie d'erreur : {{output}}", "processExitedWithError": "Le processus Claude Code s'est terminé avec le code {{exitCode}}. Sortie d'erreur : {{output}}", "stoppedWithReason": "Claude Code s'est arrêté pour la raison : {{reason}}", - "apiKeyModelPlanMismatch": "Les clés API et les plans d'abonnement permettent différents modèles. Assurez-vous que le modèle sélectionné est inclus dans votre plan.", - "notFound": "Exécutable Claude Code '{{claudePath}}' introuvable.\n\nVeuillez installer Claude Code CLI :\n1. Visitez {{installationUrl}} pour télécharger Claude Code\n2. Suivez les instructions d'installation pour votre système d'exploitation\n3. Assurez-vous que la commande 'claude' est disponible dans votre PATH\n4. Alternativement, configurez un chemin personnalisé dans les paramètres Roo sous 'Chemin de Claude Code'\n\nErreur originale : {{originalError}}" + "apiKeyModelPlanMismatch": "Les clés API et les plans d'abonnement permettent différents modèles. Assurez-vous que le modèle sélectionné est inclus dans votre plan." }, "message": { "no_active_task_to_delete": "Aucune tâche active pour supprimer des messages", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Clé API Groq", - "getGroqApiKey": "Obtenir la clé API Groq", - "claudeCode": { - "pathLabel": "Chemin de Claude Code", - "description": "Chemin optionnel vers votre CLI Claude Code. Par défaut 'claude' si non défini.", - "placeholder": "Par défaut : claude" - } + "getGroqApiKey": "Obtenir la clé API Groq" } }, "customModes": { diff --git a/src/i18n/locales/hi/common.json b/src/i18n/locales/hi/common.json index 38ed41b7e21..1b411c300e4 100644 --- a/src/i18n/locales/hi/common.json +++ b/src/i18n/locales/hi/common.json @@ -91,8 +91,7 @@ "errorOutput": "त्रुटि आउटपुट: {{output}}", "processExitedWithError": "Claude Code प्रक्रिया कोड {{exitCode}} के साथ समाप्त हुई। त्रुटि आउटपुट: {{output}}", "stoppedWithReason": "Claude Code इस कारण से रुका: {{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "संदेशों को हटाने के लिए कोई सक्रिय कार्य नहीं", @@ -188,11 +187,7 @@ "settings": { "providers": { "groqApiKey": "ग्रोक एपीआई कुंजी", - "getGroqApiKey": "ग्रोक एपीआई कुंजी प्राप्त करें", - "claudeCode": { - "pathLabel": "क्लाउड कोड पाथ", - "description": "आपके क्लाउड कोड CLI का वैकल्पिक पाथ। सेट न होने पर डिफ़ॉल्ट रूप से 'claude'।" - } + "getGroqApiKey": "ग्रोक एपीआई कुंजी प्राप्त करें" } }, "customModes": { diff --git a/src/i18n/locales/id/common.json b/src/i18n/locales/id/common.json index aa57fd1a934..0c1a10549f2 100644 --- a/src/i18n/locales/id/common.json +++ b/src/i18n/locales/id/common.json @@ -91,8 +91,7 @@ "errorOutput": "Output error: {{output}}", "processExitedWithError": "Proses Claude Code keluar dengan kode {{exitCode}}. Output error: {{output}}", "stoppedWithReason": "Claude Code berhenti karena alasan: {{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "Tidak ada tugas aktif untuk menghapus pesan", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Kunci API Groq", - "getGroqApiKey": "Dapatkan Kunci API Groq", - "claudeCode": { - "pathLabel": "Jalur Claude Code", - "description": "Jalur opsional ke CLI Claude Code Anda. Defaultnya 'claude' jika tidak diatur.", - "placeholder": "Default: claude" - } + "getGroqApiKey": "Dapatkan Kunci API Groq" } }, "customModes": { diff --git a/src/i18n/locales/it/common.json b/src/i18n/locales/it/common.json index 1bb7f4544f5..9c8cad214ca 100644 --- a/src/i18n/locales/it/common.json +++ b/src/i18n/locales/it/common.json @@ -91,8 +91,7 @@ "errorOutput": "Output di errore: {{output}}", "processExitedWithError": "Il processo Claude Code è terminato con codice {{exitCode}}. Output di errore: {{output}}", "stoppedWithReason": "Claude Code si è fermato per il motivo: {{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "Nessuna attività attiva da cui eliminare messaggi", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Chiave API Groq", - "getGroqApiKey": "Ottieni chiave API Groq", - "claudeCode": { - "pathLabel": "Percorso Claude Code", - "description": "Percorso opzionale alla tua CLI Claude Code. Predefinito 'claude' se non impostato.", - "placeholder": "Predefinito: claude" - } + "getGroqApiKey": "Ottieni chiave API Groq" } }, "customModes": { diff --git a/src/i18n/locales/ja/common.json b/src/i18n/locales/ja/common.json index 8dfafa12460..bb9725ece1e 100644 --- a/src/i18n/locales/ja/common.json +++ b/src/i18n/locales/ja/common.json @@ -91,8 +91,7 @@ "errorOutput": "エラー出力:{{output}}", "processExitedWithError": "Claude Code プロセスがコード {{exitCode}} で終了しました。エラー出力:{{output}}", "stoppedWithReason": "Claude Code が理由により停止しました:{{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "メッセージを削除するアクティブなタスクがありません", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Groq APIキー", - "getGroqApiKey": "Groq APIキーを取得", - "claudeCode": { - "pathLabel": "Claude Code パス", - "description": "Claude Code CLI へのオプションのパス。設定されていない場合は、デフォルトで「claude」になります。", - "placeholder": "デフォルト: claude" - } + "getGroqApiKey": "Groq APIキーを取得" } }, "customModes": { diff --git a/src/i18n/locales/ko/common.json b/src/i18n/locales/ko/common.json index 2a3a55d4ca9..aa1988086da 100644 --- a/src/i18n/locales/ko/common.json +++ b/src/i18n/locales/ko/common.json @@ -91,8 +91,7 @@ "errorOutput": "오류 출력: {{output}}", "processExitedWithError": "Claude Code 프로세스가 코드 {{exitCode}}로 종료되었습니다. 오류 출력: {{output}}", "stoppedWithReason": "Claude Code가 다음 이유로 중지되었습니다: {{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "메시지를 삭제할 활성 작업이 없습니다", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Groq API 키", - "getGroqApiKey": "Groq API 키 받기", - "claudeCode": { - "pathLabel": "Claude Code 경로", - "description": "Claude Code CLI의 선택적 경로입니다. 설정되지 않은 경우 기본값은 'claude'입니다.", - "placeholder": "기본값: claude" - } + "getGroqApiKey": "Groq API 키 받기" } }, "customModes": { diff --git a/src/i18n/locales/nl/common.json b/src/i18n/locales/nl/common.json index fc01724d46e..9e6a583825f 100644 --- a/src/i18n/locales/nl/common.json +++ b/src/i18n/locales/nl/common.json @@ -91,8 +91,7 @@ "errorOutput": "Foutuitvoer: {{output}}", "processExitedWithError": "Claude Code proces beëindigd met code {{exitCode}}. Foutuitvoer: {{output}}", "stoppedWithReason": "Claude Code gestopt om reden: {{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "Geen actieve taak om berichten uit te verwijderen", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Groq API-sleutel", - "getGroqApiKey": "Groq API-sleutel ophalen", - "claudeCode": { - "pathLabel": "Claude Code Pad", - "description": "Optioneel pad naar je Claude Code CLI. Standaard 'claude' indien niet ingesteld.", - "placeholder": "Standaard: claude" - } + "getGroqApiKey": "Groq API-sleutel ophalen" } }, "customModes": { diff --git a/src/i18n/locales/pl/common.json b/src/i18n/locales/pl/common.json index d5611ed8756..b41af53cc92 100644 --- a/src/i18n/locales/pl/common.json +++ b/src/i18n/locales/pl/common.json @@ -91,8 +91,7 @@ "errorOutput": "Wyjście błędu: {{output}}", "processExitedWithError": "Proces Claude Code zakończył się kodem {{exitCode}}. Wyjście błędu: {{output}}", "stoppedWithReason": "Claude Code zatrzymał się z powodu: {{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "Brak aktywnego zadania do usunięcia wiadomości", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Klucz API Groq", - "getGroqApiKey": "Uzyskaj klucz API Groq", - "claudeCode": { - "pathLabel": "Ścieżka Claude Code", - "description": "Opcjonalna ścieżka do Twojego CLI Claude Code. Domyślnie 'claude', jeśli nie ustawiono.", - "placeholder": "Domyślnie: claude" - } + "getGroqApiKey": "Uzyskaj klucz API Groq" } }, "customModes": { diff --git a/src/i18n/locales/pt-BR/common.json b/src/i18n/locales/pt-BR/common.json index 4f2e3fa488d..3554878c759 100644 --- a/src/i18n/locales/pt-BR/common.json +++ b/src/i18n/locales/pt-BR/common.json @@ -95,8 +95,7 @@ "errorOutput": "Saída de erro: {{output}}", "processExitedWithError": "O processo Claude Code saiu com código {{exitCode}}. Saída de erro: {{output}}", "stoppedWithReason": "Claude Code parou pela razão: {{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "Nenhuma tarefa ativa para excluir mensagens", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Chave de API Groq", - "getGroqApiKey": "Obter chave de API Groq", - "claudeCode": { - "pathLabel": "Caminho do Claude Code", - "description": "Caminho opcional para sua CLI do Claude Code. Padrão 'claude' se não for definido.", - "placeholder": "Padrão: claude" - } + "getGroqApiKey": "Obter chave de API Groq" } }, "customModes": { diff --git a/src/i18n/locales/ru/common.json b/src/i18n/locales/ru/common.json index 6ff5d01f896..475364164ac 100644 --- a/src/i18n/locales/ru/common.json +++ b/src/i18n/locales/ru/common.json @@ -91,8 +91,7 @@ "errorOutput": "Вывод ошибки: {{output}}", "processExitedWithError": "Процесс Claude Code завершился с кодом {{exitCode}}. Вывод ошибки: {{output}}", "stoppedWithReason": "Claude Code остановился по причине: {{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "Нет активной задачи для удаления сообщений", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Ключ API Groq", - "getGroqApiKey": "Получить ключ API Groq", - "claudeCode": { - "pathLabel": "Путь к Claude Code", - "description": "Необязательный путь к вашему CLI Claude Code. По умолчанию 'claude', если не установлено.", - "placeholder": "По умолчанию: claude" - } + "getGroqApiKey": "Получить ключ API Groq" } }, "customModes": { diff --git a/src/i18n/locales/tr/common.json b/src/i18n/locales/tr/common.json index 7529c8418f8..c188c4c1c81 100644 --- a/src/i18n/locales/tr/common.json +++ b/src/i18n/locales/tr/common.json @@ -91,8 +91,7 @@ "errorOutput": "Hata çıktısı: {{output}}", "processExitedWithError": "Claude Code işlemi {{exitCode}} koduyla çıktı. Hata çıktısı: {{output}}", "stoppedWithReason": "Claude Code şu nedenle durdu: {{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "Mesaj silinecek aktif görev yok", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Groq API Anahtarı", - "getGroqApiKey": "Groq API Anahtarı Al", - "claudeCode": { - "pathLabel": "Claude Code Yolu", - "description": "Claude Code CLI'nizin isteğe bağlı yolu. Ayarlanmazsa varsayılan olarak 'claude' olur.", - "placeholder": "Varsayılan: claude" - } + "getGroqApiKey": "Groq API Anahtarı Al" } }, "customModes": { diff --git a/src/i18n/locales/vi/common.json b/src/i18n/locales/vi/common.json index 94873fb7e96..51c53732167 100644 --- a/src/i18n/locales/vi/common.json +++ b/src/i18n/locales/vi/common.json @@ -91,8 +91,7 @@ "errorOutput": "Đầu ra lỗi: {{output}}", "processExitedWithError": "Tiến trình Claude Code thoát với mã {{exitCode}}. Đầu ra lỗi: {{output}}", "stoppedWithReason": "Claude Code dừng lại vì lý do: {{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "Không có nhiệm vụ hoạt động để xóa tin nhắn", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Khóa API Groq", - "getGroqApiKey": "Lấy khóa API Groq", - "claudeCode": { - "pathLabel": "Đường dẫn Claude Code", - "description": "Đường dẫn tùy chọn đến CLI Claude Code của bạn. Mặc định là 'claude' nếu không được đặt.", - "placeholder": "Mặc định: claude" - } + "getGroqApiKey": "Lấy khóa API Groq" } }, "customModes": { diff --git a/src/i18n/locales/zh-CN/common.json b/src/i18n/locales/zh-CN/common.json index df1407ee367..147f5e05084 100644 --- a/src/i18n/locales/zh-CN/common.json +++ b/src/i18n/locales/zh-CN/common.json @@ -96,8 +96,7 @@ "errorOutput": "错误输出:{{output}}", "processExitedWithError": "Claude Code 进程退出,退出码:{{exitCode}}。错误输出:{{output}}", "stoppedWithReason": "Claude Code 停止,原因:{{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}}" + "apiKeyModelPlanMismatch": "API keys and subscription plans allow different models. Make sure the selected model is included in your plan." }, "message": { "no_active_task_to_delete": "没有可删除消息的活跃任务", @@ -193,12 +192,7 @@ "settings": { "providers": { "groqApiKey": "Groq API 密钥", - "getGroqApiKey": "获取 Groq API 密钥", - "claudeCode": { - "pathLabel": "Claude Code 路径", - "description": "Claude Code CLI 的可选路径。如果未设置,默认为 'claude'。", - "placeholder": "默认: claude" - } + "getGroqApiKey": "获取 Groq API 密钥" } }, "customModes": { diff --git a/src/i18n/locales/zh-TW/common.json b/src/i18n/locales/zh-TW/common.json index 09d13a0fc26..7e53be370bc 100644 --- a/src/i18n/locales/zh-TW/common.json +++ b/src/i18n/locales/zh-TW/common.json @@ -90,8 +90,7 @@ "errorOutput": "錯誤輸出:{{output}}", "processExitedWithError": "Claude Code 程序退出,退出碼:{{exitCode}}。錯誤輸出:{{output}}", "stoppedWithReason": "Claude Code 停止,原因:{{reason}}", - "apiKeyModelPlanMismatch": "API 金鑰和訂閱方案允許不同的模型。請確保所選模型包含在您的方案中。", - "notFound": "找不到 Claude Code 可執行檔案 '{{claudePath}}'。\n\n請安裝 Claude Code CLI:\n1. 造訪 {{installationUrl}} 下載 Claude Code\n2. 依照作業系統的安裝說明進行操作\n3. 確保 'claude' 指令在 PATH 中可用\n4. 或者在 Roo 設定中的 'Claude Code 路徑' 下設定自訂路徑\n\n原始錯誤:{{originalError}}" + "apiKeyModelPlanMismatch": "API 金鑰和訂閱方案允許不同的模型。請確保所選模型包含在您的方案中。" }, "message": { "no_active_task_to_delete": "沒有可刪除訊息的活躍工作", @@ -188,12 +187,7 @@ "settings": { "providers": { "groqApiKey": "Groq API 金鑰", - "getGroqApiKey": "取得 Groq API 金鑰", - "claudeCode": { - "pathLabel": "Claude Code 路徑", - "description": "Claude Code CLI 的選用路徑。如果未設定,預設為 'claude'。", - "placeholder": "預設: claude" - } + "getGroqApiKey": "取得 Groq API 金鑰" } }, "customModes": { diff --git a/src/integrations/claude-code/__tests__/run.spec.ts b/src/integrations/claude-code/__tests__/run.spec.ts deleted file mode 100644 index a07120c28ae..00000000000 --- a/src/integrations/claude-code/__tests__/run.spec.ts +++ /dev/null @@ -1,521 +0,0 @@ -// Mock i18n system -vi.mock("../../i18n", () => ({ - t: vi.fn((key: string, options?: Record) => { - // Mock the specific translation key used in the code - if (key === "errors.claudeCode.notFound") { - const claudePath = options?.claudePath || "claude" - const installationUrl = options?.installationUrl || "https://docs.anthropic.com/en/docs/claude-code/setup" - const originalError = options?.originalError || "spawn claude ENOENT" - - return `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}` - } - // Return the key as fallback for other translations - return key - }), -})) - -// Mock os module -vi.mock("os", () => ({ - platform: vi.fn(() => "darwin"), // Default to non-Windows -})) - -// Mock vscode workspace -vi.mock("vscode", () => ({ - workspace: { - workspaceFolders: [ - { - uri: { - fsPath: "/test/workspace", - }, - }, - ], - }, -})) - -// Mock execa to test stdin behavior -const mockExeca = vi.fn() -const mockStdin = { - write: vi.fn((data, encoding, callback) => { - // Simulate successful write - if (callback) callback(null) - }), - end: vi.fn(), -} - -// Mock process that simulates successful execution -const createMockProcess = () => { - let resolveProcess: (value: { exitCode: number }) => void - const processPromise = new Promise<{ exitCode: number }>((resolve) => { - resolveProcess = resolve - }) - - const mockProcess = { - stdin: mockStdin, - stdout: { - on: vi.fn(), - }, - stderr: { - on: vi.fn((event, callback) => { - // Don't emit any stderr data in tests - }), - }, - on: vi.fn((event, callback) => { - if (event === "close") { - // Simulate successful process completion after a short delay - setTimeout(() => { - callback(0) - resolveProcess({ exitCode: 0 }) - }, 10) - } - if (event === "error") { - // Don't emit any errors in tests - } - }), - killed: false, - kill: vi.fn(), - then: processPromise.then.bind(processPromise), - catch: processPromise.catch.bind(processPromise), - finally: processPromise.finally.bind(processPromise), - } - return mockProcess -} - -vi.mock("execa", () => ({ - execa: mockExeca, -})) - -// Mock readline with proper interface simulation -let mockReadlineInterface: any = null - -vi.mock("readline", () => ({ - default: { - createInterface: vi.fn(() => { - mockReadlineInterface = { - async *[Symbol.asyncIterator]() { - // Simulate Claude CLI JSON output - yield '{"type":"text","text":"Hello"}' - yield '{"type":"text","text":" world"}' - // Simulate end of stream - must return to terminate the iterator - return - }, - close: vi.fn(), - } - return mockReadlineInterface - }), - }, -})) - -describe("runClaudeCode", () => { - beforeEach(() => { - vi.clearAllMocks() - mockExeca.mockReturnValue(createMockProcess()) - // Mock setImmediate to run synchronously in tests - vi.spyOn(global, "setImmediate").mockImplementation((callback: any) => { - callback() - return {} as any - }) - // Clear module cache to ensure fresh imports - vi.resetModules() - }) - - afterEach(() => { - vi.restoreAllMocks() - }) - - test("should export runClaudeCode function", async () => { - const { runClaudeCode } = await import("../run") - expect(typeof runClaudeCode).toBe("function") - }) - - test("should be an async generator function", async () => { - const { runClaudeCode } = await import("../run") - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - } - - const result = runClaudeCode(options) - expect(Symbol.asyncIterator in result).toBe(true) - expect(typeof result[Symbol.asyncIterator]).toBe("function") - }) - - test("should handle platform-specific stdin behavior", async () => { - const { runClaudeCode } = await import("../run") - const messages = [{ role: "user" as const, content: "Hello world!" }] - const systemPrompt = "You are a helpful assistant" - const options = { - systemPrompt, - messages, - } - - // Test on Windows - const os = await import("os") - vi.mocked(os.platform).mockReturnValue("win32") - - const generator = runClaudeCode(options) - const results = [] - for await (const chunk of generator) { - results.push(chunk) - } - - // On Windows, should NOT have --system-prompt in args - const [, args] = mockExeca.mock.calls[0] - expect(args).not.toContain("--system-prompt") - - // Should pass both system prompt and messages via stdin - const expectedStdinData = JSON.stringify({ systemPrompt, messages }) - expect(mockStdin.write).toHaveBeenCalledWith(expectedStdinData, "utf8", expect.any(Function)) - - // Reset mocks for non-Windows test - vi.clearAllMocks() - mockExeca.mockReturnValue(createMockProcess()) - - // Test on non-Windows - vi.mocked(os.platform).mockReturnValue("darwin") - - const generator2 = runClaudeCode(options) - const results2 = [] - for await (const chunk of generator2) { - results2.push(chunk) - } - - // On non-Windows, should have --system-prompt in args - const [, args2] = mockExeca.mock.calls[0] - expect(args2).toContain("--system-prompt") - expect(args2).toContain(systemPrompt) - - // Should only pass messages via stdin - expect(mockStdin.write).toHaveBeenCalledWith(JSON.stringify(messages), "utf8", expect.any(Function)) - }) - - test("should include model parameter when provided", async () => { - const { runClaudeCode } = await import("../run") - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - modelId: "claude-3-5-sonnet-20241022", - } - - const generator = runClaudeCode(options) - - // Consume at least one item to trigger process spawn - await generator.next() - - // Clean up the generator - await generator.return(undefined) - - const [, args] = mockExeca.mock.calls[0] - expect(args).toContain("--model") - expect(args).toContain("claude-3-5-sonnet-20241022") - }) - - test("should use custom claude path when provided", async () => { - const { runClaudeCode } = await import("../run") - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - path: "/custom/path/to/claude", - } - - const generator = runClaudeCode(options) - - // Consume at least one item to trigger process spawn - await generator.next() - - // Clean up the generator - await generator.return(undefined) - - const [claudePath] = mockExeca.mock.calls[0] - expect(claudePath).toBe("/custom/path/to/claude") - }) - - test("should handle stdin write errors gracefully", async () => { - const { runClaudeCode } = await import("../run") - - // Create a mock process with stdin that fails - const mockProcessWithError = createMockProcess() - mockProcessWithError.stdin.write = vi.fn((data, encoding, callback) => { - // Simulate write error - if (callback) callback(new Error("EPIPE: broken pipe")) - }) - - // Mock console.error to verify error logging - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - - mockExeca.mockReturnValueOnce(mockProcessWithError) - - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - } - - const generator = runClaudeCode(options) - - // Try to consume the generator - try { - await generator.next() - } catch (error) { - // Expected to fail - } - - // Verify error was logged - expect(consoleErrorSpy).toHaveBeenCalledWith("Error writing to Claude Code stdin:", expect.any(Error)) - - // Verify process was killed - expect(mockProcessWithError.kill).toHaveBeenCalled() - - // Clean up - consoleErrorSpy.mockRestore() - await generator.return(undefined) - }) - - test("should handle stdin access errors gracefully", async () => { - const { runClaudeCode } = await import("../run") - - // Create a mock process without stdin - const mockProcessWithoutStdin = createMockProcess() - mockProcessWithoutStdin.stdin = null as any - - // Mock console.error to verify error logging - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}) - - mockExeca.mockReturnValueOnce(mockProcessWithoutStdin) - - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - } - - const generator = runClaudeCode(options) - - // Try to consume the generator - try { - await generator.next() - } catch (error) { - // Expected to fail - } - - // Verify error was logged - expect(consoleErrorSpy).toHaveBeenCalledWith("Error accessing Claude Code stdin:", expect.any(Error)) - - // Verify process was killed - expect(mockProcessWithoutStdin.kill).toHaveBeenCalled() - - // Clean up - consoleErrorSpy.mockRestore() - await generator.return(undefined) - }) - - test("should handle ENOENT errors during process spawn with helpful error message", async () => { - const { runClaudeCode } = await import("../run") - - // Mock execa to throw ENOENT error - const enoentError = new Error("spawn claude ENOENT") - ;(enoentError as any).code = "ENOENT" - mockExeca.mockImplementationOnce(() => { - throw enoentError - }) - - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - } - - const generator = runClaudeCode(options) - - // Should throw enhanced ENOENT error - await expect(generator.next()).rejects.toThrow(/errors\.claudeCode\.notFound/) - }) - - test("should handle ENOENT errors during process execution with helpful error message", async () => { - const { runClaudeCode } = await import("../run") - - // Create a mock process that emits ENOENT error - const mockProcessWithError = createMockProcess() - const enoentError = new Error("spawn claude ENOENT") - ;(enoentError as any).code = "ENOENT" - - mockProcessWithError.on = vi.fn((event, callback) => { - if (event === "error") { - // Emit ENOENT error immediately - callback(enoentError) - } else if (event === "close") { - // Don't emit close event in this test - } - }) - - // Mock readline to not yield any data when there's an error - const mockReadlineForError = { - [Symbol.asyncIterator]() { - return { - async next() { - // Don't yield anything - simulate error before any output - return { done: true, value: undefined } - }, - } - }, - close: vi.fn(), - } - - const readline = await import("readline") - vi.mocked(readline.default.createInterface).mockReturnValueOnce(mockReadlineForError as any) - - mockExeca.mockReturnValueOnce(mockProcessWithError) - - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - } - - const generator = runClaudeCode(options) - - // Should throw enhanced ENOENT error - await expect(generator.next()).rejects.toThrow(/errors\.claudeCode\.notFound/) - }) - - test("should handle ENOENT errors with custom claude path", async () => { - const { runClaudeCode } = await import("../run") - - const customPath = "/custom/path/to/claude" - const enoentError = new Error(`spawn ${customPath} ENOENT`) - ;(enoentError as any).code = "ENOENT" - mockExeca.mockImplementationOnce(() => { - throw enoentError - }) - - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - path: customPath, - } - - const generator = runClaudeCode(options) - - // Should throw enhanced ENOENT error with custom path - await expect(generator.next()).rejects.toThrow(/errors\.claudeCode\.notFound/) - }) - - test("should preserve non-ENOENT errors during process spawn", async () => { - const { runClaudeCode } = await import("../run") - - // Mock execa to throw non-ENOENT error - const otherError = new Error("Permission denied") - mockExeca.mockImplementationOnce(() => { - throw otherError - }) - - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - } - - const generator = runClaudeCode(options) - - // Should throw original error, not enhanced ENOENT error - await expect(generator.next()).rejects.toThrow("Permission denied") - }) - - test("should preserve non-ENOENT errors during process execution", async () => { - const { runClaudeCode } = await import("../run") - - // Create a mock process that emits non-ENOENT error - const mockProcessWithError = createMockProcess() - const otherError = new Error("Permission denied") - - mockProcessWithError.on = vi.fn((event, callback) => { - if (event === "error") { - // Emit non-ENOENT error immediately - callback(otherError) - } else if (event === "close") { - // Don't emit close event in this test - } - }) - - // Mock readline to not yield any data when there's an error - const mockReadlineForError = { - [Symbol.asyncIterator]() { - return { - async next() { - // Don't yield anything - simulate error before any output - return { done: true, value: undefined } - }, - } - }, - close: vi.fn(), - } - - const readline = await import("readline") - vi.mocked(readline.default.createInterface).mockReturnValueOnce(mockReadlineForError as any) - - mockExeca.mockReturnValueOnce(mockProcessWithError) - - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - } - - const generator = runClaudeCode(options) - - // Should throw original error, not enhanced ENOENT error - await expect(generator.next()).rejects.toThrow("Permission denied") - }) - - test("should prioritize ClaudeCodeNotFoundError over generic exit code errors", async () => { - const { runClaudeCode } = await import("../run") - - // Create a mock process that emits ENOENT error and then exits with non-zero code - const mockProcessWithError = createMockProcess() - const enoentError = new Error("spawn claude ENOENT") - ;(enoentError as any).code = "ENOENT" - - let resolveProcess: (value: { exitCode: number }) => void - const processPromise = new Promise<{ exitCode: number }>((resolve) => { - resolveProcess = resolve - }) - - mockProcessWithError.on = vi.fn((event, callback) => { - if (event === "error") { - // Emit ENOENT error immediately - callback(enoentError) - } else if (event === "close") { - // Emit non-zero exit code - setTimeout(() => { - callback(1) - resolveProcess({ exitCode: 1 }) - }, 10) - } - }) - - mockProcessWithError.then = processPromise.then.bind(processPromise) - mockProcessWithError.catch = processPromise.catch.bind(processPromise) - mockProcessWithError.finally = processPromise.finally.bind(processPromise) - - // Mock readline to not yield any data when there's an error - const mockReadlineForError = { - [Symbol.asyncIterator]() { - return { - async next() { - // Don't yield anything - simulate error before any output - return { done: true, value: undefined } - }, - } - }, - close: vi.fn(), - } - - const readline = await import("readline") - vi.mocked(readline.default.createInterface).mockReturnValueOnce(mockReadlineForError as any) - - mockExeca.mockReturnValueOnce(mockProcessWithError) - - const options = { - systemPrompt: "You are a helpful assistant", - messages: [{ role: "user" as const, content: "Hello" }], - } - - const generator = runClaudeCode(options) - - // Should throw ClaudeCodeNotFoundError, not generic exit code error - await expect(generator.next()).rejects.toThrow(/errors\.claudeCode\.notFound/) - }) -}) diff --git a/src/integrations/claude-code/__tests__/streaming-client.spec.ts b/src/integrations/claude-code/__tests__/streaming-client.spec.ts index bb475620c44..3ef3103417a 100644 --- a/src/integrations/claude-code/__tests__/streaming-client.spec.ts +++ b/src/integrations/claude-code/__tests__/streaming-client.spec.ts @@ -18,7 +18,7 @@ describe("Claude Code Streaming Client", () => { }) test("should have correct user agent", () => { - expect(CLAUDE_CODE_API_CONFIG.userAgent).toBe("claude-cli/1.0.83 (external, cli)") + expect(CLAUDE_CODE_API_CONFIG.userAgent).toMatch(/^Roo-Code\/\d+\.\d+\.\d+$/) }) }) @@ -68,6 +68,7 @@ describe("Claude Code Streaming Client", () => { "Content-Type": "application/json", "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, Accept: "text/event-stream", + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, }), }), ) diff --git a/src/integrations/claude-code/run.ts b/src/integrations/claude-code/run.ts deleted file mode 100644 index 1d617b9242b..00000000000 --- a/src/integrations/claude-code/run.ts +++ /dev/null @@ -1,275 +0,0 @@ -import * as vscode from "vscode" -import type Anthropic from "@anthropic-ai/sdk" -import { execa } from "execa" -import { ClaudeCodeMessage } from "./types" -import readline from "readline" -import { CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS } from "@roo-code/types" -import * as os from "os" -import { t } from "../../i18n" - -const cwd = vscode.workspace.workspaceFolders?.map((folder) => folder.uri.fsPath).at(0) - -// Claude Code installation URL - can be easily updated if needed -const CLAUDE_CODE_INSTALLATION_URL = "https://docs.anthropic.com/en/docs/claude-code/setup" - -type ClaudeCodeOptions = { - systemPrompt: string - messages: Anthropic.Messages.MessageParam[] - path?: string - modelId?: string -} - -type ProcessState = { - partialData: string | null - error: Error | null - stderrLogs: string - exitCode: number | null -} - -export async function* runClaudeCode( - options: ClaudeCodeOptions & { maxOutputTokens?: number }, -): AsyncGenerator { - const claudePath = options.path || "claude" - let process - - try { - process = runProcess(options) - } catch (error: any) { - // Handle ENOENT errors immediately when spawning the process - if (error.code === "ENOENT" || error.message?.includes("ENOENT")) { - throw createClaudeCodeNotFoundError(claudePath, error) - } - throw error - } - - const rl = readline.createInterface({ - input: process.stdout, - }) - - try { - const processState: ProcessState = { - error: null, - stderrLogs: "", - exitCode: null, - partialData: null, - } - - process.stderr.on("data", (data) => { - processState.stderrLogs += data.toString() - }) - - process.on("close", (code) => { - processState.exitCode = code - }) - - process.on("error", (err) => { - // Enhance ENOENT errors with helpful installation guidance - if (err.message.includes("ENOENT") || (err as any).code === "ENOENT") { - processState.error = createClaudeCodeNotFoundError(claudePath, err) - } else { - processState.error = err - } - // Close the readline interface to break out of the loop - rl.close() - }) - - for await (const line of rl) { - if (processState.error) { - throw processState.error - } - - if (line.trim()) { - const chunk = parseChunk(line, processState) - - if (!chunk) { - continue - } - - yield chunk - } - } - - // Check for errors that occurred during processing - if (processState.error) { - throw processState.error - } - - // We rely on the assistant message. If the output was truncated, it's better having a poorly formatted message - // from which to extract something, than throwing an error/showing the model didn't return any messages. - if (processState.partialData && processState.partialData.startsWith(`{"type":"assistant"`)) { - yield processState.partialData - } - - const { exitCode } = await process - if (exitCode !== null && exitCode !== 0) { - // If we have a specific ENOENT error, throw that instead - if (processState.error && (processState.error as any).name === "ClaudeCodeNotFoundError") { - throw processState.error - } - - const errorOutput = (processState.error as any)?.message || processState.stderrLogs?.trim() - throw new Error( - `Claude Code process exited with code ${exitCode}.${errorOutput ? ` Error output: ${errorOutput}` : ""}`, - ) - } - } finally { - rl.close() - if (!process.killed) { - process.kill() - } - } -} - -// We want the model to use our custom tool format instead of built-in tools. -// Disabling built-in tools prevents tool-only responses and ensures text output. -const claudeCodeTools = [ - "Task", - "Bash", - "Glob", - "Grep", - "LS", - "exit_plan_mode", - "Read", - "Edit", - "MultiEdit", - "Write", - "NotebookRead", - "NotebookEdit", - "WebFetch", - "TodoRead", - "TodoWrite", - "WebSearch", - "ExitPlanMode", - "BashOutput", - "KillBash", -].join(",") - -const CLAUDE_CODE_TIMEOUT = 600000 // 10 minutes - -function runProcess({ - systemPrompt, - messages, - path, - modelId, - maxOutputTokens, -}: ClaudeCodeOptions & { maxOutputTokens?: number }) { - const claudePath = path || "claude" - const isWindows = os.platform() === "win32" - - // Build args based on platform - const args = ["-p"] - - // Pass system prompt as flag on non-Windows, via stdin on Windows (avoids cmd length limits) - if (!isWindows) { - args.push("--system-prompt", systemPrompt) - } - - args.push( - "--verbose", - "--output-format", - "stream-json", - "--disallowedTools", - claudeCodeTools, - // Roo Code will handle recursive calls - "--max-turns", - "1", - ) - - if (modelId) { - args.push("--model", modelId) - } - - const child = execa(claudePath, args, { - stdin: "pipe", - stdout: "pipe", - stderr: "pipe", - env: { - ...process.env, - // Use the configured value, or the environment variable, or default to CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS - CLAUDE_CODE_MAX_OUTPUT_TOKENS: - maxOutputTokens?.toString() || - process.env.CLAUDE_CODE_MAX_OUTPUT_TOKENS || - CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS.toString(), - }, - cwd, - maxBuffer: 1024 * 1024 * 1000, - timeout: CLAUDE_CODE_TIMEOUT, - }) - - // Prepare stdin data: Windows gets both system prompt & messages (avoids 8191 char limit), - // other platforms get messages only (avoids Linux E2BIG error from ~128KiB execve limit) - let stdinData: string - if (isWindows) { - stdinData = JSON.stringify({ - systemPrompt, - messages, - }) - } else { - stdinData = JSON.stringify(messages) - } - - // Use setImmediate to ensure process is spawned before writing (prevents stdin race conditions) - setImmediate(() => { - try { - child.stdin.write(stdinData, "utf8", (error: Error | null | undefined) => { - if (error) { - console.error("Error writing to Claude Code stdin:", error) - child.kill() - } - }) - child.stdin.end() - } catch (error) { - console.error("Error accessing Claude Code stdin:", error) - child.kill() - } - }) - - return child -} - -function parseChunk(data: string, processState: ProcessState) { - if (processState.partialData) { - processState.partialData += data - - const chunk = attemptParseChunk(processState.partialData) - - if (!chunk) { - return null - } - - processState.partialData = null - return chunk - } - - const chunk = attemptParseChunk(data) - - if (!chunk) { - processState.partialData = data - } - - return chunk -} - -function attemptParseChunk(data: string): ClaudeCodeMessage | null { - try { - return JSON.parse(data) - } catch (error) { - console.error("Error parsing chunk:", error, data.length) - return null - } -} - -/** - * Creates a user-friendly error message for Claude Code ENOENT errors - */ -function createClaudeCodeNotFoundError(claudePath: string, originalError: Error): Error { - const errorMessage = t("common:errors.claudeCode.notFound", { - claudePath, - installationUrl: CLAUDE_CODE_INSTALLATION_URL, - originalError: originalError.message, - }) - - const error = new Error(errorMessage) - error.name = "ClaudeCodeNotFoundError" - return error -} diff --git a/src/integrations/claude-code/streaming-client.ts b/src/integrations/claude-code/streaming-client.ts index 76664806697..93628894043 100644 --- a/src/integrations/claude-code/streaming-client.ts +++ b/src/integrations/claude-code/streaming-client.ts @@ -1,6 +1,6 @@ import type { Anthropic } from "@anthropic-ai/sdk" import type { ClaudeCodeRateLimitInfo } from "@roo-code/types" -import * as os from "os" +import { Package } from "../../shared/package" /** * Set of content block types that are valid for Anthropic API. @@ -253,40 +253,9 @@ export const CLAUDE_CODE_API_CONFIG = { "interleaved-thinking-2025-05-14", "fine-grained-tool-streaming-2025-05-14", ], - userAgent: "claude-cli/1.0.83 (external, cli)", + userAgent: `Roo-Code/${Package.version}`, } as const -/** - * Get Claude Code CLI headers - includes Stainless SDK headers and special CLI headers - */ -function getClaudeCodeCliHeaders(): Record { - const arch = os.arch() - const platform = os.platform() - - // Map platform to OS name - must match Claude CLI format exactly - const osMap: Record = { - darwin: "MacOS", // Note: Claude CLI uses "MacOS" not "macOS" - linux: "Linux", - win32: "Windows", - } - - return { - // Claude Code specific headers - "Anthropic-Dangerous-Direct-Browser-Access": "true", - "X-App": "cli", - // Stainless SDK headers as used by Claude CLI - "X-Stainless-Lang": "js", - "X-Stainless-Package-Version": "0.55.1", - "X-Stainless-OS": osMap[platform] || platform, - "X-Stainless-Arch": arch, - "X-Stainless-Runtime": "node", - "X-Stainless-Runtime-Version": "v24.3.0", - "X-Stainless-Helper-Method": "stream", - "X-Stainless-Timeout": "60", - Connection: "keep-alive", - } -} - /** * SSE Event types from Anthropic streaming API */ @@ -540,16 +509,14 @@ export async function* createStreamingMessage(options: StreamMessageOptions): As body.tool_choice = toolChoice } - // Build headers - match Claude Code CLI exactly + // Build minimal headers const headers: Record = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, "Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","), Accept: "text/event-stream", - "Accept-Encoding": "gzip, deflate, br, zstd", "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, - ...getClaudeCodeCliHeaders(), } // Make the request @@ -844,14 +811,13 @@ export async function fetchRateLimitInfo(accessToken: string): Promise = { Authorization: `Bearer ${accessToken}`, "Content-Type": "application/json", "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, "Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","), "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, - ...getClaudeCodeCliHeaders(), } // Make the request diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index 46b948bf2fd..278a97424c2 100644 --- a/src/shared/__tests__/api.spec.ts +++ b/src/shared/__tests__/api.spec.ts @@ -1,9 +1,4 @@ -import { - type ModelInfo, - type ProviderSettings, - CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS, - ANTHROPIC_DEFAULT_MAX_TOKENS, -} from "@roo-code/types" +import { type ModelInfo, type ProviderSettings, ANTHROPIC_DEFAULT_MAX_TOKENS } from "@roo-code/types" import { getModelMaxOutputTokens, shouldUseReasoningBudget, shouldUseReasoningEffort } from "../api" @@ -14,21 +9,6 @@ describe("getModelMaxOutputTokens", () => { supportsPromptCache: true, } - test("should return claudeCodeMaxOutputTokens when using claude-code provider", () => { - const settings: ProviderSettings = { - apiProvider: "claude-code", - claudeCodeMaxOutputTokens: 16384, - } - - const result = getModelMaxOutputTokens({ - modelId: "claude-3-5-sonnet-20241022", - model: mockModel, - settings, - }) - - expect(result).toBe(16384) - }) - test("should return model maxTokens when not using claude-code provider and maxTokens is within 20% of context window", () => { const settings: ProviderSettings = { apiProvider: "anthropic", @@ -45,21 +25,6 @@ describe("getModelMaxOutputTokens", () => { expect(result).toBe(8192) }) - test("should return default CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS when claude-code provider has no custom max tokens", () => { - const settings: ProviderSettings = { - apiProvider: "claude-code", - // No claudeCodeMaxOutputTokens set - } - - const result = getModelMaxOutputTokens({ - modelId: "claude-3-5-sonnet-20241022", - model: mockModel, - settings, - }) - - expect(result).toBe(CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS) - }) - test("should handle reasoning budget models correctly", () => { const reasoningModel: ModelInfo = { ...mockModel, diff --git a/src/shared/api.ts b/src/shared/api.ts index ffb42a8ca44..fb7680fbfda 100644 --- a/src/shared/api.ts +++ b/src/shared/api.ts @@ -4,7 +4,6 @@ import { type DynamicProvider, type LocalProvider, ANTHROPIC_DEFAULT_MAX_TOKENS, - CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS, isDynamicProvider, isLocalProvider, } from "@roo-code/types" @@ -120,11 +119,6 @@ export const getModelMaxOutputTokens = ({ settings?: ProviderSettings format?: "anthropic" | "openai" | "gemini" | "openrouter" }): number | undefined => { - // Check for Claude Code specific max output tokens setting - if (settings?.apiProvider === "claude-code") { - return settings.claudeCodeMaxOutputTokens || CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS - } - if (shouldUseReasoningBudget({ model, settings })) { return settings?.modelMaxTokens || DEFAULT_HYBRID_REASONING_MODEL_MAX_TOKENS } From 677b2c3eb085150e7ba60dc611d61554ec96ccce Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 16 Dec 2025 20:30:38 -0700 Subject: [PATCH 11/16] Fix ProviderSettingsManager migration test --- src/core/config/__tests__/ProviderSettingsManager.spec.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/src/core/config/__tests__/ProviderSettingsManager.spec.ts b/src/core/config/__tests__/ProviderSettingsManager.spec.ts index d8dd62cad9b..0669d9591c8 100644 --- a/src/core/config/__tests__/ProviderSettingsManager.spec.ts +++ b/src/core/config/__tests__/ProviderSettingsManager.spec.ts @@ -68,6 +68,7 @@ describe("ProviderSettingsManager", () => { openAiHeadersMigrated: true, consecutiveMistakeLimitMigrated: true, todoListEnabledMigrated: true, + claudeCodeLegacySettingsMigrated: true, }, }), ) From b04932b275e2969a9184c8f695022586389bd2f0 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 16 Dec 2025 20:52:06 -0700 Subject: [PATCH 12/16] chore(claude-code): remove unused legacy CLI message types --- src/integrations/claude-code/types.ts | 34 --------------------------- 1 file changed, 34 deletions(-) delete mode 100644 src/integrations/claude-code/types.ts diff --git a/src/integrations/claude-code/types.ts b/src/integrations/claude-code/types.ts deleted file mode 100644 index 36edaee2ed1..00000000000 --- a/src/integrations/claude-code/types.ts +++ /dev/null @@ -1,34 +0,0 @@ -import type { Anthropic } from "@anthropic-ai/sdk" - -type InitMessage = { - type: "system" - subtype: "init" - session_id: string - tools: string[] - mcp_servers: string[] - apiKeySource: "none" | "/login managed key" | string -} - -type AssistantMessage = { - type: "assistant" - message: Anthropic.Messages.Message - session_id: string -} - -type ErrorMessage = { - type: "error" -} - -type ResultMessage = { - type: "result" - subtype: "success" - total_cost_usd: number - is_error: boolean - duration_ms: number - duration_api_ms: number - num_turns: number - result: string - session_id: string -} - -export type ClaudeCodeMessage = InitMessage | AssistantMessage | ErrorMessage | ResultMessage From 48a8004c77384f274a947a00cce3b928e2265fe4 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 16 Dec 2025 21:24:26 -0700 Subject: [PATCH 13/16] refactor: remove legacy model name conversion functions and related tests --- .../providers/__tests__/claude-code.spec.ts | 43 +---- packages/types/src/providers/claude-code.ts | 34 ---- .../__tests__/streaming-client.spec.ts | 162 +----------------- .../claude-code/streaming-client.ts | 106 +----------- 4 files changed, 11 insertions(+), 334 deletions(-) diff --git a/packages/types/src/providers/__tests__/claude-code.spec.ts b/packages/types/src/providers/__tests__/claude-code.spec.ts index 26b6267e8b1..5ed66209a53 100644 --- a/packages/types/src/providers/__tests__/claude-code.spec.ts +++ b/packages/types/src/providers/__tests__/claude-code.spec.ts @@ -1,45 +1,4 @@ -import { convertModelNameForVertex, getClaudeCodeModelId, normalizeClaudeCodeModelId } from "../claude-code.js" - -describe("convertModelNameForVertex", () => { - test("should convert hyphen-date format to @date format", () => { - expect(convertModelNameForVertex("claude-sonnet-4-20250514")).toBe("claude-sonnet-4@20250514") - expect(convertModelNameForVertex("claude-opus-4-20250514")).toBe("claude-opus-4@20250514") - expect(convertModelNameForVertex("claude-3-7-sonnet-20250219")).toBe("claude-3-7-sonnet@20250219") - expect(convertModelNameForVertex("claude-3-5-sonnet-20241022")).toBe("claude-3-5-sonnet@20241022") - expect(convertModelNameForVertex("claude-3-5-haiku-20241022")).toBe("claude-3-5-haiku@20241022") - }) - - test("should not modify models without date pattern", () => { - expect(convertModelNameForVertex("some-other-model")).toBe("some-other-model") - expect(convertModelNameForVertex("claude-model")).toBe("claude-model") - expect(convertModelNameForVertex("model-with-short-date-123")).toBe("model-with-short-date-123") - }) - - test("should only convert 8-digit date patterns at the end", () => { - expect(convertModelNameForVertex("claude-20250514-sonnet")).toBe("claude-20250514-sonnet") - expect(convertModelNameForVertex("model-20250514-with-more")).toBe("model-20250514-with-more") - }) -}) - -describe("getClaudeCodeModelId", () => { - test("should return original model when useVertex is false", () => { - // Use valid ClaudeCodeModelId values - they don't have date suffixes - expect(getClaudeCodeModelId("claude-sonnet-4-5", false)).toBe("claude-sonnet-4-5") - expect(getClaudeCodeModelId("claude-opus-4-5", false)).toBe("claude-opus-4-5") - expect(getClaudeCodeModelId("claude-haiku-4-5", false)).toBe("claude-haiku-4-5") - }) - - test("should return same model when useVertex is true (no date suffix to convert)", () => { - // Valid ClaudeCodeModelIds don't have 8-digit date suffixes, so no conversion happens - expect(getClaudeCodeModelId("claude-sonnet-4-5", true)).toBe("claude-sonnet-4-5") - expect(getClaudeCodeModelId("claude-opus-4-5", true)).toBe("claude-opus-4-5") - expect(getClaudeCodeModelId("claude-haiku-4-5", true)).toBe("claude-haiku-4-5") - }) - - test("should default to useVertex false when parameter not provided", () => { - expect(getClaudeCodeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4-5") - }) -}) +import { normalizeClaudeCodeModelId } from "../claude-code.js" describe("normalizeClaudeCodeModelId", () => { test("should return valid model IDs unchanged", () => { diff --git a/packages/types/src/providers/claude-code.ts b/packages/types/src/providers/claude-code.ts index 194cd2fa27d..28863675d07 100644 --- a/packages/types/src/providers/claude-code.ts +++ b/packages/types/src/providers/claude-code.ts @@ -37,27 +37,9 @@ export interface ClaudeCodeRateLimitInfo { fetchedAt: number } -// Regex pattern to match 8-digit date at the end of model names -const VERTEX_DATE_PATTERN = /-(\d{8})$/ - // Regex pattern to strip date suffix from model names const DATE_SUFFIX_PATTERN = /-\d{8}$/ -/** - * Converts Claude model names from hyphen-date format to Vertex AI's @-date format. - * - * @param modelName - The original model name (e.g., "claude-sonnet-4-20250514") - * @returns The converted model name for Vertex AI (e.g., "claude-sonnet-4@20250514") - * - * @example - * convertModelNameForVertex("claude-sonnet-4-20250514") // returns "claude-sonnet-4@20250514" - * convertModelNameForVertex("claude-model") // returns "claude-model" (no change) - */ -export function convertModelNameForVertex(modelName: string): string { - // Convert hyphen-date format to @date format for Vertex AI - return modelName.replace(VERTEX_DATE_PATTERN, "@$1") -} - // Models that work with Claude Code OAuth tokens // See: https://docs.anthropic.com/en/docs/claude-code // NOTE: Claude Code is subscription-based with no per-token cost - pricing fields are 0 @@ -100,7 +82,6 @@ export const claudeCodeModels = { // Claude Code - Only models that work with Claude Code OAuth tokens export type ClaudeCodeModelId = keyof typeof claudeCodeModels export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5" -export const CLAUDE_CODE_DEFAULT_MAX_OUTPUT_TOKENS = 16000 /** * Model family patterns for normalization. @@ -177,18 +158,3 @@ export const claudeCodeReasoningConfig = { } as const export type ClaudeCodeReasoningLevel = keyof typeof claudeCodeReasoningConfig - -/** - * Gets the appropriate model ID based on whether Vertex AI is being used. - * - * @param baseModelId - The base Claude Code model ID - * @param useVertex - Whether to format the model ID for Vertex AI (default: false) - * @returns The model ID, potentially formatted for Vertex AI - * - * @example - * getClaudeCodeModelId("claude-sonnet-4-20250514", true) // returns "claude-sonnet-4@20250514" - * getClaudeCodeModelId("claude-sonnet-4-20250514", false) // returns "claude-sonnet-4-20250514" - */ -export function getClaudeCodeModelId(baseModelId: ClaudeCodeModelId, useVertex = false): string { - return useVertex ? convertModelNameForVertex(baseModelId) : baseModelId -} diff --git a/src/integrations/claude-code/__tests__/streaming-client.spec.ts b/src/integrations/claude-code/__tests__/streaming-client.spec.ts index 3ef3103417a..8ccb108827d 100644 --- a/src/integrations/claude-code/__tests__/streaming-client.spec.ts +++ b/src/integrations/claude-code/__tests__/streaming-client.spec.ts @@ -275,165 +275,9 @@ describe("Claude Code Streaming Client", () => { }) }) - test("should convert reasoning + thoughtSignature to thinking blocks for interleaved thinking (adjacent)", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValue({ done: true, value: undefined }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [ - { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, - { - role: "assistant", - content: [ - // Adjacent format (simple case) - { type: "reasoning", text: "Let me analyze this problem step by step..." }, - { - type: "thoughtSignature", - thoughtSignature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve", - }, - { type: "tool_use", id: "tool_123", name: "read_file", input: { path: "/test.txt" } }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: "tool_123", content: "file contents" }], - }, - ] as any, - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - const call = mockFetch.mock.calls[0] - const body = JSON.parse(call[1].body) - - // The reasoning + thoughtSignature should be converted to a proper thinking block - expect(body.messages[1].content).toContainEqual({ - type: "thinking", - thinking: "Let me analyze this problem step by step...", - signature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve", - }) - // tool_use should be preserved - expect(body.messages[1].content).toContainEqual({ - type: "tool_use", - id: "tool_123", - name: "read_file", - input: { path: "/test.txt" }, - }) - // tool_result should be preserved in user message - expect(body.messages[2].content).toContainEqual({ - type: "tool_result", - tool_use_id: "tool_123", - content: "file contents", - }) - }) - - test("should convert reasoning + thoughtSignature to thinking blocks when not adjacent (Task.ts format)", async () => { - // This matches the actual format from Task.ts where: - // - reasoning is PREPENDED (line 769) - // - text/tool_use blocks are in the middle - // - thoughtSignature is APPENDED (line 808) - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValue({ done: true, value: undefined }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [ - { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, - { - role: "assistant", - content: [ - // Task.ts format: reasoning at START, content in MIDDLE, thoughtSignature at END - { type: "reasoning", text: "Let me analyze this problem step by step..." }, - { type: "text", text: "I'll help you with that." }, - { type: "tool_use", id: "tool_123", name: "read_file", input: { path: "/test.txt" } }, - { - type: "thoughtSignature", - thoughtSignature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve", - }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: "tool_123", content: "file contents" }], - }, - ] as any, - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - const call = mockFetch.mock.calls[0] - const body = JSON.parse(call[1].body) - - // Check the ORDER of blocks - thinking should be FIRST (at reasoning position) - const assistantContent = body.messages[1].content - expect(assistantContent[0]).toEqual({ - type: "thinking", - thinking: "Let me analyze this problem step by step...", - signature: "WaUjzkypQ2mUEVM36O2TxuC06KN8xyfbJwyem2dw3URve", - }) - - // text block should be second - expect(assistantContent[1]).toMatchObject({ - type: "text", - text: "I'll help you with that.", - }) - - // tool_use should be third - expect(assistantContent[2]).toEqual({ - type: "tool_use", - id: "tool_123", - name: "read_file", - input: { path: "/test.txt" }, - }) - - // thoughtSignature should be filtered out (combined with reasoning) - expect(assistantContent.length).toBe(3) - expect(assistantContent.some((b: { type: string }) => b.type === "thoughtSignature")).toBe(false) - - // tool_result should be preserved in user message - expect(body.messages[2].content).toContainEqual({ - type: "tool_result", - tool_use_id: "tool_123", - content: "file contents", - }) - }) + // Dropped: conversion of internal `reasoning` + `thoughtSignature` blocks into + // Anthropic `thinking` blocks. The Claude Code integration now relies on the + // Anthropic-native `thinking` block format persisted by Task. test("should strip reasoning_details from messages (provider switching)", async () => { // When switching from OpenRouter/Roo to Claude Code, messages may have diff --git a/src/integrations/claude-code/streaming-client.ts b/src/integrations/claude-code/streaming-client.ts index 93628894043..b91c1842ba7 100644 --- a/src/integrations/claude-code/streaming-client.ts +++ b/src/integrations/claude-code/streaming-client.ts @@ -17,100 +17,7 @@ const VALID_ANTHROPIC_BLOCK_TYPES = new Set([ "document", ]) -/** - * Converts internal content blocks to proper Anthropic format for interleaved thinking. - * - * This handles the conversion of Roo Code's internal block types to Anthropic's API format: - * - `reasoning` blocks (with text) + `thoughtSignature` blocks -> `thinking` blocks with `signature` - * - * According to Anthropic docs: - * - During tool use, you must pass `thinking` blocks back to the API for the last assistant message - * - The `signature` field is used to verify that thinking blocks were generated by Claude - * - Include the complete unmodified block back to the API to maintain reasoning continuity - * - * IMPORTANT: In Task.ts, the message structure is: - * - reasoning block is PREPENDED (at the start) - * - text/tool_use blocks are in the middle - * - thoughtSignature block is APPENDED (at the end) - * - * So we need to: - * 1. Find the reasoning block and thoughtSignature block anywhere in the content - * 2. If both exist, combine them into a thinking block at the REASONING position - * 3. Remove the thoughtSignature block from its position - * 4. Pass through other valid blocks in their original positions - */ -/** - * Internal type for content blocks that may include non-Anthropic types like - * reasoning and thoughtSignature that are used internally by Roo Code. - */ -interface InternalContentBlock { - type: string - text?: string - thinking?: string - signature?: string - thoughtSignature?: string - summary?: unknown[] - [key: string]: unknown -} - -function convertToAnthropicThinkingBlocks( - content: Anthropic.Messages.ContentBlockParam[], -): Anthropic.Messages.ContentBlockParam[] { - // First pass: Find reasoning and thoughtSignature blocks (legacy format from older Task.ts) - // Note: New Task.ts stores thinking blocks directly with { type: "thinking", thinking, signature } - // which will pass through unchanged since "thinking" is in VALID_ANTHROPIC_BLOCK_TYPES - let reasoningIndex = -1 - let reasoningText: string | undefined - let thoughtSignatureIndex = -1 - let signature: string | undefined - - for (let i = 0; i < content.length; i++) { - const block = content[i] as unknown as InternalContentBlock - - // Handle legacy reasoning + thoughtSignature format - if (block.type === "reasoning" && typeof block.text === "string") { - reasoningIndex = i - reasoningText = block.text - } else if (block.type === "thoughtSignature" && typeof block.thoughtSignature === "string") { - thoughtSignatureIndex = i - signature = block.thoughtSignature - } - // Note: thinking blocks with { type: "thinking", thinking: "...", signature: "..." } - // are handled naturally since "thinking" is in VALID_ANTHROPIC_BLOCK_TYPES - } - - // Second pass: Build result with proper thinking block placement - const result: Anthropic.Messages.ContentBlockParam[] = [] - - for (let i = 0; i < content.length; i++) { - const block = content[i] as unknown as InternalContentBlock - - if (i === reasoningIndex) { - // At the reasoning position, insert a thinking block if we have both reasoning and signature (legacy format) - if (reasoningText && signature) { - result.push({ - type: "thinking", - thinking: reasoningText, - signature: signature, - } as unknown as Anthropic.Messages.ContentBlockParam) - } - // If we only have reasoning without signature, skip it (not valid for API) - continue - } - - if (i === thoughtSignatureIndex) { - // Skip the thoughtSignature block - it was combined with reasoning above - continue - } - - // Pass through valid Anthropic blocks (includes thinking blocks with proper format) - if (VALID_ANTHROPIC_BLOCK_TYPES.has(block.type)) { - result.push(content[i]) - } - } - - return result -} +type ContentBlockWithType = { type: string } /** * Filters out non-Anthropic content blocks from messages before sending to the API. @@ -150,19 +57,20 @@ function filterNonAnthropicBlocks(messages: Anthropic.Messages.MessageParam[]): continue } - // Convert reasoning + thoughtSignature to proper thinking blocks - // and filter out any invalid block types - const convertedContent = convertToAnthropicThinkingBlocks(content) + // Filter out invalid block types (allowlist) + const filteredContent = content.filter((block) => + VALID_ANTHROPIC_BLOCK_TYPES.has((block as ContentBlockWithType).type), + ) // If all content was filtered out, skip this message - if (convertedContent.length === 0) { + if (filteredContent.length === 0) { continue } // Return a clean message with only role and content (no extra fields) result.push({ role, - content: convertedContent, + content: filteredContent, }) } From 53733b8c35fac0bd5b96273d72affab04994dbe4 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Tue, 16 Dec 2025 21:27:23 -0700 Subject: [PATCH 14/16] Update src/api/providers/claude-code.ts Co-authored-by: roomote[bot] <219738659+roomote[bot]@users.noreply.github.com> --- src/api/providers/claude-code.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index 3808b88e727..d96c8633e3f 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -140,7 +140,7 @@ export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler { const model = this.getModel() // Validate that the model ID is a valid ClaudeCodeModelId - const modelId = model.id in claudeCodeModels ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId +const modelId = Object.hasOwn(claudeCodeModels, model.id) ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId // Generate user_id metadata in the format required by Claude Code API const userId = generateUserId(email || undefined) From 1e5ae814fbd4c4ca056e53131aa8c58f5f5d9436 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 17 Dec 2025 11:23:23 -0700 Subject: [PATCH 15/16] fix: use Object.hasOwn for safer property checks and update docstring - Replace 'in' operator with Object.hasOwn() in getModel() and completePrompt() to avoid prototype chain lookups for safer model ID validation - Update filterNonAnthropicBlocks() docstring to accurately reflect behavior (allowlist filtering only, no reasoning/thoughtSignature conversion) --- src/api/providers/claude-code.ts | 10 +++++++--- src/integrations/claude-code/streaming-client.ts | 5 ++--- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts index d96c8633e3f..cdd1cb3beb7 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -140,7 +140,9 @@ export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler { const model = this.getModel() // Validate that the model ID is a valid ClaudeCodeModelId -const modelId = Object.hasOwn(claudeCodeModels, model.id) ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId + const modelId = Object.hasOwn(claudeCodeModels, model.id) + ? (model.id as ClaudeCodeModelId) + : claudeCodeDefaultModelId // Generate user_id metadata in the format required by Claude Code API const userId = generateUserId(email || undefined) @@ -271,7 +273,7 @@ const modelId = Object.hasOwn(claudeCodeModels, model.id) ? (model.id as ClaudeC getModel(): { id: string; info: ModelInfo } { const modelId = this.options.apiModelId - if (modelId && modelId in claudeCodeModels) { + if (modelId && Object.hasOwn(claudeCodeModels, modelId)) { const id = modelId as ClaudeCodeModelId return { id, info: { ...claudeCodeModels[id] } } } @@ -313,7 +315,9 @@ const modelId = Object.hasOwn(claudeCodeModels, model.id) ? (model.id as ClaudeC const model = this.getModel() // Validate that the model ID is a valid ClaudeCodeModelId - const modelId = model.id in claudeCodeModels ? (model.id as ClaudeCodeModelId) : claudeCodeDefaultModelId + const modelId = Object.hasOwn(claudeCodeModels, model.id) + ? (model.id as ClaudeCodeModelId) + : claudeCodeDefaultModelId // Generate user_id metadata in the format required by Claude Code API const userId = generateUserId(email || undefined) diff --git a/src/integrations/claude-code/streaming-client.ts b/src/integrations/claude-code/streaming-client.ts index b91c1842ba7..a4f05af9448 100644 --- a/src/integrations/claude-code/streaming-client.ts +++ b/src/integrations/claude-code/streaming-client.ts @@ -21,12 +21,11 @@ type ContentBlockWithType = { type: string } /** * Filters out non-Anthropic content blocks from messages before sending to the API. - * Also converts internal reasoning + thoughtSignature blocks to proper Anthropic thinking blocks. * * Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept. * This automatically filters out: - * - Internal "reasoning" blocks (Roo Code's internal representation) - unless combined with thoughtSignature - * - Gemini's "thoughtSignature" blocks (converted to thinking blocks when paired with reasoning) + * - Internal "reasoning" blocks (Roo Code's internal representation) + * - Gemini's "thoughtSignature" blocks * - Any other unknown block types * * IMPORTANT: This function also strips message-level fields that are not part of the Anthropic API: From 095daee49c7809fc93339403cb6979e3c405e8a7 Mon Sep 17 00:00:00 2001 From: Hannes Rudolph Date: Wed, 17 Dec 2025 12:38:03 -0700 Subject: [PATCH 16/16] docs: clarify filterNonAnthropicBlocks performs filtering only, no conversion --- src/integrations/claude-code/streaming-client.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/src/integrations/claude-code/streaming-client.ts b/src/integrations/claude-code/streaming-client.ts index a4f05af9448..b864995f2cd 100644 --- a/src/integrations/claude-code/streaming-client.ts +++ b/src/integrations/claude-code/streaming-client.ts @@ -22,9 +22,12 @@ type ContentBlockWithType = { type: string } /** * Filters out non-Anthropic content blocks from messages before sending to the API. * + * NOTE: This function performs FILTERING ONLY - no type conversion is performed. + * Blocks are either kept as-is or removed entirely based on the allowlist. + * * Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept. * This automatically filters out: - * - Internal "reasoning" blocks (Roo Code's internal representation) + * - Internal "reasoning" blocks (Roo Code's internal representation) - NOT converted to "thinking" * - Gemini's "thoughtSignature" blocks * - Any other unknown block types * @@ -32,7 +35,7 @@ type ContentBlockWithType = { type: string } * - `reasoning_details` (added by OpenRouter/Roo providers for Gemini/OpenAI reasoning) * - Any other non-standard fields added by other providers * - * We preserve ALL thinking blocks for these reasons: + * We preserve ALL "thinking" blocks (Anthropic's native extended thinking format) for these reasons: * 1. Rewind functionality - users need to be able to go back in conversation history * 2. Claude Opus 4.5+ preserves thinking blocks by default (per Anthropic docs) * 3. Interleaved thinking requires thinking blocks to be passed back for tool use continuations