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/packages/types/src/providers/__tests__/claude-code.spec.ts b/packages/types/src/providers/__tests__/claude-code.spec.ts index a73912d44ac..5ed66209a53 100644 --- a/packages/types/src/providers/__tests__/claude-code.spec.ts +++ b/packages/types/src/providers/__tests__/claude-code.spec.ts @@ -1,40 +1,46 @@ -import { convertModelNameForVertex, getClaudeCodeModelId } from "../claude-code.js" +import { 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") +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 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 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 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") + 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") }) -}) -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") + 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 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 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 default to useVertex false when parameter not provided", () => { - expect(getClaudeCodeModelId("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-20250514") + 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 988617e515f..28863675d07 100644 --- a/packages/types/src/providers/claude-code.ts +++ b/packages/types/src/providers/claude-code.ts @@ -1,154 +1,160 @@ import type { ModelInfo } from "../model.js" -import { anthropicModels } from "./anthropic.js" - -// Regex pattern to match 8-digit date at the end of model names -const VERTEX_DATE_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) + * Rate limit information from Claude Code API */ -export function convertModelNameForVertex(modelName: string): string { - // Convert hyphen-date format to @date format for Vertex AI - return modelName.replace(VERTEX_DATE_PATTERN, "@$1") +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 } -// Claude Code +// Regex pattern to strip date suffix from model names +const DATE_SUFFIX_PATTERN = /-\d{8}$/ + +// 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-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-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-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 + +// 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 /** - * Gets the appropriate model ID based on whether Vertex AI is being used. + * 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 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 + * @param modelId - The model ID to normalize (may be a legacy format) + * @returns A valid ClaudeCodeModelId, or the original ID if already valid * * @example - * getClaudeCodeModelId("claude-sonnet-4-20250514", true) // returns "claude-sonnet-4@20250514" - * getClaudeCodeModelId("claude-sonnet-4-20250514", false) // returns "claude-sonnet-4-20250514" + * 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 getClaudeCodeModelId(baseModelId: ClaudeCodeModelId, useVertex = false): string { - return useVertex ? convertModelNameForVertex(baseModelId) : baseModelId +export function normalizeClaudeCodeModelId(modelId: string): ClaudeCodeModelId { + // If already a valid model ID, return as-is + // Use Object.hasOwn() instead of 'in' operator to avoid matching inherited properties like 'toString' + if (Object.hasOwn(claudeCodeModels, modelId)) { + 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 (Object.hasOwn(claudeCodeModels, withoutDate)) { + 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 } -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-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-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, - }, -} as const satisfies Record +/** + * 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 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) }) }) diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts index 41375432027..5b5bdca65ae 100644 --- a/src/api/providers/__tests__/claude-code.spec.ts +++ b/src/api/providers/__tests__/claude-code.spec.ts @@ -1,21 +1,31 @@ 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 mockGetEmail = vi.mocked(claudeCodeOAuthManager.getEmail) +const mockCreateStreamingMessage = vi.mocked(createStreamingMessage) describe("ClaudeCodeHandler", () => { let handler: ClaudeCodeHandler @@ -23,22 +33,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 +55,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 filter messages and call runClaudeCode", async () => { + test("should throw error when not authenticated", 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(null) + + const stream = handler.createMessage(systemPrompt, messages) + const iterator = stream[Symbol.asyncIterator]() + + await expect(iterator.next()).rejects.toThrow(/not authenticated/i) + }) + + 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" }] + + 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 +109,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 (32768 for claude-sonnet-4-5) + expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ + accessToken: "test-access-token", + model: "claude-sonnet-4-5", + systemPrompt, + messages, + maxTokens: 32768, // model's maxTokens from claudeCodeModels definition + 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") - // Verify runClaudeCode was called with filtered messages - expect(mockRunClaudeCode).toHaveBeenCalledWith({ + // 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 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: 32768, // model maxTokens from claudeCodeModels definition + 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 (32768 for claude-sonnet-4-5) + 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: 32768, // model's maxTokens from claudeCodeModels definition + 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 +235,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 +269,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 +305,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 +327,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 +384,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 +420,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 +472,126 @@ 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", + }) + + expect(mockCreateStreamingMessage).toHaveBeenCalledWith( + expect.objectContaining({ + accessToken: "refreshed-token", + }), + ) + }) + + 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()) - consoleSpy.mockRestore() + 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 a15512d65f4..cdd1cb3beb7 100644 --- a/src/api/providers/claude-code.ts +++ b/src/api/providers/claude-code.ts @@ -1,171 +1,286 @@ 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 { type ApiHandler, ApiHandlerCreateMessageMetadata, type SingleCompletionHandler } 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, + } + } -export class ClaudeCodeHandler implements ApiHandler { + return { type: "auto", disable_parallel_tool_use: disableParallelToolUse } +} + +export class ClaudeCodeHandler implements ApiHandler, SingleCompletionHandler { 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 modelId = Object.hasOwn(claudeCodeModels, model.id) + ? (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) } } } 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 - 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] }, } } @@ -176,11 +291,68 @@ export class ClaudeCodeHandler implements ApiHandler { return countTokens(content, { useWorker: true }) } - private attemptParse(str: string) { - try { - return JSON.parse(str) - } catch (err) { - return null + /** + * 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 = 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) + + // 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 } } 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/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/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, }, }), ) 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/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__/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__/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 new file mode 100644 index 00000000000..8ccb108827d --- /dev/null +++ b/src/integrations/claude-code/__tests__/streaming-client.spec.ts @@ -0,0 +1,585 @@ +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).toMatch(/^Roo-Code\/\d+\.\d+\.\d+$/) + }) + }) + + 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", + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, + }), + }), + ) + }) + + 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", + }) + }) + + // 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 + // 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/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 new file mode 100644 index 00000000000..b864995f2cd --- /dev/null +++ b/src/integrations/claude-code/streaming-client.ts @@ -0,0 +1,759 @@ +import type { Anthropic } from "@anthropic-ai/sdk" +import type { ClaudeCodeRateLimitInfo } from "@roo-code/types" +import { Package } from "../../shared/package" + +/** + * 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", +]) + +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) - NOT converted to "thinking" + * - 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: + * - `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 (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 + * + * 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 + } + + // 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 (filteredContent.length === 0) { + continue + } + + // Return a clean message with only role and content (no extra fields) + result.push({ + role, + content: filteredContent, + }) + } + + 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: `Roo-Code/${Package.version}`, +} as const + +/** + * 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 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", + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, + } + + // 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 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(","), + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, + } + + // 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/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 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/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 } 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..8cb2e2d4f48 100644 --- a/webview-ui/src/components/chat/ErrorRow.tsx +++ b/webview-ui/src/components/chat/ErrorRow.tsx @@ -223,10 +223,23 @@ 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", + values: { section: "providers" }, + }) + } 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/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/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 }) }) 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 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