From 67b5d3448b5c411c597a6716f98d8fcabbac503c Mon Sep 17 00:00:00 2001 From: Roo Code Date: Fri, 9 Jan 2026 17:34:11 +0000 Subject: [PATCH] feat: ROO-434 - Remove Claude Code provider - Remove Claude Code integration files (oauth.ts, streaming-client.ts, tests) - Remove Claude Code API provider handler and tests - Remove Claude Code UI components - Remove Claude Code types and models - Update provider lists and configuration - Remove Claude Code message handlers from webview - Remove Claude Code initialization from extension - Update UI hooks and settings to remove Claude Code support - Remove Claude Code authentication state tracking - Remove legacy migration code for Claude Code settings - Update test files to use qwen-code instead of claude-code --- .../src/__tests__/provider-settings.test.ts | 5 - packages/types/src/provider-settings.ts | 10 +- .../providers/__tests__/claude-code.spec.ts | 46 -- packages/types/src/providers/claude-code.ts | 160 ---- packages/types/src/providers/index.ts | 4 - src/api/index.ts | 3 - .../__tests__/claude-code-caching.spec.ts | 169 ---- .../providers/__tests__/claude-code.spec.ts | 597 -------------- src/api/providers/claude-code.ts | 389 --------- src/api/providers/index.ts | 1 - src/core/config/ContextProxy.ts | 17 +- src/core/config/ProviderSettingsManager.ts | 23 - .../config/__tests__/importExport.spec.ts | 54 +- src/core/webview/ClineProvider.ts | 8 - src/core/webview/webviewMessageHandler.ts | 70 -- src/extension.ts | 4 - .../claude-code/__tests__/oauth.spec.ts | 235 ------ .../__tests__/streaming-client.spec.ts | 585 -------------- src/integrations/claude-code/oauth.ts | 638 --------------- .../claude-code/streaming-client.ts | 759 ------------------ src/shared/ExtensionMessage.ts | 2 - src/shared/WebviewMessage.ts | 3 - src/shared/__tests__/api.spec.ts | 2 +- src/shared/checkExistApiConfig.ts | 4 +- webview-ui/src/components/chat/ChatRow.tsx | 46 +- .../src/components/settings/ApiOptions.tsx | 17 +- .../src/components/settings/constants.ts | 3 - .../settings/providers/ClaudeCode.tsx | 68 -- .../ClaudeCodeRateLimitDashboard.tsx | 181 ----- .../components/settings/providers/index.ts | 1 - .../hooks/__tests__/useSelectedModel.spec.ts | 71 -- .../components/ui/hooks/useSelectedModel.ts | 10 - 32 files changed, 55 insertions(+), 4130 deletions(-) delete mode 100644 packages/types/src/providers/__tests__/claude-code.spec.ts delete mode 100644 packages/types/src/providers/claude-code.ts delete mode 100644 src/api/providers/__tests__/claude-code-caching.spec.ts delete mode 100644 src/api/providers/__tests__/claude-code.spec.ts delete mode 100644 src/api/providers/claude-code.ts delete mode 100644 src/integrations/claude-code/__tests__/oauth.spec.ts delete mode 100644 src/integrations/claude-code/__tests__/streaming-client.spec.ts delete mode 100644 src/integrations/claude-code/oauth.ts delete mode 100644 src/integrations/claude-code/streaming-client.ts delete mode 100644 webview-ui/src/components/settings/providers/ClaudeCode.tsx delete mode 100644 webview-ui/src/components/settings/providers/ClaudeCodeRateLimitDashboard.tsx diff --git a/packages/types/src/__tests__/provider-settings.test.ts b/packages/types/src/__tests__/provider-settings.test.ts index cedf9a3e2f3..fc7bee2268a 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -7,11 +7,6 @@ describe("getApiProtocol", () => { expect(getApiProtocol("anthropic", "gpt-4")).toBe("anthropic") }) - it("should return 'anthropic' for claude-code provider", () => { - expect(getApiProtocol("claude-code")).toBe("anthropic") - expect(getApiProtocol("claude-code", "some-model")).toBe("anthropic") - }) - it("should return 'anthropic' for bedrock provider", () => { expect(getApiProtocol("bedrock")).toBe("anthropic") expect(getApiProtocol("bedrock", "gpt-4")).toBe("anthropic") diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 6463a974991..b9aff958ee8 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -7,7 +7,6 @@ import { basetenModels, bedrockModels, cerebrasModels, - claudeCodeModels, deepSeekModels, doubaoModels, featherlessModels, @@ -122,7 +121,6 @@ export const providerNames = [ "bedrock", "baseten", "cerebras", - "claude-code", "doubao", "deepseek", "featherless", @@ -200,8 +198,6 @@ const anthropicSchema = apiModelIdProviderModelSchema.extend({ anthropicBeta1MContext: z.boolean().optional(), // Enable 'context-1m-2025-08-07' beta for 1M context window. }) -const claudeCodeSchema = apiModelIdProviderModelSchema.extend({}) - const openRouterSchema = baseProviderSettingsSchema.extend({ openRouterApiKey: z.string().optional(), openRouterModelId: z.string().optional(), @@ -425,7 +421,6 @@ const defaultSchema = z.object({ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProvider", [ anthropicSchema.merge(z.object({ apiProvider: z.literal("anthropic") })), - claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })), openRouterSchema.merge(z.object({ apiProvider: z.literal("openrouter") })), bedrockSchema.merge(z.object({ apiProvider: z.literal("bedrock") })), vertexSchema.merge(z.object({ apiProvider: z.literal("vertex") })), @@ -466,7 +461,6 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv export const providerSettingsSchema = z.object({ apiProvider: providerNamesSchema.optional(), ...anthropicSchema.shape, - ...claudeCodeSchema.shape, ...openRouterSchema.shape, ...bedrockSchema.shape, ...vertexSchema.shape, @@ -554,7 +548,6 @@ export const isTypicalProvider = (key: unknown): key is TypicalProvider => export const modelIdKeysByProvider: Record = { anthropic: "apiModelId", - "claude-code": "apiModelId", openrouter: "openRouterModelId", bedrock: "apiModelId", vertex: "apiModelId", @@ -593,7 +586,7 @@ export const modelIdKeysByProvider: Record = { */ // Providers that use Anthropic-style API protocol. -export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "claude-code", "bedrock", "minimax"] +export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "bedrock", "minimax"] export const getApiProtocol = (provider: ProviderName | undefined, modelId?: string): "anthropic" | "openai" => { if (provider && ANTHROPIC_STYLE_PROVIDERS.includes(provider)) { @@ -640,7 +633,6 @@ export const MODELS_BY_PROVIDER: Record< label: "Cerebras", models: Object.keys(cerebrasModels), }, - "claude-code": { id: "claude-code", label: "Claude Code", models: Object.keys(claudeCodeModels) }, deepseek: { id: "deepseek", label: "DeepSeek", diff --git a/packages/types/src/providers/__tests__/claude-code.spec.ts b/packages/types/src/providers/__tests__/claude-code.spec.ts deleted file mode 100644 index 5ed66209a53..00000000000 --- a/packages/types/src/providers/__tests__/claude-code.spec.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { normalizeClaudeCodeModelId } from "../claude-code.js" - -describe("normalizeClaudeCodeModelId", () => { - test("should return valid model IDs unchanged", () => { - expect(normalizeClaudeCodeModelId("claude-sonnet-4-5")).toBe("claude-sonnet-4-5") - expect(normalizeClaudeCodeModelId("claude-opus-4-5")).toBe("claude-opus-4-5") - expect(normalizeClaudeCodeModelId("claude-haiku-4-5")).toBe("claude-haiku-4-5") - }) - - test("should normalize sonnet models with date suffix to claude-sonnet-4-5", () => { - // Sonnet 4.5 with date - expect(normalizeClaudeCodeModelId("claude-sonnet-4-5-20250929")).toBe("claude-sonnet-4-5") - // Sonnet 4 (legacy) - expect(normalizeClaudeCodeModelId("claude-sonnet-4-20250514")).toBe("claude-sonnet-4-5") - // Claude 3.7 Sonnet - expect(normalizeClaudeCodeModelId("claude-3-7-sonnet-20250219")).toBe("claude-sonnet-4-5") - // Claude 3.5 Sonnet - expect(normalizeClaudeCodeModelId("claude-3-5-sonnet-20241022")).toBe("claude-sonnet-4-5") - }) - - test("should normalize opus models with date suffix to claude-opus-4-5", () => { - // Opus 4.5 with date - expect(normalizeClaudeCodeModelId("claude-opus-4-5-20251101")).toBe("claude-opus-4-5") - // Opus 4.1 (legacy) - expect(normalizeClaudeCodeModelId("claude-opus-4-1-20250805")).toBe("claude-opus-4-5") - // Opus 4 (legacy) - expect(normalizeClaudeCodeModelId("claude-opus-4-20250514")).toBe("claude-opus-4-5") - }) - - test("should normalize haiku models with date suffix to claude-haiku-4-5", () => { - // Haiku 4.5 with date - expect(normalizeClaudeCodeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5") - // Claude 3.5 Haiku - expect(normalizeClaudeCodeModelId("claude-3-5-haiku-20241022")).toBe("claude-haiku-4-5") - }) - - test("should handle case-insensitive model family matching", () => { - expect(normalizeClaudeCodeModelId("Claude-Sonnet-4-5-20250929")).toBe("claude-sonnet-4-5") - expect(normalizeClaudeCodeModelId("CLAUDE-OPUS-4-5-20251101")).toBe("claude-opus-4-5") - }) - - test("should fallback to default for unrecognized models", () => { - expect(normalizeClaudeCodeModelId("unknown-model")).toBe("claude-sonnet-4-5") - expect(normalizeClaudeCodeModelId("gpt-4")).toBe("claude-sonnet-4-5") - }) -}) diff --git a/packages/types/src/providers/claude-code.ts b/packages/types/src/providers/claude-code.ts deleted file mode 100644 index 28863675d07..00000000000 --- a/packages/types/src/providers/claude-code.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { ModelInfo } from "../model.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 strip date suffix from model names -const DATE_SUFFIX_PATTERN = /-\d{8}$/ - -// Models that work with Claude Code OAuth tokens -// See: https://docs.anthropic.com/en/docs/claude-code -// NOTE: Claude Code is subscription-based with no per-token cost - pricing fields are 0 -export const claudeCodeModels = { - "claude-haiku-4-5": { - maxTokens: 32768, - contextWindow: 200_000, - supportsImages: true, - supportsPromptCache: true, - supportsNativeTools: true, - defaultToolProtocol: "native", - supportsReasoningEffort: ["disable", "low", "medium", "high"], - reasoningEffort: "medium", - description: "Claude Haiku 4.5 - Fast and efficient with thinking", - }, - "claude-sonnet-4-5": { - maxTokens: 32768, - contextWindow: 200_000, - supportsImages: true, - supportsPromptCache: true, - supportsNativeTools: true, - defaultToolProtocol: "native", - supportsReasoningEffort: ["disable", "low", "medium", "high"], - reasoningEffort: "medium", - description: "Claude Sonnet 4.5 - Balanced performance with thinking", - }, - "claude-opus-4-5": { - maxTokens: 32768, - contextWindow: 200_000, - supportsImages: true, - supportsPromptCache: true, - supportsNativeTools: true, - defaultToolProtocol: "native", - supportsReasoningEffort: ["disable", "low", "medium", "high"], - reasoningEffort: "medium", - description: "Claude Opus 4.5 - Most capable with thinking", - }, -} as const satisfies Record - -// Claude Code - Only models that work with Claude Code OAuth tokens -export type ClaudeCodeModelId = keyof typeof claudeCodeModels -export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-4-5" - -/** - * Model family patterns for normalization. - * Maps regex patterns to their canonical Claude Code model IDs. - * - * Order matters - more specific patterns should come first. - */ -const MODEL_FAMILY_PATTERNS: Array<{ pattern: RegExp; target: ClaudeCodeModelId }> = [ - // Opus models (any version) → claude-opus-4-5 - { pattern: /opus/i, target: "claude-opus-4-5" }, - // Haiku models (any version) → claude-haiku-4-5 - { pattern: /haiku/i, target: "claude-haiku-4-5" }, - // Sonnet models (any version) → claude-sonnet-4-5 - { pattern: /sonnet/i, target: "claude-sonnet-4-5" }, -] - -/** - * Normalizes a Claude model ID to a valid Claude Code model ID. - * - * This function handles backward compatibility for legacy model names - * that may include version numbers or date suffixes. It maps: - * - claude-sonnet-4-5-20250929, claude-sonnet-4-20250514, claude-3-7-sonnet-20250219, claude-3-5-sonnet-20241022 → claude-sonnet-4-5 - * - claude-opus-4-5-20251101, claude-opus-4-1-20250805, claude-opus-4-20250514 → claude-opus-4-5 - * - claude-haiku-4-5-20251001, claude-3-5-haiku-20241022 → claude-haiku-4-5 - * - * @param modelId - The model ID to normalize (may be a legacy format) - * @returns A valid ClaudeCodeModelId, or the original ID if already valid - * - * @example - * normalizeClaudeCodeModelId("claude-sonnet-4-5") // returns "claude-sonnet-4-5" - * normalizeClaudeCodeModelId("claude-3-5-sonnet-20241022") // returns "claude-sonnet-4-5" - * normalizeClaudeCodeModelId("claude-opus-4-1-20250805") // returns "claude-opus-4-5" - */ -export function normalizeClaudeCodeModelId(modelId: string): ClaudeCodeModelId { - // If already a valid model ID, return as-is - // Use Object.hasOwn() instead of 'in' operator to avoid matching inherited properties like 'toString' - if (Object.hasOwn(claudeCodeModels, modelId)) { - return modelId as ClaudeCodeModelId - } - - // Strip date suffix if present (e.g., -20250514) - const withoutDate = modelId.replace(DATE_SUFFIX_PATTERN, "") - - // Check if stripping the date makes it valid - if (Object.hasOwn(claudeCodeModels, withoutDate)) { - return withoutDate as ClaudeCodeModelId - } - - // Match by model family - for (const { pattern, target } of MODEL_FAMILY_PATTERNS) { - if (pattern.test(modelId)) { - return target - } - } - - // Fallback to default if no match (shouldn't happen with valid Claude models) - return claudeCodeDefaultModelId -} - -/** - * Reasoning effort configuration for Claude Code thinking mode. - * Maps reasoning effort level to budget_tokens for the thinking process. - * - * Note: With interleaved thinking (enabled via beta header), budget_tokens - * can exceed max_tokens as the token limit becomes the entire context window. - * The max_tokens is drawn from the model's maxTokens definition. - * - * @see https://docs.anthropic.com/en/docs/build-with-claude/extended-thinking#interleaved-thinking - */ -export const claudeCodeReasoningConfig = { - low: { budgetTokens: 16_000 }, - medium: { budgetTokens: 32_000 }, - high: { budgetTokens: 64_000 }, -} as const - -export type ClaudeCodeReasoningLevel = keyof typeof claudeCodeReasoningConfig diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index a08d673e221..eeb1e514954 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -3,7 +3,6 @@ export * from "./baseten.js" export * from "./bedrock.js" export * from "./cerebras.js" export * from "./chutes.js" -export * from "./claude-code.js" export * from "./deepseek.js" export * from "./doubao.js" export * from "./featherless.js" @@ -37,7 +36,6 @@ import { basetenDefaultModelId } from "./baseten.js" import { bedrockDefaultModelId } from "./bedrock.js" import { cerebrasDefaultModelId } from "./cerebras.js" import { chutesDefaultModelId } from "./chutes.js" -import { claudeCodeDefaultModelId } from "./claude-code.js" import { deepSeekDefaultModelId } from "./deepseek.js" import { doubaoDefaultModelId } from "./doubao.js" import { featherlessDefaultModelId } from "./featherless.js" @@ -123,8 +121,6 @@ export function getProviderDefaultModelId( return deepInfraDefaultModelId case "vscode-lm": return vscodeLlmDefaultModelId - case "claude-code": - return claudeCodeDefaultModelId case "cerebras": return cerebrasDefaultModelId case "sambanova": diff --git a/src/api/index.ts b/src/api/index.ts index 2ee882ad72c..088522f2436 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -28,7 +28,6 @@ import { HuggingFaceHandler, ChutesHandler, LiteLLMHandler, - ClaudeCodeHandler, QwenCodeHandler, SambaNovaHandler, IOIntelligenceHandler, @@ -122,8 +121,6 @@ export function buildApiHandler(configuration: ProviderSettings): ApiHandler { switch (apiProvider) { case "anthropic": return new AnthropicHandler(options) - case "claude-code": - return new ClaudeCodeHandler(options) case "openrouter": return new OpenRouterHandler(options) case "bedrock": diff --git a/src/api/providers/__tests__/claude-code-caching.spec.ts b/src/api/providers/__tests__/claude-code-caching.spec.ts deleted file mode 100644 index a0996ab244b..00000000000 --- a/src/api/providers/__tests__/claude-code-caching.spec.ts +++ /dev/null @@ -1,169 +0,0 @@ -import { ClaudeCodeHandler } from "../claude-code" -import type { ApiHandlerOptions } from "../../../shared/api" -import type { StreamChunk } from "../../../integrations/claude-code/streaming-client" -import type { ApiStreamUsageChunk } from "../../transform/stream" - -// Mock the OAuth manager -vi.mock("../../../integrations/claude-code/oauth", () => ({ - claudeCodeOAuthManager: { - getAccessToken: vi.fn(), - getEmail: vi.fn(), - loadCredentials: vi.fn(), - saveCredentials: vi.fn(), - clearCredentials: vi.fn(), - isAuthenticated: vi.fn(), - }, - generateUserId: vi.fn(() => "user_abc123_account_def456_session_ghi789"), -})) - -// Mock the streaming client -vi.mock("../../../integrations/claude-code/streaming-client", () => ({ - createStreamingMessage: vi.fn(), -})) - -const { claudeCodeOAuthManager } = await import("../../../integrations/claude-code/oauth") -const { createStreamingMessage } = await import("../../../integrations/claude-code/streaming-client") - -const mockGetAccessToken = vi.mocked(claudeCodeOAuthManager.getAccessToken) -const mockCreateStreamingMessage = vi.mocked(createStreamingMessage) - -describe("ClaudeCodeHandler - Caching Support", () => { - let handler: ClaudeCodeHandler - const mockOptions: ApiHandlerOptions = { - apiModelId: "claude-sonnet-4-5", - } - - beforeEach(() => { - handler = new ClaudeCodeHandler(mockOptions) - vi.clearAllMocks() - mockGetAccessToken.mockResolvedValue("test-access-token") - }) - - it("should collect cache read tokens from API response", async () => { - const mockStream = async function* (): AsyncGenerator { - yield { type: "text", text: "Hello!" } - yield { - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 80, - cacheWriteTokens: 20, - } - } - - mockCreateStreamingMessage.mockReturnValue(mockStream()) - - const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }]) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Find the usage chunk - const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined - expect(usageChunk).toBeDefined() - expect(usageChunk!.inputTokens).toBe(100) - expect(usageChunk!.outputTokens).toBe(50) - expect(usageChunk!.cacheReadTokens).toBe(80) - expect(usageChunk!.cacheWriteTokens).toBe(20) - }) - - it("should accumulate cache tokens across multiple messages", async () => { - // Note: The streaming client handles accumulation internally. - // Each usage chunk represents the accumulated totals for that point in the stream. - // This test verifies that we correctly pass through the accumulated values. - const mockStream = async function* (): AsyncGenerator { - yield { type: "text", text: "Part 1" } - yield { - type: "usage", - inputTokens: 50, - outputTokens: 25, - cacheReadTokens: 40, - cacheWriteTokens: 10, - } - yield { type: "text", text: "Part 2" } - yield { - type: "usage", - inputTokens: 100, // Accumulated: 50 + 50 - outputTokens: 50, // Accumulated: 25 + 25 - cacheReadTokens: 70, // Accumulated: 40 + 30 - cacheWriteTokens: 30, // Accumulated: 10 + 20 - } - } - - mockCreateStreamingMessage.mockReturnValue(mockStream()) - - const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }]) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Get the last usage chunk which should have accumulated totals - const usageChunks = chunks.filter((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk[] - expect(usageChunks.length).toBe(2) - - const lastUsageChunk = usageChunks[usageChunks.length - 1] - expect(lastUsageChunk.inputTokens).toBe(100) // 50 + 50 - expect(lastUsageChunk.outputTokens).toBe(50) // 25 + 25 - expect(lastUsageChunk.cacheReadTokens).toBe(70) // 40 + 30 - expect(lastUsageChunk.cacheWriteTokens).toBe(30) // 10 + 20 - }) - - it("should handle missing cache token fields gracefully", async () => { - const mockStream = async function* (): AsyncGenerator { - yield { type: "text", text: "Hello!" } - yield { - type: "usage", - inputTokens: 100, - outputTokens: 50, - // No cache tokens provided - } - } - - mockCreateStreamingMessage.mockReturnValue(mockStream()) - - const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }]) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined - expect(usageChunk).toBeDefined() - expect(usageChunk!.inputTokens).toBe(100) - expect(usageChunk!.outputTokens).toBe(50) - expect(usageChunk!.cacheReadTokens).toBeUndefined() - expect(usageChunk!.cacheWriteTokens).toBeUndefined() - }) - - it("should report zero cost for subscription usage", async () => { - // Claude Code is always subscription-based, cost should always be 0 - const mockStream = async function* (): AsyncGenerator { - yield { type: "text", text: "Hello!" } - yield { - type: "usage", - inputTokens: 100, - outputTokens: 50, - cacheReadTokens: 80, - cacheWriteTokens: 20, - } - } - - mockCreateStreamingMessage.mockReturnValue(mockStream()) - - const stream = handler.createMessage("System prompt", [{ role: "user", content: "Hello" }]) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const usageChunk = chunks.find((c) => c.type === "usage" && "totalCost" in c) as ApiStreamUsageChunk | undefined - expect(usageChunk).toBeDefined() - expect(usageChunk!.totalCost).toBe(0) // Should always be 0 for Claude Code (subscription-based) - }) -}) diff --git a/src/api/providers/__tests__/claude-code.spec.ts b/src/api/providers/__tests__/claude-code.spec.ts deleted file mode 100644 index 5b5bdca65ae..00000000000 --- a/src/api/providers/__tests__/claude-code.spec.ts +++ /dev/null @@ -1,597 +0,0 @@ -import { ClaudeCodeHandler } from "../claude-code" -import { ApiHandlerOptions } from "../../../shared/api" -import type { StreamChunk } from "../../../integrations/claude-code/streaming-client" - -// Mock the OAuth manager -vi.mock("../../../integrations/claude-code/oauth", () => ({ - claudeCodeOAuthManager: { - getAccessToken: vi.fn(), - getEmail: vi.fn(), - loadCredentials: vi.fn(), - saveCredentials: vi.fn(), - clearCredentials: vi.fn(), - isAuthenticated: vi.fn(), - }, - generateUserId: vi.fn(() => "user_abc123_account_def456_session_ghi789"), -})) - -// Mock the streaming client -vi.mock("../../../integrations/claude-code/streaming-client", () => ({ - createStreamingMessage: vi.fn(), -})) - -const { claudeCodeOAuthManager } = await import("../../../integrations/claude-code/oauth") -const { createStreamingMessage } = await import("../../../integrations/claude-code/streaming-client") - -const mockGetAccessToken = vi.mocked(claudeCodeOAuthManager.getAccessToken) -const mockGetEmail = vi.mocked(claudeCodeOAuthManager.getEmail) -const mockCreateStreamingMessage = vi.mocked(createStreamingMessage) - -describe("ClaudeCodeHandler", () => { - let handler: ClaudeCodeHandler - - beforeEach(() => { - vi.clearAllMocks() - const options: ApiHandlerOptions = { - 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-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 = { - apiModelId: "invalid-model", - } - const handlerWithInvalidModel = new ClaudeCodeHandler(options) - const model = handlerWithInvalidModel.getModel() - - expect(model.id).toBe("claude-sonnet-4-5") // default model - }) - - test("should return model maxTokens from model definition", () => { - const options: ApiHandlerOptions = { - apiModelId: "claude-opus-4-5", - } - const handlerWithModel = new ClaudeCodeHandler(options) - const model = handlerWithModel.getModel() - - 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 support reasoning effort configuration", () => { - const options: ApiHandlerOptions = { - apiModelId: "claude-sonnet-4-5", - } - const handler = new ClaudeCodeHandler(options) - const model = handler.getModel() - - // Default model has supportsReasoningEffort - expect(model.info.supportsReasoningEffort).toEqual(["disable", "low", "medium", "high"]) - expect(model.info.reasoningEffort).toBe("medium") - }) - - test("should throw error when not authenticated", async () => { - const systemPrompt = "You are a helpful assistant" - const messages = [{ role: "user" as const, content: "Hello" }] - - mockGetAccessToken.mockResolvedValue(null) - - const stream = handler.createMessage(systemPrompt, messages) - const iterator = stream[Symbol.asyncIterator]() - - await expect(iterator.next()).rejects.toThrow(/not authenticated/i) - }) - - test("should 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 { - // Empty generator for basic test - } - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - const stream = handler.createMessage(systemPrompt, messages) - - // Need to start iterating to trigger the call - const iterator = stream[Symbol.asyncIterator]() - await iterator.next() - - // Verify createStreamingMessage was called with correct parameters - // Default model has reasoning effort of "medium" so thinking should be enabled - // With interleaved thinking, maxTokens comes from model definition (32768 for claude-sonnet-4-5) - expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ - accessToken: "test-access-token", - model: "claude-sonnet-4-5", - systemPrompt, - messages, - maxTokens: 32768, // model's maxTokens from claudeCodeModels definition - thinking: { - type: "enabled", - budget_tokens: 32000, // medium reasoning budget_tokens - }, - tools: undefined, - toolChoice: undefined, - metadata: { - user_id: "user_abc123_account_def456_session_ghi789", - }, - }) - }) - - test("should disable thinking when reasoningEffort is set to disable", async () => { - const options: ApiHandlerOptions = { - apiModelId: "claude-sonnet-4-5", - reasoningEffort: "disable", - } - const handlerNoThinking = new ClaudeCodeHandler(options) - - const systemPrompt = "You are a helpful assistant" - const messages = [{ role: "user" as const, content: "Hello" }] - - mockGetAccessToken.mockResolvedValue("test-access-token") - - // Mock empty async generator - const mockGenerator = async function* (): AsyncGenerator { - // Empty generator for basic test - } - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - const stream = handlerNoThinking.createMessage(systemPrompt, messages) - - // Need to start iterating to trigger the call - const iterator = stream[Symbol.asyncIterator]() - await iterator.next() - - // Verify createStreamingMessage was called with thinking disabled - expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ - accessToken: "test-access-token", - model: "claude-sonnet-4-5", - systemPrompt, - messages, - maxTokens: 32768, // model maxTokens from claudeCodeModels definition - thinking: { type: "disabled" }, - tools: undefined, - toolChoice: undefined, - metadata: { - user_id: "user_abc123_account_def456_session_ghi789", - }, - }) - }) - - test("should 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" }] - - 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 (32768 for claude-sonnet-4-5) - expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ - accessToken: "test-access-token", - model: "claude-sonnet-4-5", - systemPrompt, - messages, - maxTokens: 32768, // model's maxTokens from claudeCodeModels definition - thinking: { - type: "enabled", - budget_tokens: 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!" } - } - - 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 = [] - - for await (const chunk of stream) { - results.push(chunk) - } - - expect(results).toHaveLength(1) - expect(results[0]).toEqual({ - type: "reasoning", - text: "I need to think about this carefully...", - }) - }) - - test("should handle mixed content types from streaming", async () => { - const systemPrompt = "You are a helpful assistant" - const messages = [{ role: "user" as const, content: "Hello" }] - - mockGetAccessToken.mockResolvedValue("test-access-token") - - // Mock async generator that yields mixed content - const mockGenerator = async function* (): AsyncGenerator { - yield { type: "reasoning", text: "Let me think about this..." } - yield { type: "text", text: "Here's my response!" } - } - - 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: "reasoning", - text: "Let me think about this...", - }) - expect(results[1]).toEqual({ - type: "text", - text: "Here's my response!", - }) - }) - - 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" }] - - 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"}' } - } - - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - const stream = handler.createMessage(systemPrompt, messages) - const results = [] - - for await (const chunk of stream) { - results.push(chunk) - } - - expect(results).toHaveLength(3) - expect(results[0]).toEqual({ - type: "tool_call_partial", - index: 0, - id: "tool_123", - name: "read_file", - arguments: undefined, - }) - expect(results[1]).toEqual({ - 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 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 with text and usage - const mockGenerator = async function* (): AsyncGenerator { - yield { type: "text", text: "Hello there!" } - yield { - type: "usage", - inputTokens: 10, - outputTokens: 20, - cacheReadTokens: 5, - cacheWriteTokens: 3, - } - } - - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - const stream = handler.createMessage(systemPrompt, messages) - const results = [] - - for await (const chunk of stream) { - results.push(chunk) - } - - // Should have text chunk and usage chunk - expect(results).toHaveLength(2) - expect(results[0]).toEqual({ - 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, - }) - }) - - test("should handle usage without cache tokens", async () => { - const systemPrompt = "You are a helpful assistant" - const messages = [{ role: "user" as const, content: "Hello" }] - - mockGetAccessToken.mockResolvedValue("test-access-token") - - // Mock async generator with usage without cache tokens - const mockGenerator = async function* (): AsyncGenerator { - yield { type: "text", text: "Hello there!" } - yield { - type: "usage", - inputTokens: 10, - outputTokens: 20, - } - } - - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - const stream = handler.createMessage(systemPrompt, messages) - const results = [] - - for await (const chunk of stream) { - results.push(chunk) - } - - // Claude Code is subscription-based, no per-token cost - expect(results).toHaveLength(2) - expect(results[1]).toEqual({ - type: "usage", - inputTokens: 10, - outputTokens: 20, - cacheReadTokens: undefined, - cacheWriteTokens: undefined, - totalCost: 0, - }) - }) - - test("should handle API errors 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 an error - const mockGenerator = async function* (): AsyncGenerator { - yield { type: "error", error: "Invalid model name" } - } - - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - const stream = handler.createMessage(systemPrompt, messages) - const iterator = stream[Symbol.asyncIterator]() - - // Should throw an error - await expect(iterator.next()).rejects.toThrow("Invalid model name") - }) - - test("should handle authentication refresh and continue streaming", async () => { - const systemPrompt = "You are a helpful assistant" - const messages = [{ role: "user" as const, content: "Hello" }] - - // First call returns a valid token - mockGetAccessToken.mockResolvedValue("refreshed-token") - - const mockGenerator = async function* (): AsyncGenerator { - yield { type: "text", text: "Response after refresh" } - } - - mockCreateStreamingMessage.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: "text", - text: "Response after refresh", - }) - - expect(mockCreateStreamingMessage).toHaveBeenCalledWith( - expect.objectContaining({ - accessToken: "refreshed-token", - }), - ) - }) - - describe("completePrompt", () => { - test("should throw error when not authenticated", async () => { - mockGetAccessToken.mockResolvedValue(null) - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow(/not authenticated/i) - }) - - test("should complete prompt and return text response", async () => { - mockGetAccessToken.mockResolvedValue("test-access-token") - mockGetEmail.mockResolvedValue("test@example.com") - - // Mock async generator that yields text chunks - const mockGenerator = async function* (): AsyncGenerator { - yield { type: "text", text: "Hello " } - yield { type: "text", text: "world!" } - yield { type: "usage", inputTokens: 10, outputTokens: 5 } - } - - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - const result = await handler.completePrompt("Say hello") - - expect(result).toBe("Hello world!") - }) - - test("should call createStreamingMessage with empty system prompt and thinking disabled", async () => { - mockGetAccessToken.mockResolvedValue("test-access-token") - mockGetEmail.mockResolvedValue("test@example.com") - - // Mock empty async generator - const mockGenerator = async function* (): AsyncGenerator { - yield { type: "text", text: "Response" } - } - - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - await handler.completePrompt("Test prompt") - - // Verify createStreamingMessage was called with correct parameters - // System prompt is empty because the prompt text contains all context - // createStreamingMessage will still prepend the Claude Code branding - expect(mockCreateStreamingMessage).toHaveBeenCalledWith({ - accessToken: "test-access-token", - model: "claude-sonnet-4-5", - systemPrompt: "", // Empty - branding is added by createStreamingMessage - messages: [{ role: "user", content: "Test prompt" }], - maxTokens: 32768, - thinking: { type: "disabled" }, // No thinking for simple completions - metadata: { - user_id: "user_abc123_account_def456_session_ghi789", - }, - }) - }) - - test("should handle API errors from streaming", async () => { - mockGetAccessToken.mockResolvedValue("test-access-token") - mockGetEmail.mockResolvedValue("test@example.com") - - // Mock async generator that yields an error - const mockGenerator = async function* (): AsyncGenerator { - yield { type: "error", error: "API rate limit exceeded" } - } - - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - await expect(handler.completePrompt("Test prompt")).rejects.toThrow("API rate limit exceeded") - }) - - test("should return empty string when no text chunks received", async () => { - mockGetAccessToken.mockResolvedValue("test-access-token") - mockGetEmail.mockResolvedValue("test@example.com") - - // Mock async generator that only yields usage - const mockGenerator = async function* (): AsyncGenerator { - yield { type: "usage", inputTokens: 10, outputTokens: 0 } - } - - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - const result = await handler.completePrompt("Test prompt") - - expect(result).toBe("") - }) - - test("should use opus model maxTokens when configured", async () => { - const options: ApiHandlerOptions = { - apiModelId: "claude-opus-4-5", - } - const handlerOpus = new ClaudeCodeHandler(options) - - mockGetAccessToken.mockResolvedValue("test-access-token") - mockGetEmail.mockResolvedValue("test@example.com") - - const mockGenerator = async function* (): AsyncGenerator { - yield { type: "text", text: "Response" } - } - - mockCreateStreamingMessage.mockReturnValue(mockGenerator()) - - await handlerOpus.completePrompt("Test prompt") - - expect(mockCreateStreamingMessage).toHaveBeenCalledWith( - expect.objectContaining({ - model: "claude-opus-4-5", - maxTokens: 32768, // opus model maxTokens - }), - ) - }) - }) -}) diff --git a/src/api/providers/claude-code.ts b/src/api/providers/claude-code.ts deleted file mode 100644 index f2bccc329c7..00000000000 --- a/src/api/providers/claude-code.ts +++ /dev/null @@ -1,389 +0,0 @@ -import type { Anthropic } from "@anthropic-ai/sdk" -import OpenAI from "openai" -import { - claudeCodeDefaultModelId, - type ClaudeCodeModelId, - claudeCodeModels, - claudeCodeReasoningConfig, - type ClaudeCodeReasoningLevel, - type ModelInfo, -} from "@roo-code/types" -import { type ApiHandler, ApiHandlerCreateMessageMetadata, type SingleCompletionHandler } from ".." -import { ApiStreamUsageChunk, type ApiStream } from "../transform/stream" -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, SingleCompletionHandler { - private options: ApiHandlerOptions - /** - * Store the last thinking block signature for interleaved thinking with tool use. - * This is captured from thinking_complete events during streaming and - * must be passed back to the API when providing tool results. - * Similar to Gemini's thoughtSignature pattern. - */ - private lastThinkingSignature?: string - - constructor(options: ApiHandlerOptions) { - this.options = options - } - - /** - * Get the thinking signature from the last response. - * Used by Task.addToApiConversationHistory to persist the signature - * so it can be passed back to the API for tool use continuations. - * This follows the same pattern as Gemini's getThoughtSignature(). - */ - public getThoughtSignature(): string | undefined { - return this.lastThinkingSignature - } - - /** - * Gets the reasoning effort level for the current request. - * Returns the effective reasoning level (low/medium/high) or null if disabled. - */ - private getReasoningEffort(modelInfo: ModelInfo): ClaudeCodeReasoningLevel | null { - // Check if reasoning is explicitly disabled - if (this.options.enableReasoningEffort === false) { - return null - } - - // Get the selected effort from settings or model default - const selectedEffort = this.options.reasoningEffort ?? modelInfo.reasoningEffort - - // "disable" or no selection means no reasoning - if (!selectedEffort || selectedEffort === "disable") { - return null - } - - // Only allow valid levels for Claude Code - if (selectedEffort === "low" || selectedEffort === "medium" || selectedEffort === "high") { - return selectedEffort - } - - return null - } - - async *createMessage( - systemPrompt: string, - messages: Anthropic.Messages.MessageParam[], - metadata?: ApiHandlerCreateMessageMetadata, - ): ApiStream { - // Reset per-request state that we persist into apiConversationHistory - this.lastThinkingSignature = undefined - - const buildNotAuthenticatedError = () => - new Error( - t("common:errors.claudeCode.notAuthenticated", { - defaultValue: - "Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.", - }), - ) - - async function* streamOnce(this: ClaudeCodeHandler, accessToken: string): ApiStream { - // Get user email for generating user_id metadata - const email = await claudeCodeOAuthManager.getEmail() - - const model = this.getModel() - - // Validate that the model ID is a valid ClaudeCodeModelId - const modelId = Object.hasOwn(claudeCodeModels, model.id) - ? (model.id as ClaudeCodeModelId) - : claudeCodeDefaultModelId - - // Generate user_id metadata in the format required by Claude Code API - const userId = generateUserId(email || undefined) - - // 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" } - } - - // Create streaming request using OAuth - const stream = createStreamingMessage({ - accessToken, - model: modelId, - systemPrompt, - messages, - maxTokens, - thinking, - tools: anthropicTools, - toolChoice: anthropicToolChoice, - metadata: { - user_id: userId, - }, - }) - - // Track usage for cost calculation - let inputTokens = 0 - let outputTokens = 0 - let cacheReadTokens = 0 - let cacheWriteTokens = 0 - - for await (const chunk of stream) { - switch (chunk.type) { - case "text": - yield { - type: "text", - text: chunk.text, - } - 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 - } - - case "error": - throw new Error(chunk.error) - } - } - } - - // Get access token from OAuth manager - let accessToken = await claudeCodeOAuthManager.getAccessToken() - if (!accessToken) { - throw buildNotAuthenticatedError() - } - - // Try the request with at most one force-refresh retry on auth failure - for (let attempt = 0; attempt < 2; attempt++) { - try { - yield* streamOnce.call(this, accessToken) - return - } catch (error) { - const message = error instanceof Error ? error.message : String(error) - const isAuthFailure = /unauthorized|invalid token|not authenticated|authentication/i.test(message) - - // Only retry on auth failure during first attempt - const canRetry = attempt === 0 && isAuthFailure - if (!canRetry) { - throw error - } - - // Force refresh the token for retry - const refreshed = await claudeCodeOAuthManager.forceRefreshAccessToken() - if (!refreshed) { - throw buildNotAuthenticatedError() - } - accessToken = refreshed - } - } - - // Unreachable: loop always returns on success or throws on failure - throw buildNotAuthenticatedError() - } - - getModel(): { id: string; info: ModelInfo } { - const modelId = this.options.apiModelId - if (modelId && Object.hasOwn(claudeCodeModels, modelId)) { - const id = modelId as ClaudeCodeModelId - return { id, info: { ...claudeCodeModels[id] } } - } - - return { - id: claudeCodeDefaultModelId, - info: { ...claudeCodeModels[claudeCodeDefaultModelId] }, - } - } - - async countTokens(content: Anthropic.Messages.ContentBlockParam[]): Promise { - if (content.length === 0) { - return 0 - } - return countTokens(content, { useWorker: true }) - } - - /** - * Completes a prompt using the Claude Code API. - * This is used for context condensing and prompt enhancement. - * The Claude Code branding is automatically prepended by createStreamingMessage. - */ - async completePrompt(prompt: string): Promise { - // Get access token from OAuth manager - const accessToken = await claudeCodeOAuthManager.getAccessToken() - - if (!accessToken) { - throw new Error( - t("common:errors.claudeCode.notAuthenticated", { - defaultValue: - "Not authenticated with Claude Code. Please sign in using the Claude Code OAuth flow.", - }), - ) - } - - // Get user email for generating user_id metadata - const email = await claudeCodeOAuthManager.getEmail() - - const model = this.getModel() - - // Validate that the model ID is a valid ClaudeCodeModelId - const modelId = Object.hasOwn(claudeCodeModels, model.id) - ? (model.id as ClaudeCodeModelId) - : claudeCodeDefaultModelId - - // Generate user_id metadata in the format required by Claude Code API - const userId = generateUserId(email || undefined) - - // Use maxTokens from model info for completion - const maxTokens = model.info.maxTokens ?? 16384 - - // Create streaming request using OAuth - // The system prompt is empty here since the prompt itself contains all context - // createStreamingMessage will still prepend the Claude Code branding - const stream = createStreamingMessage({ - accessToken, - model: modelId, - systemPrompt: "", // Empty system prompt - the prompt text contains all necessary context - messages: [{ role: "user", content: prompt }], - maxTokens, - thinking: { type: "disabled" }, // No thinking for simple completions - metadata: { - user_id: userId, - }, - }) - - // Collect all text chunks into a single response - let result = "" - - for await (const chunk of stream) { - switch (chunk.type) { - case "text": - result += chunk.text - break - case "error": - throw new Error(chunk.error) - } - } - - return result - } -} diff --git a/src/api/providers/index.ts b/src/api/providers/index.ts index fe9388962f0..8f543f64db2 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -3,7 +3,6 @@ export { AnthropicHandler } from "./anthropic" export { AwsBedrockHandler } from "./bedrock" export { CerebrasHandler } from "./cerebras" export { ChutesHandler } from "./chutes" -export { ClaudeCodeHandler } from "./claude-code" export { DeepSeekHandler } from "./deepseek" export { DoubaoHandler } from "./doubao" export { MoonshotHandler } from "./moonshot" diff --git a/src/core/config/ContextProxy.ts b/src/core/config/ContextProxy.ts index 64baf546bd5..d570ccc7c67 100644 --- a/src/core/config/ContextProxy.ts +++ b/src/core/config/ContextProxy.ts @@ -316,26 +316,13 @@ export class ContextProxy { * This prevents schema validation errors for removed providers. */ private sanitizeProviderValues(values: RooCodeSettings): RooCodeSettings { - // Remove legacy Claude Code CLI wrapper keys that may still exist in global state. - // These keys were used by a removed local CLI runner and are no longer part of ProviderSettings. - const legacyKeys = ["claudeCodePath", "claudeCodeMaxOutputTokens"] as const - - let sanitizedValues = values - for (const key of legacyKeys) { - if (key in sanitizedValues) { - const copy = { ...sanitizedValues } as Record - delete copy[key as string] - sanitizedValues = copy as RooCodeSettings - } - } - if (values.apiProvider !== undefined && !isProviderName(values.apiProvider)) { logger.info(`[ContextProxy] Sanitizing invalid provider "${values.apiProvider}" - resetting to undefined`) // Return a new values object without the invalid apiProvider - const { apiProvider, ...restValues } = sanitizedValues + const { apiProvider, ...restValues } = values return restValues as RooCodeSettings } - return sanitizedValues + return values } public async setProviderSettings(values: ProviderSettings) { diff --git a/src/core/config/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index 420ab332b24..43017882fae 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -47,7 +47,6 @@ export const providerProfilesSchema = z.object({ openAiHeadersMigrated: z.boolean().optional(), consecutiveMistakeLimitMigrated: z.boolean().optional(), todoListEnabledMigrated: z.boolean().optional(), - claudeCodeLegacySettingsMigrated: z.boolean().optional(), }) .optional(), }) @@ -72,7 +71,6 @@ export class ProviderSettingsManager { openAiHeadersMigrated: true, // Mark as migrated on fresh installs consecutiveMistakeLimitMigrated: true, // Mark as migrated on fresh installs todoListEnabledMigrated: true, // Mark as migrated on fresh installs - claudeCodeLegacySettingsMigrated: true, // Mark as migrated on fresh installs }, } @@ -145,7 +143,6 @@ export class ProviderSettingsManager { openAiHeadersMigrated: false, consecutiveMistakeLimitMigrated: false, todoListEnabledMigrated: false, - claudeCodeLegacySettingsMigrated: false, } // Initialize with default values isDirty = true } @@ -180,26 +177,6 @@ export class ProviderSettingsManager { isDirty = true } - if (!providerProfiles.migrations.claudeCodeLegacySettingsMigrated) { - // These keys were used by the removed local Claude Code CLI wrapper. - for (const apiConfig of Object.values(providerProfiles.apiConfigs)) { - if (apiConfig.apiProvider !== "claude-code") continue - - const config = apiConfig as unknown as Record - if ("claudeCodePath" in config) { - delete config.claudeCodePath - isDirty = true - } - if ("claudeCodeMaxOutputTokens" in config) { - delete config.claudeCodeMaxOutputTokens - isDirty = true - } - } - - providerProfiles.migrations.claudeCodeLegacySettingsMigrated = true - isDirty = true - } - if (isDirty) { await this.store(providerProfiles) } diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 3d5329f377b..55cb3bd0d93 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -68,9 +68,9 @@ vi.mock("../../../api", () => ({ buildApiHandler: vi.fn().mockImplementation((config) => { // Return different model info based on the provider and model const getModelInfo = () => { - if (config.apiProvider === "claude-code") { + if (config.apiProvider === "qwen-code") { return { - id: config.apiModelId || "claude-sonnet-4-5", + id: config.apiModelId || "qwen3-qwq-32b", info: { supportsReasoningBudget: false, requiredReasoningBudget: false, @@ -483,18 +483,18 @@ describe("importExport", () => { it("should handle import when reasoning budget fields are missing from config", async () => { // This test verifies that import works correctly when reasoning budget fields are not present - // Using claude-code provider which doesn't support reasoning budgets + // Using qwen-code provider which doesn't support reasoning budgets ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) const mockFileContent = JSON.stringify({ providerProfiles: { - currentApiConfigName: "claude-code-provider", + currentApiConfigName: "qwen-code-provider", apiConfigs: { - "claude-code-provider": { - apiProvider: "claude-code" as ProviderName, - apiModelId: "claude-3-5-sonnet-20241022", - id: "claude-code-id", + "qwen-code-provider": { + apiProvider: "qwen-code" as ProviderName, + apiModelId: "qwen3-qwq-32b", + id: "qwen-code-id", apiKey: "test-key", // No modelMaxTokens or modelMaxThinkingTokens fields }, @@ -512,7 +512,7 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "claude-code-provider", id: "claude-code-id", apiProvider: "claude-code" as ProviderName }, + { name: "qwen-code-provider", id: "qwen-code-id", apiProvider: "qwen-code" as ProviderName }, { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, ]) @@ -529,21 +529,21 @@ describe("importExport", () => { expect(mockProviderSettingsManager.export).toHaveBeenCalled() expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ - currentApiConfigName: "claude-code-provider", + currentApiConfigName: "qwen-code-provider", apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" }, - "claude-code-provider": { - apiProvider: "claude-code" as ProviderName, - apiModelId: "claude-3-5-sonnet-20241022", + "qwen-code-provider": { + apiProvider: "qwen-code" as ProviderName, + apiModelId: "qwen3-qwq-32b", apiKey: "test-key", - id: "claude-code-id", + id: "qwen-code-id", }, }, modeApiConfigs: {}, }) expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true }) - expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "claude-code-provider") + expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "qwen-code-provider") }) }) @@ -1721,27 +1721,27 @@ describe("importExport", () => { it.each([ { testCase: "supportsReasoningBudget is false", - providerName: "claude-code-provider", - modelId: "claude-sonnet-4-5", - providerId: "claude-code-id", + providerName: "qwen-code-provider", + modelId: "qwen3-qwq-32b", + providerId: "qwen-code-id", }, { testCase: "requiredReasoningBudget is false", - providerName: "claude-code-provider-2", - modelId: "claude-sonnet-4-5", - providerId: "claude-code-id-2", + providerName: "qwen-code-provider-2", + modelId: "qwen3-qwq-32b", + providerId: "qwen-code-id-2", }, { testCase: "both supportsReasoningBudget and requiredReasoningBudget are false", - providerName: "claude-code-provider-3", - modelId: "claude-3-5-haiku-20241022", - providerId: "claude-code-id-3", + providerName: "qwen-code-provider-3", + modelId: "qwen3-qwq-32b", + providerId: "qwen-code-id-3", }, ])( "should exclude modelMaxTokens and modelMaxThinkingTokens when $testCase", async ({ providerName, modelId, providerId }) => { // This test verifies that token fields are excluded when model doesn't support reasoning budget - // Using claude-code provider which has supportsReasoningBudget: false and requiredReasoningBudget: false + // Using qwen-code provider which has supportsReasoningBudget: false and requiredReasoningBudget: false ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ fsPath: "/mock/path/roo-code-settings.json", @@ -1753,9 +1753,9 @@ describe("importExport", () => { // Wait for initialization to complete await realProviderSettingsManager.initialize() - // Save a claude-code provider config with token fields + // Save a qwen-code provider config with token fields await realProviderSettingsManager.saveConfig(providerName, { - apiProvider: "claude-code" as ProviderName, + apiProvider: "qwen-code" as ProviderName, apiModelId: modelId, id: providerId, apiKey: "test-key", diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index a2a400660e7..723a266e22c 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2157,14 +2157,6 @@ export class ClineProvider openRouterImageApiKey, openRouterImageGenerationSelectedModel, 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 0df014b49ab..2fd9fa6d394 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2340,45 +2340,6 @@ 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) { @@ -3160,37 +3121,6 @@ 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 76f02af6de2..3fe68da1460 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,7 +27,6 @@ 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" @@ -101,9 +100,6 @@ 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, (message) => outputChannel.appendLine(message)) - // Get default commands from configuration. const defaultCommands = vscode.workspace.getConfiguration(Package.name).get("allowedCommands") || [] diff --git a/src/integrations/claude-code/__tests__/oauth.spec.ts b/src/integrations/claude-code/__tests__/oauth.spec.ts deleted file mode 100644 index 7de75ec5292..00000000000 --- a/src/integrations/claude-code/__tests__/oauth.spec.ts +++ /dev/null @@ -1,235 +0,0 @@ -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) - }) - }) - - describe("refresh token behavior", () => { - afterEach(() => { - vi.unstubAllGlobals() - }) - - test("refresh responses may omit refresh_token (should be tolerated)", async () => { - const { refreshAccessToken } = await import("../oauth") - - // Mock fetch to return a refresh response with no refresh_token - const mockFetch = vi.fn().mockResolvedValue( - new Response( - JSON.stringify({ - access_token: "new-access", - expires_in: 3600, - // refresh_token intentionally omitted - }), - { status: 200, headers: { "Content-Type": "application/json" } }, - ), - ) - - vi.stubGlobal("fetch", mockFetch) - - const creds: ClaudeCodeCredentials = { - type: "claude" as const, - access_token: "old-access", - refresh_token: "old-refresh", - expired: new Date(Date.now() - 1000).toISOString(), - email: "test@example.com", - } - - const refreshed = await refreshAccessToken(creds) - expect(refreshed.access_token).toBe("new-access") - expect(refreshed.refresh_token).toBe("old-refresh") - expect(refreshed.email).toBe("test@example.com") - }) - }) -}) diff --git a/src/integrations/claude-code/__tests__/streaming-client.spec.ts b/src/integrations/claude-code/__tests__/streaming-client.spec.ts deleted file mode 100644 index 8ccb108827d..00000000000 --- a/src/integrations/claude-code/__tests__/streaming-client.spec.ts +++ /dev/null @@ -1,585 +0,0 @@ -import { CLAUDE_CODE_API_CONFIG } from "../streaming-client" - -describe("Claude Code Streaming Client", () => { - describe("CLAUDE_CODE_API_CONFIG", () => { - test("should have correct API endpoint", () => { - expect(CLAUDE_CODE_API_CONFIG.endpoint).toBe("https://api.anthropic.com/v1/messages") - }) - - test("should have correct API version", () => { - expect(CLAUDE_CODE_API_CONFIG.version).toBe("2023-06-01") - }) - - test("should have correct default betas", () => { - expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("claude-code-20250219") - expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("oauth-2025-04-20") - expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("interleaved-thinking-2025-05-14") - expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("fine-grained-tool-streaming-2025-05-14") - }) - - test("should have correct user agent", () => { - expect(CLAUDE_CODE_API_CONFIG.userAgent).toMatch(/^Roo-Code\/\d+\.\d+\.\d+$/) - }) - }) - - describe("createStreamingMessage", () => { - let originalFetch: typeof global.fetch - - beforeEach(() => { - originalFetch = global.fetch - }) - - afterEach(() => { - global.fetch = originalFetch - }) - - test("should make request with correct headers", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValue({ done: true, value: undefined }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [{ role: "user", content: "Hello" }], - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - expect(mockFetch).toHaveBeenCalledWith( - expect.stringContaining(CLAUDE_CODE_API_CONFIG.endpoint), - expect.objectContaining({ - method: "POST", - headers: expect.objectContaining({ - Authorization: "Bearer test-token", - "Content-Type": "application/json", - "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, - Accept: "text/event-stream", - "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, - }), - }), - ) - }) - - test("should include correct body parameters", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValue({ done: true, value: undefined }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [{ role: "user", content: "Hello" }], - maxTokens: 4096, - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - const call = mockFetch.mock.calls[0] - const body = JSON.parse(call[1].body) - - expect(body.model).toBe("claude-3-5-sonnet-20241022") - expect(body.stream).toBe(true) - expect(body.max_tokens).toBe(4096) - // System prompt should have cache_control on the user-provided text - expect(body.system).toEqual([ - { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, - { type: "text", text: "You are helpful", cache_control: { type: "ephemeral" } }, - ]) - // Messages should have cache_control on the last user message - expect(body.messages).toEqual([ - { - role: "user", - content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }], - }, - ]) - }) - - test("should add cache breakpoints to last two user messages", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValue({ done: true, value: undefined }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [ - { role: "user", content: "First message" }, - { role: "assistant", content: "Response" }, - { role: "user", content: "Second message" }, - { role: "assistant", content: "Another response" }, - { role: "user", content: "Third message" }, - ], - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - const call = mockFetch.mock.calls[0] - const body = JSON.parse(call[1].body) - - // Only the last two user messages should have cache_control - expect(body.messages[0].content).toBe("First message") // No cache_control - expect(body.messages[2].content).toEqual([ - { type: "text", text: "Second message", cache_control: { type: "ephemeral" } }, - ]) - expect(body.messages[4].content).toEqual([ - { type: "text", text: "Third message", cache_control: { type: "ephemeral" } }, - ]) - }) - - test("should filter out non-Anthropic block types", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValue({ done: true, value: undefined }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [ - { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, - { - role: "assistant", - content: [ - { type: "reasoning", text: "Internal reasoning" }, // Should be filtered - { type: "thoughtSignature", data: "encrypted" }, // Should be filtered - { type: "text", text: "Response" }, - ], - }, - { - role: "user", - content: [{ type: "text", text: "Follow up" }], - }, - ] as any, - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - const call = mockFetch.mock.calls[0] - const body = JSON.parse(call[1].body) - - // The assistant message should only have the text block - expect(body.messages[1].content).toEqual([{ type: "text", text: "Response" }]) - }) - - test("should preserve thinking and redacted_thinking blocks", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValue({ done: true, value: undefined }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [ - { - role: "user", - content: [{ type: "text", text: "Hello" }], - }, - { - role: "assistant", - content: [ - { type: "thinking", thinking: "Let me think...", signature: "abc123" }, - { type: "text", text: "Response" }, - ], - }, - { - role: "user", - content: [{ type: "tool_result", tool_use_id: "123", content: "result" }], - }, - ] as any, - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - const call = mockFetch.mock.calls[0] - const body = JSON.parse(call[1].body) - - // Thinking blocks should be preserved - expect(body.messages[1].content).toContainEqual({ - type: "thinking", - thinking: "Let me think...", - signature: "abc123", - }) - // Tool result blocks should be preserved - expect(body.messages[2].content).toContainEqual({ - type: "tool_result", - tool_use_id: "123", - content: "result", - }) - }) - - // Dropped: conversion of internal `reasoning` + `thoughtSignature` blocks into - // Anthropic `thinking` blocks. The Claude Code integration now relies on the - // Anthropic-native `thinking` block format persisted by Task. - - test("should strip reasoning_details from messages (provider switching)", async () => { - // When switching from OpenRouter/Roo to Claude Code, messages may have - // reasoning_details fields that the Anthropic API doesn't accept - // This causes errors like: "messages.3.reasoning_details: Extra inputs are not permitted" - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValue({ done: true, value: undefined }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - // Simulate messages with reasoning_details (added by OpenRouter for Gemini/o-series) - const messagesWithReasoningDetails = [ - { role: "user", content: "Hello" }, - { - role: "assistant", - content: [{ type: "text", text: "I'll help with that." }], - // This field is added by OpenRouter/Roo providers for Gemini/OpenAI reasoning - reasoning_details: [{ type: "summary_text", summary: "Thinking about the request" }], - }, - { role: "user", content: "Follow up question" }, - ] - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: messagesWithReasoningDetails as any, - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - const call = mockFetch.mock.calls[0] - const body = JSON.parse(call[1].body) - - // The assistant message should NOT have reasoning_details - expect(body.messages[1]).not.toHaveProperty("reasoning_details") - // But should still have the content - expect(body.messages[1].content).toContainEqual( - expect.objectContaining({ - type: "text", - text: "I'll help with that.", - }), - ) - // Only role and content should be present - expect(Object.keys(body.messages[1])).toEqual(["role", "content"]) - }) - - test("should strip other non-standard message fields", async () => { - // Ensure any non-standard fields are stripped from messages - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockResolvedValue({ done: true, value: undefined }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const messagesWithExtraFields = [ - { - role: "user", - content: "Hello", - customField: "should be stripped", - metadata: { foo: "bar" }, - }, - { - role: "assistant", - content: [{ type: "text", text: "Response" }], - internalId: "123", - timestamp: Date.now(), - }, - ] - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: messagesWithExtraFields as any, - }) - - // Consume the stream - for await (const _ of stream) { - // Just consume - } - - const call = mockFetch.mock.calls[0] - const body = JSON.parse(call[1].body) - - // All messages should only have role and content - body.messages.forEach((msg: Record) => { - expect(Object.keys(msg).filter((k) => k !== "role" && k !== "content")).toHaveLength(0) - }) - }) - - test("should yield error chunk on non-ok response", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: false, - status: 401, - statusText: "Unauthorized", - text: vi.fn().mockResolvedValue('{"error":{"message":"Invalid API key"}}'), - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "invalid-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [{ role: "user", content: "Hello" }], - }) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks).toHaveLength(1) - expect(chunks[0].type).toBe("error") - expect((chunks[0] as { type: "error"; error: string }).error).toBe("Invalid API key") - }) - - test("should yield error chunk when no response body", async () => { - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: null, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [{ role: "user", content: "Hello" }], - }) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks).toHaveLength(1) - expect(chunks[0].type).toBe("error") - expect((chunks[0] as { type: "error"; error: string }).error).toBe("No response body") - }) - - test("should parse text SSE events correctly", async () => { - const sseData = [ - 'event: content_block_start\ndata: {"index":0,"content_block":{"type":"text","text":"Hello"}}\n\n', - 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"text_delta","text":" world"}}\n\n', - "event: message_stop\ndata: {}\n\n", - ] - - let readIndex = 0 - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockImplementation(() => { - if (readIndex < sseData.length) { - const value = new TextEncoder().encode(sseData[readIndex++]) - return Promise.resolve({ done: false, value }) - } - return Promise.resolve({ done: true, value: undefined }) - }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [{ role: "user", content: "Hello" }], - }) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - // Should have text chunks and usage - expect(chunks.some((c) => c.type === "text")).toBe(true) - expect(chunks.filter((c) => c.type === "text")).toEqual([ - { type: "text", text: "Hello" }, - { type: "text", text: " world" }, - ]) - }) - - test("should parse thinking/reasoning SSE events correctly", async () => { - const sseData = [ - 'event: content_block_start\ndata: {"index":0,"content_block":{"type":"thinking","thinking":"Let me think..."}}\n\n', - 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"thinking_delta","thinking":" more thoughts"}}\n\n', - "event: message_stop\ndata: {}\n\n", - ] - - let readIndex = 0 - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockImplementation(() => { - if (readIndex < sseData.length) { - const value = new TextEncoder().encode(sseData[readIndex++]) - return Promise.resolve({ done: false, value }) - } - return Promise.resolve({ done: true, value: undefined }) - }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [{ role: "user", content: "Hello" }], - }) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - expect(chunks.filter((c) => c.type === "reasoning")).toEqual([ - { type: "reasoning", text: "Let me think..." }, - { type: "reasoning", text: " more thoughts" }, - ]) - }) - - test("should track and yield usage from message events", async () => { - const sseData = [ - 'event: message_start\ndata: {"message":{"usage":{"input_tokens":10,"output_tokens":0,"cache_read_input_tokens":5}}}\n\n', - 'event: message_delta\ndata: {"usage":{"output_tokens":20}}\n\n', - "event: message_stop\ndata: {}\n\n", - ] - - let readIndex = 0 - const mockFetch = vi.fn().mockResolvedValue({ - ok: true, - body: { - getReader: () => ({ - read: vi.fn().mockImplementation(() => { - if (readIndex < sseData.length) { - const value = new TextEncoder().encode(sseData[readIndex++]) - return Promise.resolve({ done: false, value }) - } - return Promise.resolve({ done: true, value: undefined }) - }), - releaseLock: vi.fn(), - }), - }, - }) - global.fetch = mockFetch - - const { createStreamingMessage } = await import("../streaming-client") - - const stream = createStreamingMessage({ - accessToken: "test-token", - model: "claude-3-5-sonnet-20241022", - systemPrompt: "You are helpful", - messages: [{ role: "user", content: "Hello" }], - }) - - const chunks = [] - for await (const chunk of stream) { - chunks.push(chunk) - } - - const usageChunk = chunks.find((c) => c.type === "usage") - expect(usageChunk).toBeDefined() - expect(usageChunk).toMatchObject({ - type: "usage", - inputTokens: 10, - outputTokens: 20, - cacheReadTokens: 5, - }) - }) - }) -}) diff --git a/src/integrations/claude-code/oauth.ts b/src/integrations/claude-code/oauth.ts deleted file mode 100644 index 5d7a929e1cc..00000000000 --- a/src/integrations/claude-code/oauth.ts +++ /dev/null @@ -1,638 +0,0 @@ -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 responses may omit refresh_token (common OAuth behavior). When omitted, - // callers must preserve the existing refresh token. - refresh_token: z.string().min(1).optional(), - expires_in: z.number(), - email: z.string().optional(), - token_type: z.string().optional(), -}) - -class ClaudeCodeOAuthTokenError extends Error { - public readonly status?: number - public readonly errorCode?: string - - constructor(message: string, opts?: { status?: number; errorCode?: string }) { - super(message) - this.name = "ClaudeCodeOAuthTokenError" - this.status = opts?.status - this.errorCode = opts?.errorCode - } - - public isLikelyInvalidGrant(): boolean { - if (this.errorCode && /invalid_grant/i.test(this.errorCode)) { - return true - } - if (this.status === 400 || this.status === 401 || this.status === 403) { - return /invalid_grant|revoked|expired|invalid refresh/i.test(this.message) - } - return false - } -} - -function parseOAuthErrorDetails(errorText: string): { errorCode?: string; errorMessage?: string } { - try { - const json: unknown = JSON.parse(errorText) - if (!json || typeof json !== "object") { - return {} - } - - const obj = json as Record - const errorField = obj.error - - const errorCode: string | undefined = - typeof errorField === "string" - ? errorField - : errorField && - typeof errorField === "object" && - typeof (errorField as Record).type === "string" - ? ((errorField as Record).type as string) - : undefined - - const errorDescription = obj.error_description - const errorMessageFromError = - errorField && typeof errorField === "object" ? (errorField as Record).message : undefined - - const errorMessage: string | undefined = - typeof errorDescription === "string" - ? errorDescription - : typeof errorMessageFromError === "string" - ? errorMessageFromError - : typeof obj.message === "string" - ? obj.message - : undefined - - return { errorCode, errorMessage } - } catch { - return {} - } -} - -/** - * 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) - - if (!tokenResponse.refresh_token) { - // The access token is unusable without a refresh token for persistence. - throw new Error("Token exchange did not return a refresh_token") - } - - // 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(credentials: ClaudeCodeCredentials): Promise { - const body = { - grant_type: "refresh_token", - client_id: CLAUDE_CODE_OAUTH_CONFIG.clientId, - refresh_token: credentials.refresh_token, - } - - 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() - const { errorCode, errorMessage } = parseOAuthErrorDetails(errorText) - const details = errorMessage ? errorMessage : errorText - throw new ClaudeCodeOAuthTokenError( - `Token refresh failed: ${response.status} ${response.statusText}${details ? ` - ${details}` : ""}`, - { status: response.status, errorCode }, - ) - } - - 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 ?? credentials.refresh_token, - expired: expiresAt.toISOString(), - email: tokenResponse.email ?? credentials.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 logFn: ((message: string) => void) | null = null - private refreshPromise: Promise | null = null - private pendingAuth: { - codeVerifier: string - state: string - server?: http.Server - } | null = null - - private log(message: string): void { - if (this.logFn) { - this.logFn(message) - } else { - console.log(message) - } - } - - private logError(message: string, error?: unknown): void { - const details = error instanceof Error ? error.message : error !== undefined ? String(error) : undefined - const full = details ? `${message} ${details}` : message - this.log(full) - console.error(full) - } - - /** - * Initialize the OAuth manager with VS Code extension context - */ - initialize(context: ExtensionContext, logFn?: (message: string) => void): void { - this.context = context - this.logFn = logFn ?? null - } - - /** - * Force a refresh using the stored refresh token even if the access token is not expired. - * Useful when the server invalidates an access token early. - */ - async forceRefreshAccessToken(): Promise { - if (!this.credentials) { - await this.loadCredentials() - } - - if (!this.credentials) { - return null - } - - try { - // De-dupe concurrent refreshes - if (!this.refreshPromise) { - const prevRefreshToken = this.credentials.refresh_token - this.log(`[claude-code-oauth] Forcing token refresh (expired=${this.credentials.expired})...`) - this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { - const rotated = newCreds.refresh_token !== prevRefreshToken - this.log( - `[claude-code-oauth] Forced refresh response received (expires_in≈${Math.round( - (new Date(newCreds.expired).getTime() - Date.now()) / 1000, - )}s, refresh_token_rotated=${rotated})`, - ) - return newCreds - }) - } - - const newCredentials = await this.refreshPromise - this.refreshPromise = null - await this.saveCredentials(newCredentials) - this.log(`[claude-code-oauth] Forced token persisted (expired=${newCredentials.expired})`) - return newCredentials.access_token - } catch (error) { - this.refreshPromise = null - this.logError("[claude-code-oauth] Failed to force refresh token:", error) - if (error instanceof ClaudeCodeOAuthTokenError && error.isLikelyInvalidGrant()) { - this.log("[claude-code-oauth] Refresh token appears invalid; clearing stored credentials") - await this.clearCredentials() - } - return null - } - } - - /** - * 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) { - this.logError("[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 { - // De-dupe concurrent refreshes - if (!this.refreshPromise) { - this.log( - `[claude-code-oauth] Access token expired (expired=${this.credentials.expired}). Refreshing...`, - ) - const prevRefreshToken = this.credentials.refresh_token - this.refreshPromise = refreshAccessToken(this.credentials).then((newCreds) => { - const rotated = newCreds.refresh_token !== prevRefreshToken - this.log( - `[claude-code-oauth] Refresh response received (expires_in≈${Math.round( - (new Date(newCreds.expired).getTime() - Date.now()) / 1000, - )}s, refresh_token_rotated=${rotated})`, - ) - return newCreds - }) - } - - const newCredentials = await this.refreshPromise - this.refreshPromise = null - await this.saveCredentials(newCredentials) - this.log(`[claude-code-oauth] Token persisted (expired=${newCredentials.expired})`) - } catch (error) { - this.refreshPromise = null - this.logError("[claude-code-oauth] Failed to refresh token:", error) - - // Only clear secrets when the refresh token is clearly invalid/revoked. - if (error instanceof ClaudeCodeOAuthTokenError && error.isLikelyInvalidGrant()) { - this.log("[claude-code-oauth] Refresh token appears invalid; clearing stored 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 deleted file mode 100644 index b864995f2cd..00000000000 --- a/src/integrations/claude-code/streaming-client.ts +++ /dev/null @@ -1,759 +0,0 @@ -import type { Anthropic } from "@anthropic-ai/sdk" -import type { ClaudeCodeRateLimitInfo } from "@roo-code/types" -import { Package } from "../../shared/package" - -/** - * Set of content block types that are valid for Anthropic API. - * Only these types will be passed through to the API. - * See: https://docs.anthropic.com/en/api/messages - */ -const VALID_ANTHROPIC_BLOCK_TYPES = new Set([ - "text", - "image", - "tool_use", - "tool_result", - "thinking", - "redacted_thinking", - "document", -]) - -type ContentBlockWithType = { type: string } - -/** - * Filters out non-Anthropic content blocks from messages before sending to the API. - * - * NOTE: This function performs FILTERING ONLY - no type conversion is performed. - * Blocks are either kept as-is or removed entirely based on the allowlist. - * - * Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept. - * This automatically filters out: - * - Internal "reasoning" blocks (Roo Code's internal representation) - NOT converted to "thinking" - * - Gemini's "thoughtSignature" blocks - * - Any other unknown block types - * - * IMPORTANT: This function also strips message-level fields that are not part of the Anthropic API: - * - `reasoning_details` (added by OpenRouter/Roo providers for Gemini/OpenAI reasoning) - * - Any other non-standard fields added by other providers - * - * We preserve ALL "thinking" blocks (Anthropic's native extended thinking format) for these reasons: - * 1. Rewind functionality - users need to be able to go back in conversation history - * 2. Claude Opus 4.5+ preserves thinking blocks by default (per Anthropic docs) - * 3. Interleaved thinking requires thinking blocks to be passed back for tool use continuations - * - * The API will handle thinking blocks appropriately based on the model: - * - Claude Opus 4.5+: thinking blocks preserved (enables cache optimization) - * - Older models: thinking blocks stripped from prior turns automatically - */ -function filterNonAnthropicBlocks(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { - const result: Anthropic.Messages.MessageParam[] = [] - - for (const message of messages) { - // Extract ONLY the standard Anthropic message fields (role, content) - // This strips out any extra fields like `reasoning_details` that other providers - // may have added to the messages (e.g., OpenRouter adds reasoning_details for Gemini/o-series) - const { role, content } = message - - if (typeof content === "string") { - // Return a clean message with only role and content - result.push({ role, content }) - continue - } - - // Filter out invalid block types (allowlist) - const filteredContent = content.filter((block) => - VALID_ANTHROPIC_BLOCK_TYPES.has((block as ContentBlockWithType).type), - ) - - // If all content was filtered out, skip this message - if (filteredContent.length === 0) { - continue - } - - // Return a clean message with only role and content (no extra fields) - result.push({ - role, - content: filteredContent, - }) - } - - return result -} - -/** - * Adds cache_control breakpoints to the last two user messages for prompt caching. - * This follows Anthropic's recommended pattern: - * - Cache the system prompt (handled separately) - * - Cache the last text block of the second-to-last user message - * - Cache the last text block of the last user message - * - * According to Anthropic docs: - * - System prompts and tools remain cached despite thinking parameter changes - * - Message cache breakpoints are invalidated when thinking parameters change - * - When using extended thinking, thinking blocks from previous turns are stripped from context - */ -function addMessageCacheBreakpoints(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { - // Find indices of user messages - const userMsgIndices = messages.reduce( - (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), - [] as number[], - ) - - const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 - const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 - - return messages.map((message, index) => { - // Only add cache control to the last two user messages - if (index !== lastUserMsgIndex && index !== secondLastUserMsgIndex) { - return message - } - - // Handle string content - if (typeof message.content === "string") { - return { - ...message, - content: [ - { - type: "text" as const, - text: message.content, - cache_control: { type: "ephemeral" as const }, - }, - ], - } - } - - // Handle array content - add cache_control to the last text block - const contentWithCache = message.content.map((block, blockIndex) => { - // Find the last text block index - let lastTextIndex = -1 - for (let i = message.content.length - 1; i >= 0; i--) { - if ((message.content[i] as { type: string }).type === "text") { - lastTextIndex = i - break - } - } - - // Only add cache_control to text blocks (the last one specifically) - if (blockIndex === lastTextIndex && (block as { type: string }).type === "text") { - const textBlock = block as { type: "text"; text: string } - return { - type: "text" as const, - text: textBlock.text, - cache_control: { type: "ephemeral" as const }, - } - } - - return block - }) - - return { - ...message, - content: contentWithCache, - } - }) -} - -// API Configuration -export const CLAUDE_CODE_API_CONFIG = { - endpoint: "https://api.anthropic.com/v1/messages", - version: "2023-06-01", - defaultBetas: [ - "prompt-caching-2024-07-31", - "claude-code-20250219", - "oauth-2025-04-20", - "interleaved-thinking-2025-05-14", - "fine-grained-tool-streaming-2025-05-14", - ], - userAgent: `Roo-Code/${Package.version}`, -} as const - -/** - * SSE Event types from Anthropic streaming API - */ -export type SSEEventType = - | "message_start" - | "content_block_start" - | "content_block_delta" - | "content_block_stop" - | "message_delta" - | "message_stop" - | "ping" - | "error" - -export interface SSEEvent { - event: SSEEventType - data: unknown -} - -/** - * Thinking configuration for extended thinking mode - */ -export type ThinkingConfig = - | { - type: "enabled" - budget_tokens: number - } - | { - type: "disabled" - } - -/** - * Stream message request options - */ -export interface StreamMessageOptions { - accessToken: string - model: string - systemPrompt: string - messages: Anthropic.Messages.MessageParam[] - maxTokens?: number - thinking?: ThinkingConfig - tools?: Anthropic.Messages.Tool[] - toolChoice?: Anthropic.Messages.ToolChoice - metadata?: { - user_id?: string - } - signal?: AbortSignal -} - -/** - * SSE Parser state that persists across chunks - * This is necessary because SSE events can be split across multiple chunks - */ -interface SSEParserState { - buffer: string - currentEvent: string | null - currentData: string[] -} - -/** - * Creates initial SSE parser state - */ -function createSSEParserState(): SSEParserState { - return { - buffer: "", - currentEvent: null, - currentData: [], - } -} - -/** - * Parses SSE lines from a text chunk - * Returns parsed events and updates the state for the next chunk - * - * The state persists across chunks to handle events that span multiple chunks: - * - buffer: incomplete line from previous chunk - * - currentEvent: event type if we've seen "event:" but not the complete event - * - currentData: accumulated data lines for the current event - */ -function parseSSEChunk(chunk: string, state: SSEParserState): { events: SSEEvent[]; state: SSEParserState } { - const events: SSEEvent[] = [] - const lines = (state.buffer + chunk).split("\n") - - // Start with the accumulated state - let currentEvent = state.currentEvent - let currentData = [...state.currentData] - let remaining = "" - - for (let i = 0; i < lines.length; i++) { - const line = lines[i] - - // If this is the last line and doesn't end with newline, it might be incomplete - if (i === lines.length - 1 && !chunk.endsWith("\n") && line !== "") { - remaining = line - continue - } - - // Empty line signals end of event - if (line === "") { - if (currentEvent && currentData.length > 0) { - try { - const dataStr = currentData.join("\n") - const data = dataStr === "[DONE]" ? null : JSON.parse(dataStr) - events.push({ - event: currentEvent as SSEEventType, - data, - }) - } catch { - // Skip malformed events - console.error("[claude-code-streaming] Failed to parse SSE data:", currentData.join("\n")) - } - } - currentEvent = null - currentData = [] - continue - } - - // Parse event type - if (line.startsWith("event: ")) { - currentEvent = line.slice(7) - continue - } - - // Parse data - if (line.startsWith("data: ")) { - currentData.push(line.slice(6)) - continue - } - } - - // Return updated state for next chunk - return { - events, - state: { - buffer: remaining, - currentEvent, - currentData, - }, - } -} - -/** - * Stream chunk types that the handler can yield - */ -export interface StreamTextChunk { - type: "text" - text: string -} - -export interface StreamReasoningChunk { - type: "reasoning" - text: string -} - -/** - * A complete thinking block with signature, used for tool use continuations. - * According to Anthropic docs: - * - During tool use, you must pass thinking blocks back to the API for the last assistant message - * - Include the complete unmodified block back to the API to maintain reasoning continuity - * - The signature field is used to verify that thinking blocks were generated by Claude - */ -export interface StreamThinkingCompleteChunk { - type: "thinking_complete" - index: number - thinking: string - signature: string -} - -export interface StreamToolCallPartialChunk { - type: "tool_call_partial" - index: number - id?: string - name?: string - arguments?: string -} - -export interface StreamUsageChunk { - type: "usage" - inputTokens: number - outputTokens: number - cacheReadTokens?: number - cacheWriteTokens?: number - totalCost?: number -} - -export interface StreamErrorChunk { - type: "error" - error: string -} - -export type StreamChunk = - | StreamTextChunk - | StreamReasoningChunk - | StreamThinkingCompleteChunk - | StreamToolCallPartialChunk - | StreamUsageChunk - | StreamErrorChunk - -/** - * Creates a streaming message request to the Anthropic API using OAuth - */ -export async function* createStreamingMessage(options: StreamMessageOptions): AsyncGenerator { - const { accessToken, model, systemPrompt, messages, maxTokens, thinking, tools, toolChoice, metadata, signal } = - options - - // Filter out non-Anthropic blocks before processing - const sanitizedMessages = filterNonAnthropicBlocks(messages) - - // Add cache breakpoints to the last two user messages - // According to Anthropic docs: - // - System prompts and tools remain cached despite thinking parameter changes - // - Message cache breakpoints are invalidated when thinking parameters change - // - We cache the last two user messages for optimal cache hit rates - const messagesWithCache = addMessageCacheBreakpoints(sanitizedMessages) - - // Build request body - match Claude Code format exactly - const body: Record = { - model, - stream: true, - messages: messagesWithCache, - } - - // Only include max_tokens if explicitly provided - if (maxTokens !== undefined) { - body.max_tokens = maxTokens - } - - // Add thinking configuration for extended thinking mode - if (thinking) { - body.thinking = thinking - } - - // System prompt as array of content blocks (Claude Code format) - // Prepend Claude Code branding as required by the API - // Add cache_control to the last text block for prompt caching - // System prompt caching is preserved even when thinking parameters change - body.system = [ - { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, - ...(systemPrompt ? [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }] : []), - ] - - // Metadata with user_id is required for Claude Code - if (metadata) { - body.metadata = metadata - } - - if (tools && tools.length > 0) { - body.tools = tools - // Default tool_choice to "auto" when tools are provided (as per spec example) - body.tool_choice = toolChoice || { type: "auto" } - } else if (toolChoice) { - body.tool_choice = toolChoice - } - - // Build minimal headers - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, - "Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","), - Accept: "text/event-stream", - "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, - } - - // Make the request - const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, { - method: "POST", - headers, - body: JSON.stringify(body), - signal, - }) - - if (!response.ok) { - const errorText = await response.text() - let errorMessage = `API request failed: ${response.status} ${response.statusText}` - try { - const errorJson = JSON.parse(errorText) - if (errorJson.error?.message) { - errorMessage = errorJson.error.message - } - } catch { - if (errorText) { - errorMessage += ` - ${errorText}` - } - } - yield { type: "error", error: errorMessage } - return - } - - if (!response.body) { - yield { type: "error", error: "No response body" } - return - } - - // Track usage across events - let totalInputTokens = 0 - let totalOutputTokens = 0 - let cacheReadTokens = 0 - let cacheWriteTokens = 0 - - // Track content blocks by index for proper assembly - // This is critical for interleaved thinking - we need to capture complete thinking blocks - // with their signatures so they can be passed back to the API for tool use continuations - const contentBlocks: Map< - number, - { - type: string - text: string - signature?: string - id?: string - name?: string - arguments?: string - } - > = new Map() - - // Read the stream - const reader = response.body.getReader() - const decoder = new TextDecoder() - let sseState = createSSEParserState() - - try { - while (true) { - const { done, value } = await reader.read() - if (done) break - - const chunk = decoder.decode(value, { stream: true }) - const result = parseSSEChunk(chunk, sseState) - sseState = result.state - const events = result.events - - for (const event of events) { - const eventData = event.data as Record | null - - if (!eventData) { - continue - } - - switch (event.event) { - case "message_start": { - const message = eventData.message as Record - if (!message) { - break - } - const usage = message.usage as Record | undefined - if (usage) { - totalInputTokens += usage.input_tokens || 0 - totalOutputTokens += usage.output_tokens || 0 - cacheReadTokens += usage.cache_read_input_tokens || 0 - cacheWriteTokens += usage.cache_creation_input_tokens || 0 - } - break - } - - case "content_block_start": { - const contentBlock = eventData.content_block as Record - const index = eventData.index as number - - if (contentBlock) { - switch (contentBlock.type) { - case "text": - // Initialize text block tracking - contentBlocks.set(index, { - type: "text", - text: (contentBlock.text as string) || "", - }) - if (contentBlock.text) { - yield { type: "text", text: contentBlock.text as string } - } - break - case "thinking": - // Initialize thinking block tracking - critical for interleaved thinking - // We need to accumulate the text and capture the signature - contentBlocks.set(index, { - type: "thinking", - text: (contentBlock.thinking as string) || "", - }) - if (contentBlock.thinking) { - yield { type: "reasoning", text: contentBlock.thinking as string } - } - break - case "tool_use": - contentBlocks.set(index, { - type: "tool_use", - text: "", - id: contentBlock.id as string, - name: contentBlock.name as string, - arguments: "", - }) - yield { - type: "tool_call_partial", - index, - id: contentBlock.id as string, - name: contentBlock.name as string, - arguments: undefined, - } - break - } - } - break - } - - case "content_block_delta": { - const delta = eventData.delta as Record - const index = eventData.index as number - const block = contentBlocks.get(index) - - if (delta) { - switch (delta.type) { - case "text_delta": - if (delta.text) { - // Accumulate text - if (block && block.type === "text") { - block.text += delta.text as string - } - yield { type: "text", text: delta.text as string } - } - break - case "thinking_delta": - if (delta.thinking) { - // Accumulate thinking text - if (block && block.type === "thinking") { - block.text += delta.thinking as string - } - yield { type: "reasoning", text: delta.thinking as string } - } - break - case "signature_delta": - // Capture the signature for the thinking block - // This is critical for interleaved thinking - the signature - // must be included when passing thinking blocks back to the API - if (delta.signature && block && block.type === "thinking") { - block.signature = delta.signature as string - } - break - case "input_json_delta": - if (block && block.type === "tool_use") { - block.arguments = (block.arguments || "") + (delta.partial_json as string) - } - yield { - type: "tool_call_partial", - index, - id: undefined, - name: undefined, - arguments: delta.partial_json as string, - } - break - } - } - break - } - - case "content_block_stop": { - // When a content block completes, emit complete thinking blocks - // This enables the caller to preserve them for tool use continuations - const index = eventData.index as number - const block = contentBlocks.get(index) - - if (block && block.type === "thinking" && block.signature) { - // Emit the complete thinking block with signature - // This is required for interleaved thinking with tool use - yield { - type: "thinking_complete", - index, - thinking: block.text, - signature: block.signature, - } - } - break - } - - case "message_delta": { - const usage = eventData.usage as Record | undefined - if (usage && usage.output_tokens !== undefined) { - // output_tokens in message_delta is the running total, not a delta - // So we replace rather than add - totalOutputTokens = usage.output_tokens - } - break - } - - case "message_stop": { - // Yield final usage chunk - yield { - type: "usage", - inputTokens: totalInputTokens, - outputTokens: totalOutputTokens, - cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, - cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, - } - break - } - - case "error": { - const errorData = eventData.error as Record - yield { - type: "error", - error: (errorData?.message as string) || "Unknown streaming error", - } - break - } - } - } - } - } finally { - reader.releaseLock() - } -} - -/** - * Parse rate limit headers from a response into a structured format - */ -function parseRateLimitHeaders(headers: Headers): ClaudeCodeRateLimitInfo { - const getHeader = (name: string): string | null => headers.get(name) - const parseFloat = (val: string | null): number => (val ? Number.parseFloat(val) : 0) - const parseInt = (val: string | null): number => (val ? Number.parseInt(val, 10) : 0) - - return { - fiveHour: { - status: getHeader("anthropic-ratelimit-unified-5h-status") || "unknown", - utilization: parseFloat(getHeader("anthropic-ratelimit-unified-5h-utilization")), - resetTime: parseInt(getHeader("anthropic-ratelimit-unified-5h-reset")), - }, - weekly: { - status: getHeader("anthropic-ratelimit-unified-7d_sonnet-status") || "unknown", - utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d_sonnet-utilization")), - resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d_sonnet-reset")), - }, - weeklyUnified: { - status: getHeader("anthropic-ratelimit-unified-7d-status") || "unknown", - utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d-utilization")), - resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d-reset")), - }, - representativeClaim: getHeader("anthropic-ratelimit-unified-representative-claim") || undefined, - overage: { - status: getHeader("anthropic-ratelimit-unified-overage-status") || "unknown", - disabledReason: getHeader("anthropic-ratelimit-unified-overage-disabled-reason") || undefined, - }, - fallbackPercentage: parseFloat(getHeader("anthropic-ratelimit-unified-fallback-percentage")) || undefined, - organizationId: getHeader("anthropic-organization-id") || undefined, - fetchedAt: Date.now(), - } -} - -/** - * Fetch rate limit information by making a minimal API call - * Uses a small request to get the response headers containing rate limit data - */ -export async function fetchRateLimitInfo(accessToken: string): Promise { - // Build minimal request body - use haiku for speed and lowest cost - const body = { - model: "claude-haiku-4-5", - max_tokens: 1, - system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }], - messages: [{ role: "user", content: "hi" }], - } - - // Build minimal headers - const headers: Record = { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, - "Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","), - "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, - } - - // Make the request - const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, { - method: "POST", - headers, - body: JSON.stringify(body), - signal: AbortSignal.timeout(30000), - }) - - if (!response.ok) { - const errorText = await response.text() - let errorMessage = `API request failed: ${response.status} ${response.statusText}` - try { - const errorJson = JSON.parse(errorText) - if (errorJson.error?.message) { - errorMessage = errorJson.error.message - } - } catch { - if (errorText) { - errorMessage += ` - ${errorText}` - } - } - throw new Error(errorMessage) - } - - // Parse rate limit headers from the response - return parseRateLimitHeaders(response.headers) -} diff --git a/src/shared/ExtensionMessage.ts b/src/shared/ExtensionMessage.ts index 2eec4cb6c88..fd3ecd8db01 100644 --- a/src/shared/ExtensionMessage.ts +++ b/src/shared/ExtensionMessage.ts @@ -130,7 +130,6 @@ export interface ExtensionMessage { | "interactionRequired" | "browserSessionUpdate" | "browserSessionNavigate" - | "claudeCodeRateLimits" | "customToolsResult" text?: string payload?: any // Add a generic payload for now, can refine later @@ -359,7 +358,6 @@ 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 4c3e321dea8..ffaaa8a2d0c 100644 --- a/src/shared/WebviewMessage.ts +++ b/src/shared/WebviewMessage.ts @@ -126,8 +126,6 @@ export interface WebviewMessage { | "cloudLandingPageSignIn" | "rooCloudSignOut" | "rooCloudManualUrl" - | "claudeCodeSignIn" - | "claudeCodeSignOut" | "switchOrganization" | "condenseTaskContextRequest" | "requestIndexingStatus" @@ -178,7 +176,6 @@ export interface WebviewMessage { | "openDebugApiHistory" | "openDebugUiHistory" | "downloadErrorDiagnostics" - | "requestClaudeCodeRateLimits" | "refreshCustomTools" text?: string editedMessageContent?: string diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index 278a97424c2..f8830d8b647 100644 --- a/src/shared/__tests__/api.spec.ts +++ b/src/shared/__tests__/api.spec.ts @@ -9,7 +9,7 @@ describe("getModelMaxOutputTokens", () => { supportsPromptCache: true, } - test("should return model maxTokens when not using claude-code provider and maxTokens is within 20% of context window", () => { + test("should return model maxTokens when maxTokens is within 20% of context window", () => { const settings: ProviderSettings = { apiProvider: "anthropic", } diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index 37b468ce1ac..2a633f4eee2 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -5,8 +5,8 @@ export function checkExistKey(config: ProviderSettings | undefined) { return false } - // Special case for fake-ai, claude-code, qwen-code, and roo providers which don't need any configuration. - if (config.apiProvider && ["fake-ai", "claude-code", "qwen-code", "roo"].includes(config.apiProvider)) { + // Special case for fake-ai, qwen-code, and roo providers which don't need any configuration. + if (config.apiProvider && ["fake-ai", "qwen-code", "roo"].includes(config.apiProvider)) { return true } diff --git a/webview-ui/src/components/chat/ChatRow.tsx b/webview-ui/src/components/chat/ChatRow.tsx index ef2231b26d1..fda06c06e56 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1104,36 +1104,30 @@ export const ChatRowContent = ({ let body = t(`chat:apiRequest.failed`) let retryInfo, rawError, code, docsURL if (message.text !== undefined) { - // Check for Claude Code authentication error first - if (message.text.includes("Not authenticated with Claude Code")) { - body = t("chat:apiRequest.errorMessage.claudeCodeNotAuthenticated") - docsURL = "roocode://settings?provider=claude-code" - } else { - // Try to show richer error message for that code, if available - const potentialCode = parseInt(message.text.substring(0, 3)) - if (!isNaN(potentialCode) && potentialCode >= 400) { - code = potentialCode - const stringForError = `chat:apiRequest.errorMessage.${code}` - if (i18n.exists(stringForError)) { - body = t(stringForError) - // Fill this out in upcoming PRs - // Do not remove this - // switch(code) { - // case ERROR_CODE: - // docsURL = ??? - // break; - // } - } else { - body = t("chat:apiRequest.errorMessage.unknown") - docsURL = "mailto:support@roocode.com?subject=Unknown API Error" - } - } else if (message.text.indexOf("Connection error") === 0) { - body = t("chat:apiRequest.errorMessage.connection") + // Try to show richer error message for that code, if available + const potentialCode = parseInt(message.text.substring(0, 3)) + if (!isNaN(potentialCode) && potentialCode >= 400) { + code = potentialCode + const stringForError = `chat:apiRequest.errorMessage.${code}` + if (i18n.exists(stringForError)) { + body = t(stringForError) + // Fill this out in upcoming PRs + // Do not remove this + // switch(code) { + // case ERROR_CODE: + // docsURL = ??? + // break; + // } } else { - // Non-HTTP-status-code error message - store full text as errorDetails body = t("chat:apiRequest.errorMessage.unknown") docsURL = "mailto:support@roocode.com?subject=Unknown API Error" } + } else if (message.text.indexOf("Connection error") === 0) { + body = t("chat:apiRequest.errorMessage.connection") + } else { + // Non-HTTP-status-code error message - store full text as errorDetails + body = t("chat:apiRequest.errorMessage.unknown") + docsURL = "mailto:support@roocode.com?subject=Unknown API Error" } // This isn't pretty, but since the retry logic happens at a lower level diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index 8e2c117e7c0..8b3dec656ff 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -15,7 +15,6 @@ import { openAiNativeDefaultModelId, anthropicDefaultModelId, doubaoDefaultModelId, - claudeCodeDefaultModelId, qwenCodeDefaultModelId, geminiDefaultModelId, deepSeekDefaultModelId, @@ -69,7 +68,6 @@ import { Bedrock, Cerebras, Chutes, - ClaudeCode, DeepSeek, Doubao, Gemini, @@ -138,7 +136,7 @@ const ApiOptions = ({ setErrorMessage, }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { organizationAllowList, cloudIsAuthenticated, claudeCodeIsAuthenticated } = useExtensionState() + const { organizationAllowList, cloudIsAuthenticated } = useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -341,7 +339,6 @@ const ApiOptions = ({ litellm: { field: "litellmModelId", default: litellmDefaultModelId }, anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, cerebras: { field: "apiModelId", default: cerebrasDefaultModelId }, - "claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId }, "qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId }, "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, gemini: { field: "apiModelId", default: geminiDefaultModelId }, @@ -554,15 +551,6 @@ const ApiOptions = ({ /> )} - {selectedProvider === "claude-code" && ( - - )} - {selectedProvider === "openai-native" && ( )} - {/* Skip generic model picker for claude-code since it has its own in ClaudeCode.tsx */} - {selectedProviderModels.length > 0 && selectedProvider !== "claude-code" && ( + {selectedProviderModels.length > 0 && ( <>
diff --git a/webview-ui/src/components/settings/constants.ts b/webview-ui/src/components/settings/constants.ts index d35ec547b4d..7f519f2549f 100644 --- a/webview-ui/src/components/settings/constants.ts +++ b/webview-ui/src/components/settings/constants.ts @@ -4,7 +4,6 @@ import { anthropicModels, bedrockModels, cerebrasModels, - claudeCodeModels, deepSeekModels, moonshotModels, geminiModels, @@ -25,7 +24,6 @@ import { export const MODELS_BY_PROVIDER: Partial>> = { anthropic: anthropicModels, - "claude-code": claudeCodeModels, bedrock: bedrockModels, cerebras: cerebrasModels, deepseek: deepSeekModels, @@ -50,7 +48,6 @@ export const PROVIDERS = [ { value: "openrouter", label: "OpenRouter" }, { value: "deepinfra", label: "DeepInfra" }, { value: "anthropic", label: "Anthropic" }, - { value: "claude-code", label: "Claude Code" }, { value: "cerebras", label: "Cerebras" }, { value: "gemini", label: "Google Gemini" }, { value: "doubao", label: "Doubao" }, diff --git a/webview-ui/src/components/settings/providers/ClaudeCode.tsx b/webview-ui/src/components/settings/providers/ClaudeCode.tsx deleted file mode 100644 index 87072a9b976..00000000000 --- a/webview-ui/src/components/settings/providers/ClaudeCode.tsx +++ /dev/null @@ -1,68 +0,0 @@ -import React from "react" -import { type ProviderSettings, claudeCodeDefaultModelId, claudeCodeModels } from "@roo-code/types" -import { useAppTranslation } from "@src/i18n/TranslationContext" -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, - simplifySettings, - claudeCodeIsAuthenticated = false, -}) => { - const { t } = useAppTranslation() - - return ( -
- {/* Authentication Section */} -
- {claudeCodeIsAuthenticated ? ( -
- -
- ) : ( - - )} -
- - {/* 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 deleted file mode 100644 index 9b152c27177..00000000000 --- a/webview-ui/src/components/settings/providers/ClaudeCodeRateLimitDashboard.tsx +++ /dev/null @@ -1,181 +0,0 @@ -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/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index 0dd722a5220..d7433694ac7 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -2,7 +2,6 @@ export { Anthropic } from "./Anthropic" export { Bedrock } from "./Bedrock" export { Cerebras } from "./Cerebras" export { Chutes } from "./Chutes" -export { ClaudeCode } from "./ClaudeCode" export { DeepSeek } from "./DeepSeek" export { Doubao } from "./Doubao" export { Gemini } from "./Gemini" diff --git a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts index 4dca874ee20..32502d82fbc 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -412,77 +412,6 @@ describe("useSelectedModel", () => { }) }) - describe("claude-code provider", () => { - it("should return claude-code model with correct model info", () => { - mockUseRouterModels.mockReturnValue({ - data: { - openrouter: {}, - requesty: {}, - unbound: {}, - litellm: {}, - "io-intelligence": {}, - }, - isLoading: false, - isError: false, - } as any) - - mockUseOpenRouterModelProviders.mockReturnValue({ - data: {}, - isLoading: false, - isError: false, - } as any) - - const apiConfiguration: ProviderSettings = { - apiProvider: "claude-code", - apiModelId: "claude-sonnet-4-5", // Use valid claude-code model ID - } - - const wrapper = createWrapper() - const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - - expect(result.current.provider).toBe("claude-code") - expect(result.current.id).toBe("claude-sonnet-4-5") - expect(result.current.info).toBeDefined() - expect(result.current.info?.supportsImages).toBe(true) // Claude Code now supports images - expect(result.current.info?.supportsPromptCache).toBe(true) // Claude Code now supports prompt cache - // Verify it inherits other properties from claude-code models - expect(result.current.info?.maxTokens).toBe(32768) - expect(result.current.info?.contextWindow).toBe(200_000) - }) - - it("should use default claude-code model when no modelId is specified", () => { - mockUseRouterModels.mockReturnValue({ - data: { - openrouter: {}, - requesty: {}, - unbound: {}, - litellm: {}, - "io-intelligence": {}, - }, - isLoading: false, - isError: false, - } as any) - - mockUseOpenRouterModelProviders.mockReturnValue({ - data: {}, - isLoading: false, - isError: false, - } as any) - - const apiConfiguration: ProviderSettings = { - apiProvider: "claude-code", - } - - const wrapper = createWrapper() - const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) - - expect(result.current.provider).toBe("claude-code") - expect(result.current.id).toBe("claude-sonnet-4-5") // Default model - expect(result.current.info).toBeDefined() - expect(result.current.info?.supportsImages).toBe(true) // Claude Code now supports images - }) - }) - describe("bedrock provider with 1M context", () => { beforeEach(() => { mockUseRouterModels.mockReturnValue({ diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index 68f54ab0f3c..e256e4fee93 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -17,8 +17,6 @@ import { groqModels, vscodeLlmModels, vscodeLlmDefaultModelId, - claudeCodeModels, - normalizeClaudeCodeModelId, sambaNovaModels, doubaoModels, internationalZAiModels, @@ -333,14 +331,6 @@ function getSelectedModel({ const info = vscodeLlmModels[modelFamily as keyof typeof vscodeLlmModels] return { id, info: { ...openAiModelInfoSaneDefaults, ...info, supportsImages: false } } // VSCode LM API currently doesn't support images. } - case "claude-code": { - // Claude Code models extend anthropic models but with images and prompt caching disabled - // Normalize legacy model IDs to current canonical model IDs for backward compatibility - const rawId = apiConfiguration.apiModelId ?? defaultModelId - const normalizedId = normalizeClaudeCodeModelId(rawId) - const info = claudeCodeModels[normalizedId] - return { id: normalizedId, info: { ...openAiModelInfoSaneDefaults, ...info } } - } case "cerebras": { const id = apiConfiguration.apiModelId ?? defaultModelId const info = cerebrasModels[id as keyof typeof cerebrasModels]