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

✓ Authentication Successful

+

You can close this window and return to VS Code.

+ + +`) + + this.pendingAuth = null + server.close() + resolve(credentials) + } catch (exchangeError) { + res.writeHead(500) + res.end(`Token exchange failed: ${exchangeError}`) + reject(exchangeError) + server.close() + } + } catch (err) { + res.writeHead(500) + res.end("Internal server error") + reject(err) + server.close() + } + }) + + server.on("error", (err: NodeJS.ErrnoException) => { + this.pendingAuth = null + if (err.code === "EADDRINUSE") { + reject( + new Error( + `Port ${CLAUDE_CODE_OAUTH_CONFIG.callbackPort} is already in use. ` + + `Please close any other applications using this port and try again.`, + ), + ) + } else { + reject(err) + } + }) + + // Set a timeout for the callback + const timeout = setTimeout( + () => { + server.close() + reject(new Error("Authentication timed out")) + }, + 5 * 60 * 1000, + ) // 5 minutes + + server.listen(CLAUDE_CODE_OAUTH_CONFIG.callbackPort, () => { + if (this.pendingAuth) { + this.pendingAuth.server = server + } + }) + + // Clear timeout when server closes + server.on("close", () => { + clearTimeout(timeout) + }) + }) + } + + /** + * Cancel any pending authorization flow + */ + cancelAuthorizationFlow(): void { + if (this.pendingAuth?.server) { + this.pendingAuth.server.close() + } + this.pendingAuth = null + } + + /** + * Get the current credentials (for display purposes) + */ + getCredentials(): ClaudeCodeCredentials | null { + return this.credentials + } +} + +// Singleton instance +export const claudeCodeOAuthManager = new ClaudeCodeOAuthManager() diff --git a/src/integrations/claude-code/streaming-client.ts b/src/integrations/claude-code/streaming-client.ts new file mode 100644 index 0000000000..7666480669 --- /dev/null +++ b/src/integrations/claude-code/streaming-client.ts @@ -0,0 +1,883 @@ +import type { Anthropic } from "@anthropic-ai/sdk" +import type { ClaudeCodeRateLimitInfo } from "@roo-code/types" +import * as os from "os" + +/** + * Set of content block types that are valid for Anthropic API. + * Only these types will be passed through to the API. + * See: https://docs.anthropic.com/en/api/messages + */ +const VALID_ANTHROPIC_BLOCK_TYPES = new Set([ + "text", + "image", + "tool_use", + "tool_result", + "thinking", + "redacted_thinking", + "document", +]) + +/** + * Converts internal content blocks to proper Anthropic format for interleaved thinking. + * + * This handles the conversion of Roo Code's internal block types to Anthropic's API format: + * - `reasoning` blocks (with text) + `thoughtSignature` blocks -> `thinking` blocks with `signature` + * + * According to Anthropic docs: + * - During tool use, you must pass `thinking` blocks back to the API for the last assistant message + * - The `signature` field is used to verify that thinking blocks were generated by Claude + * - Include the complete unmodified block back to the API to maintain reasoning continuity + * + * IMPORTANT: In Task.ts, the message structure is: + * - reasoning block is PREPENDED (at the start) + * - text/tool_use blocks are in the middle + * - thoughtSignature block is APPENDED (at the end) + * + * So we need to: + * 1. Find the reasoning block and thoughtSignature block anywhere in the content + * 2. If both exist, combine them into a thinking block at the REASONING position + * 3. Remove the thoughtSignature block from its position + * 4. Pass through other valid blocks in their original positions + */ +/** + * Internal type for content blocks that may include non-Anthropic types like + * reasoning and thoughtSignature that are used internally by Roo Code. + */ +interface InternalContentBlock { + type: string + text?: string + thinking?: string + signature?: string + thoughtSignature?: string + summary?: unknown[] + [key: string]: unknown +} + +function convertToAnthropicThinkingBlocks( + content: Anthropic.Messages.ContentBlockParam[], +): Anthropic.Messages.ContentBlockParam[] { + // First pass: Find reasoning and thoughtSignature blocks (legacy format from older Task.ts) + // Note: New Task.ts stores thinking blocks directly with { type: "thinking", thinking, signature } + // which will pass through unchanged since "thinking" is in VALID_ANTHROPIC_BLOCK_TYPES + let reasoningIndex = -1 + let reasoningText: string | undefined + let thoughtSignatureIndex = -1 + let signature: string | undefined + + for (let i = 0; i < content.length; i++) { + const block = content[i] as unknown as InternalContentBlock + + // Handle legacy reasoning + thoughtSignature format + if (block.type === "reasoning" && typeof block.text === "string") { + reasoningIndex = i + reasoningText = block.text + } else if (block.type === "thoughtSignature" && typeof block.thoughtSignature === "string") { + thoughtSignatureIndex = i + signature = block.thoughtSignature + } + // Note: thinking blocks with { type: "thinking", thinking: "...", signature: "..." } + // are handled naturally since "thinking" is in VALID_ANTHROPIC_BLOCK_TYPES + } + + // Second pass: Build result with proper thinking block placement + const result: Anthropic.Messages.ContentBlockParam[] = [] + + for (let i = 0; i < content.length; i++) { + const block = content[i] as unknown as InternalContentBlock + + if (i === reasoningIndex) { + // At the reasoning position, insert a thinking block if we have both reasoning and signature (legacy format) + if (reasoningText && signature) { + result.push({ + type: "thinking", + thinking: reasoningText, + signature: signature, + } as unknown as Anthropic.Messages.ContentBlockParam) + } + // If we only have reasoning without signature, skip it (not valid for API) + continue + } + + if (i === thoughtSignatureIndex) { + // Skip the thoughtSignature block - it was combined with reasoning above + continue + } + + // Pass through valid Anthropic blocks (includes thinking blocks with proper format) + if (VALID_ANTHROPIC_BLOCK_TYPES.has(block.type)) { + result.push(content[i]) + } + } + + return result +} + +/** + * Filters out non-Anthropic content blocks from messages before sending to the API. + * Also converts internal reasoning + thoughtSignature blocks to proper Anthropic thinking blocks. + * + * Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept. + * This automatically filters out: + * - Internal "reasoning" blocks (Roo Code's internal representation) - unless combined with thoughtSignature + * - Gemini's "thoughtSignature" blocks (converted to thinking blocks when paired with reasoning) + * - Any other unknown block types + * + * IMPORTANT: This function also strips message-level fields that are not part of the Anthropic API: + * - `reasoning_details` (added by OpenRouter/Roo providers for Gemini/OpenAI reasoning) + * - Any other non-standard fields added by other providers + * + * We preserve ALL thinking blocks for these reasons: + * 1. Rewind functionality - users need to be able to go back in conversation history + * 2. Claude Opus 4.5+ preserves thinking blocks by default (per Anthropic docs) + * 3. Interleaved thinking requires thinking blocks to be passed back for tool use continuations + * + * The API will handle thinking blocks appropriately based on the model: + * - Claude Opus 4.5+: thinking blocks preserved (enables cache optimization) + * - Older models: thinking blocks stripped from prior turns automatically + */ +function filterNonAnthropicBlocks(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { + const result: Anthropic.Messages.MessageParam[] = [] + + for (const message of messages) { + // Extract ONLY the standard Anthropic message fields (role, content) + // This strips out any extra fields like `reasoning_details` that other providers + // may have added to the messages (e.g., OpenRouter adds reasoning_details for Gemini/o-series) + const { role, content } = message + + if (typeof content === "string") { + // Return a clean message with only role and content + result.push({ role, content }) + continue + } + + // Convert reasoning + thoughtSignature to proper thinking blocks + // and filter out any invalid block types + const convertedContent = convertToAnthropicThinkingBlocks(content) + + // If all content was filtered out, skip this message + if (convertedContent.length === 0) { + continue + } + + // Return a clean message with only role and content (no extra fields) + result.push({ + role, + content: convertedContent, + }) + } + + return result +} + +/** + * Adds cache_control breakpoints to the last two user messages for prompt caching. + * This follows Anthropic's recommended pattern: + * - Cache the system prompt (handled separately) + * - Cache the last text block of the second-to-last user message + * - Cache the last text block of the last user message + * + * According to Anthropic docs: + * - System prompts and tools remain cached despite thinking parameter changes + * - Message cache breakpoints are invalidated when thinking parameters change + * - When using extended thinking, thinking blocks from previous turns are stripped from context + */ +function addMessageCacheBreakpoints(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { + // Find indices of user messages + const userMsgIndices = messages.reduce( + (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), + [] as number[], + ) + + const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 + const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 + + return messages.map((message, index) => { + // Only add cache control to the last two user messages + if (index !== lastUserMsgIndex && index !== secondLastUserMsgIndex) { + return message + } + + // Handle string content + if (typeof message.content === "string") { + return { + ...message, + content: [ + { + type: "text" as const, + text: message.content, + cache_control: { type: "ephemeral" as const }, + }, + ], + } + } + + // Handle array content - add cache_control to the last text block + const contentWithCache = message.content.map((block, blockIndex) => { + // Find the last text block index + let lastTextIndex = -1 + for (let i = message.content.length - 1; i >= 0; i--) { + if ((message.content[i] as { type: string }).type === "text") { + lastTextIndex = i + break + } + } + + // Only add cache_control to text blocks (the last one specifically) + if (blockIndex === lastTextIndex && (block as { type: string }).type === "text") { + const textBlock = block as { type: "text"; text: string } + return { + type: "text" as const, + text: textBlock.text, + cache_control: { type: "ephemeral" as const }, + } + } + + return block + }) + + return { + ...message, + content: contentWithCache, + } + }) +} + +// API Configuration +export const CLAUDE_CODE_API_CONFIG = { + endpoint: "https://api.anthropic.com/v1/messages", + version: "2023-06-01", + defaultBetas: [ + "prompt-caching-2024-07-31", + "claude-code-20250219", + "oauth-2025-04-20", + "interleaved-thinking-2025-05-14", + "fine-grained-tool-streaming-2025-05-14", + ], + userAgent: "claude-cli/1.0.83 (external, cli)", +} as const + +/** + * Get Claude Code CLI headers - includes Stainless SDK headers and special CLI headers + */ +function getClaudeCodeCliHeaders(): Record { + const arch = os.arch() + const platform = os.platform() + + // Map platform to OS name - must match Claude CLI format exactly + const osMap: Record = { + darwin: "MacOS", // Note: Claude CLI uses "MacOS" not "macOS" + linux: "Linux", + win32: "Windows", + } + + return { + // Claude Code specific headers + "Anthropic-Dangerous-Direct-Browser-Access": "true", + "X-App": "cli", + // Stainless SDK headers as used by Claude CLI + "X-Stainless-Lang": "js", + "X-Stainless-Package-Version": "0.55.1", + "X-Stainless-OS": osMap[platform] || platform, + "X-Stainless-Arch": arch, + "X-Stainless-Runtime": "node", + "X-Stainless-Runtime-Version": "v24.3.0", + "X-Stainless-Helper-Method": "stream", + "X-Stainless-Timeout": "60", + Connection: "keep-alive", + } +} + +/** + * SSE Event types from Anthropic streaming API + */ +export type SSEEventType = + | "message_start" + | "content_block_start" + | "content_block_delta" + | "content_block_stop" + | "message_delta" + | "message_stop" + | "ping" + | "error" + +export interface SSEEvent { + event: SSEEventType + data: unknown +} + +/** + * Thinking configuration for extended thinking mode + */ +export type ThinkingConfig = + | { + type: "enabled" + budget_tokens: number + } + | { + type: "disabled" + } + +/** + * Stream message request options + */ +export interface StreamMessageOptions { + accessToken: string + model: string + systemPrompt: string + messages: Anthropic.Messages.MessageParam[] + maxTokens?: number + thinking?: ThinkingConfig + tools?: Anthropic.Messages.Tool[] + toolChoice?: Anthropic.Messages.ToolChoice + metadata?: { + user_id?: string + } + signal?: AbortSignal +} + +/** + * SSE Parser state that persists across chunks + * This is necessary because SSE events can be split across multiple chunks + */ +interface SSEParserState { + buffer: string + currentEvent: string | null + currentData: string[] +} + +/** + * Creates initial SSE parser state + */ +function createSSEParserState(): SSEParserState { + return { + buffer: "", + currentEvent: null, + currentData: [], + } +} + +/** + * Parses SSE lines from a text chunk + * Returns parsed events and updates the state for the next chunk + * + * The state persists across chunks to handle events that span multiple chunks: + * - buffer: incomplete line from previous chunk + * - currentEvent: event type if we've seen "event:" but not the complete event + * - currentData: accumulated data lines for the current event + */ +function parseSSEChunk(chunk: string, state: SSEParserState): { events: SSEEvent[]; state: SSEParserState } { + const events: SSEEvent[] = [] + const lines = (state.buffer + chunk).split("\n") + + // Start with the accumulated state + let currentEvent = state.currentEvent + let currentData = [...state.currentData] + let remaining = "" + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // If this is the last line and doesn't end with newline, it might be incomplete + if (i === lines.length - 1 && !chunk.endsWith("\n") && line !== "") { + remaining = line + continue + } + + // Empty line signals end of event + if (line === "") { + if (currentEvent && currentData.length > 0) { + try { + const dataStr = currentData.join("\n") + const data = dataStr === "[DONE]" ? null : JSON.parse(dataStr) + events.push({ + event: currentEvent as SSEEventType, + data, + }) + } catch { + // Skip malformed events + console.error("[claude-code-streaming] Failed to parse SSE data:", currentData.join("\n")) + } + } + currentEvent = null + currentData = [] + continue + } + + // Parse event type + if (line.startsWith("event: ")) { + currentEvent = line.slice(7) + continue + } + + // Parse data + if (line.startsWith("data: ")) { + currentData.push(line.slice(6)) + continue + } + } + + // Return updated state for next chunk + return { + events, + state: { + buffer: remaining, + currentEvent, + currentData, + }, + } +} + +/** + * Stream chunk types that the handler can yield + */ +export interface StreamTextChunk { + type: "text" + text: string +} + +export interface StreamReasoningChunk { + type: "reasoning" + text: string +} + +/** + * A complete thinking block with signature, used for tool use continuations. + * According to Anthropic docs: + * - During tool use, you must pass thinking blocks back to the API for the last assistant message + * - Include the complete unmodified block back to the API to maintain reasoning continuity + * - The signature field is used to verify that thinking blocks were generated by Claude + */ +export interface StreamThinkingCompleteChunk { + type: "thinking_complete" + index: number + thinking: string + signature: string +} + +export interface StreamToolCallPartialChunk { + type: "tool_call_partial" + index: number + id?: string + name?: string + arguments?: string +} + +export interface StreamUsageChunk { + type: "usage" + inputTokens: number + outputTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + totalCost?: number +} + +export interface StreamErrorChunk { + type: "error" + error: string +} + +export type StreamChunk = + | StreamTextChunk + | StreamReasoningChunk + | StreamThinkingCompleteChunk + | StreamToolCallPartialChunk + | StreamUsageChunk + | StreamErrorChunk + +/** + * Creates a streaming message request to the Anthropic API using OAuth + */ +export async function* createStreamingMessage(options: StreamMessageOptions): AsyncGenerator { + const { accessToken, model, systemPrompt, messages, maxTokens, thinking, tools, toolChoice, metadata, signal } = + options + + // Filter out non-Anthropic blocks before processing + const sanitizedMessages = filterNonAnthropicBlocks(messages) + + // Add cache breakpoints to the last two user messages + // According to Anthropic docs: + // - System prompts and tools remain cached despite thinking parameter changes + // - Message cache breakpoints are invalidated when thinking parameters change + // - We cache the last two user messages for optimal cache hit rates + const messagesWithCache = addMessageCacheBreakpoints(sanitizedMessages) + + // Build request body - match Claude Code format exactly + const body: Record = { + model, + stream: true, + messages: messagesWithCache, + } + + // Only include max_tokens if explicitly provided + if (maxTokens !== undefined) { + body.max_tokens = maxTokens + } + + // Add thinking configuration for extended thinking mode + if (thinking) { + body.thinking = thinking + } + + // System prompt as array of content blocks (Claude Code format) + // Prepend Claude Code branding as required by the API + // Add cache_control to the last text block for prompt caching + // System prompt caching is preserved even when thinking parameters change + body.system = [ + { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, + ...(systemPrompt ? [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }] : []), + ] + + // Metadata with user_id is required for Claude Code + if (metadata) { + body.metadata = metadata + } + + if (tools && tools.length > 0) { + body.tools = tools + // Default tool_choice to "auto" when tools are provided (as per spec example) + body.tool_choice = toolChoice || { type: "auto" } + } else if (toolChoice) { + body.tool_choice = toolChoice + } + + // Build headers - match Claude Code CLI exactly + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, + "Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","), + Accept: "text/event-stream", + "Accept-Encoding": "gzip, deflate, br, zstd", + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, + ...getClaudeCodeCliHeaders(), + } + + // Make the request + const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal, + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `API request failed: ${response.status} ${response.statusText}` + try { + const errorJson = JSON.parse(errorText) + if (errorJson.error?.message) { + errorMessage = errorJson.error.message + } + } catch { + if (errorText) { + errorMessage += ` - ${errorText}` + } + } + yield { type: "error", error: errorMessage } + return + } + + if (!response.body) { + yield { type: "error", error: "No response body" } + return + } + + // Track usage across events + let totalInputTokens = 0 + let totalOutputTokens = 0 + let cacheReadTokens = 0 + let cacheWriteTokens = 0 + + // Track content blocks by index for proper assembly + // This is critical for interleaved thinking - we need to capture complete thinking blocks + // with their signatures so they can be passed back to the API for tool use continuations + const contentBlocks: Map< + number, + { + type: string + text: string + signature?: string + id?: string + name?: string + arguments?: string + } + > = new Map() + + // Read the stream + const reader = response.body.getReader() + const decoder = new TextDecoder() + let sseState = createSSEParserState() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + const result = parseSSEChunk(chunk, sseState) + sseState = result.state + const events = result.events + + for (const event of events) { + const eventData = event.data as Record | null + + if (!eventData) { + continue + } + + switch (event.event) { + case "message_start": { + const message = eventData.message as Record + if (!message) { + break + } + const usage = message.usage as Record | undefined + if (usage) { + totalInputTokens += usage.input_tokens || 0 + totalOutputTokens += usage.output_tokens || 0 + cacheReadTokens += usage.cache_read_input_tokens || 0 + cacheWriteTokens += usage.cache_creation_input_tokens || 0 + } + break + } + + case "content_block_start": { + const contentBlock = eventData.content_block as Record + const index = eventData.index as number + + if (contentBlock) { + switch (contentBlock.type) { + case "text": + // Initialize text block tracking + contentBlocks.set(index, { + type: "text", + text: (contentBlock.text as string) || "", + }) + if (contentBlock.text) { + yield { type: "text", text: contentBlock.text as string } + } + break + case "thinking": + // Initialize thinking block tracking - critical for interleaved thinking + // We need to accumulate the text and capture the signature + contentBlocks.set(index, { + type: "thinking", + text: (contentBlock.thinking as string) || "", + }) + if (contentBlock.thinking) { + yield { type: "reasoning", text: contentBlock.thinking as string } + } + break + case "tool_use": + contentBlocks.set(index, { + type: "tool_use", + text: "", + id: contentBlock.id as string, + name: contentBlock.name as string, + arguments: "", + }) + yield { + type: "tool_call_partial", + index, + id: contentBlock.id as string, + name: contentBlock.name as string, + arguments: undefined, + } + break + } + } + break + } + + case "content_block_delta": { + const delta = eventData.delta as Record + const index = eventData.index as number + const block = contentBlocks.get(index) + + if (delta) { + switch (delta.type) { + case "text_delta": + if (delta.text) { + // Accumulate text + if (block && block.type === "text") { + block.text += delta.text as string + } + yield { type: "text", text: delta.text as string } + } + break + case "thinking_delta": + if (delta.thinking) { + // Accumulate thinking text + if (block && block.type === "thinking") { + block.text += delta.thinking as string + } + yield { type: "reasoning", text: delta.thinking as string } + } + break + case "signature_delta": + // Capture the signature for the thinking block + // This is critical for interleaved thinking - the signature + // must be included when passing thinking blocks back to the API + if (delta.signature && block && block.type === "thinking") { + block.signature = delta.signature as string + } + break + case "input_json_delta": + if (block && block.type === "tool_use") { + block.arguments = (block.arguments || "") + (delta.partial_json as string) + } + yield { + type: "tool_call_partial", + index, + id: undefined, + name: undefined, + arguments: delta.partial_json as string, + } + break + } + } + break + } + + case "content_block_stop": { + // When a content block completes, emit complete thinking blocks + // This enables the caller to preserve them for tool use continuations + const index = eventData.index as number + const block = contentBlocks.get(index) + + if (block && block.type === "thinking" && block.signature) { + // Emit the complete thinking block with signature + // This is required for interleaved thinking with tool use + yield { + type: "thinking_complete", + index, + thinking: block.text, + signature: block.signature, + } + } + break + } + + case "message_delta": { + const usage = eventData.usage as Record | undefined + if (usage && usage.output_tokens !== undefined) { + // output_tokens in message_delta is the running total, not a delta + // So we replace rather than add + totalOutputTokens = usage.output_tokens + } + break + } + + case "message_stop": { + // Yield final usage chunk + yield { + type: "usage", + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + } + break + } + + case "error": { + const errorData = eventData.error as Record + yield { + type: "error", + error: (errorData?.message as string) || "Unknown streaming error", + } + break + } + } + } + } + } finally { + reader.releaseLock() + } +} + +/** + * Parse rate limit headers from a response into a structured format + */ +function parseRateLimitHeaders(headers: Headers): ClaudeCodeRateLimitInfo { + const getHeader = (name: string): string | null => headers.get(name) + const parseFloat = (val: string | null): number => (val ? Number.parseFloat(val) : 0) + const parseInt = (val: string | null): number => (val ? Number.parseInt(val, 10) : 0) + + return { + fiveHour: { + status: getHeader("anthropic-ratelimit-unified-5h-status") || "unknown", + utilization: parseFloat(getHeader("anthropic-ratelimit-unified-5h-utilization")), + resetTime: parseInt(getHeader("anthropic-ratelimit-unified-5h-reset")), + }, + weekly: { + status: getHeader("anthropic-ratelimit-unified-7d_sonnet-status") || "unknown", + utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d_sonnet-utilization")), + resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d_sonnet-reset")), + }, + weeklyUnified: { + status: getHeader("anthropic-ratelimit-unified-7d-status") || "unknown", + utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d-utilization")), + resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d-reset")), + }, + representativeClaim: getHeader("anthropic-ratelimit-unified-representative-claim") || undefined, + overage: { + status: getHeader("anthropic-ratelimit-unified-overage-status") || "unknown", + disabledReason: getHeader("anthropic-ratelimit-unified-overage-disabled-reason") || undefined, + }, + fallbackPercentage: parseFloat(getHeader("anthropic-ratelimit-unified-fallback-percentage")) || undefined, + organizationId: getHeader("anthropic-organization-id") || undefined, + fetchedAt: Date.now(), + } +} + +/** + * Fetch rate limit information by making a minimal API call + * Uses a small request to get the response headers containing rate limit data + */ +export async function fetchRateLimitInfo(accessToken: string): Promise { + // Build minimal request body - use haiku for speed and lowest cost + const body = { + model: "claude-haiku-4-5", + max_tokens: 1, + system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }], + messages: [{ role: "user", content: "hi" }], + } + + // Build headers - match Claude Code CLI exactly + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, + "Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","), + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, + ...getClaudeCodeCliHeaders(), + } + + // Make the request + const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `API request failed: ${response.status} ${response.statusText}` + try { + const errorJson = JSON.parse(errorText) + if (errorJson.error?.message) { + errorMessage = errorJson.error.message + } + } catch { + if (errorText) { + errorMessage += ` - ${errorText}` + } + } + throw new Error(errorMessage) + } + + // Parse rate limit headers from the response + return parseRateLimitHeaders(response.headers) +} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 5d40f2ef09..9d63d871e2 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 @@ -358,6 +359,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 eb109166c8..a2ae6b199d 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -127,6 +127,8 @@ export interface WebviewMessage { | "cloudLandingPageSignIn" | "rooCloudSignOut" | "rooCloudManualUrl" + | "claudeCodeSignIn" + | "claudeCodeSignOut" | "switchOrganization" | "condenseTaskContextRequest" | "requestIndexingStatus" @@ -176,6 +178,7 @@ export interface WebviewMessage { | "browserPanelDidLaunch" | "openDebugApiHistory" | "openDebugUiHistory" + | "requestClaudeCodeRateLimits" text?: string editedMessageContent?: string tab?: "settings" | "history" | "mcp" | "modes" | "chat" | "marketplace" | "cloud" diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 18162804f3..62172f885b 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 167ce85e3d..e043f68f83 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 b6a9201bcd..4fe4c02dda 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 706c51339f..87072a9b97 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 0000000000..9b152c2717 --- /dev/null +++ b/webview-ui/src/components/settings/providers/ClaudeCodeRateLimitDashboard.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useState, useCallback } from "react" +import type { ClaudeCodeRateLimitInfo } from "@roo-code/types" +import { vscode } from "@src/utils/vscode" + +interface ClaudeCodeRateLimitDashboardProps { + isAuthenticated: boolean +} + +/** + * Formats a Unix timestamp reset time into a human-readable duration + */ +function formatResetTime(resetTimestamp: number): string { + if (!resetTimestamp) return "N/A" + + const now = Date.now() / 1000 // Current time in seconds + const diff = resetTimestamp - now + + if (diff <= 0) return "Now" + + const hours = Math.floor(diff / 3600) + const minutes = Math.floor((diff % 3600) / 60) + + if (hours > 24) { + const days = Math.floor(hours / 24) + const remainingHours = hours % 24 + return `${days}d ${remainingHours}h` + } + + if (hours > 0) { + return `${hours}h ${minutes}m` + } + + return `${minutes}m` +} + +/** + * Formats utilization as a percentage + */ +function formatUtilization(utilization: number): string { + return `${(utilization * 100).toFixed(1)}%` +} + +/** + * Progress bar component for displaying usage + */ +const UsageProgressBar: React.FC<{ utilization: number; label: string }> = ({ utilization, label }) => { + const percentage = Math.min(utilization * 100, 100) + const isWarning = percentage >= 70 + const isCritical = percentage >= 90 + + return ( +
+
{label}
+
+
+
+
+ ) +} + +export const ClaudeCodeRateLimitDashboard: React.FC = ({ isAuthenticated }) => { + const [rateLimits, setRateLimits] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchRateLimits = useCallback(() => { + if (!isAuthenticated) { + setRateLimits(null) + setError(null) + return + } + + setIsLoading(true) + setError(null) + vscode.postMessage({ type: "requestClaudeCodeRateLimits" }) + }, [isAuthenticated]) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "claudeCodeRateLimits") { + setIsLoading(false) + if (message.error) { + setError(message.error) + setRateLimits(null) + } else if (message.values) { + setRateLimits(message.values) + setError(null) + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + + // Fetch rate limits when authenticated + useEffect(() => { + if (isAuthenticated) { + fetchRateLimits() + } + }, [isAuthenticated, fetchRateLimits]) + + if (!isAuthenticated) { + return null + } + + if (isLoading && !rateLimits) { + return ( +
+
Loading rate limits...
+
+ ) + } + + if (error) { + return ( +
+
+
Failed to load rate limits
+ +
+
+ ) + } + + if (!rateLimits) { + return null + } + + return ( +
+
+
Usage Limits
+
+ +
+ {/* 5-hour limit */} +
+
+ + Limit: {rateLimits.representativeClaim || "5-hour"} + + + {formatUtilization(rateLimits.fiveHour.utilization)} used • resets in{" "} + {formatResetTime(rateLimits.fiveHour.resetTime)} + +
+ +
+ + {/* Weekly limit (if available) */} + {rateLimits.weeklyUnified && rateLimits.weeklyUnified.utilization > 0 && ( +
+
+ Weekly + + {formatUtilization(rateLimits.weeklyUnified.utilization)} used • resets in{" "} + {formatResetTime(rateLimits.weeklyUnified.resetTime)} + +
+ +
+ )} +
+
+ ) +} diff --git a/webview-ui/src/components/ui/select.tsx b/webview-ui/src/components/ui/select.tsx index 6e8bcb612e..2a84193047 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