From eafc73c88c86333c5c4d42be8e9425355ad2a6e3 Mon Sep 17 00:00:00 2001 From: My Name Date: Fri, 23 Jan 2026 11:00:28 +0100 Subject: [PATCH 01/14] Revert "feat: remove Claude Code provider (#10883)" This reverts commit 7f854c0dd7ed25dac68a2310346708b4b64b48d9. Restores the Claude Code provider feature that allows using Claude Code OAuth tokens for API access. This includes: - Claude Code API provider and handler - OAuth authentication flow - Streaming client implementation - Rate limit dashboard UI - Settings panel integration - Associated tests Co-Authored-By: Claude Opus 4.5 --- .../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 | 154 ++++ packages/types/src/providers/index.ts | 4 + packages/types/src/vscode-extension-host.ts | 5 + src/api/index.ts | 3 + .../__tests__/claude-code-caching.spec.ts | 169 ++++ .../providers/__tests__/claude-code.spec.ts | 606 ++++++++++++++ src/api/providers/claude-code.ts | 378 +++++++++ src/api/providers/index.ts | 1 + src/core/config/ProviderSettingsManager.ts | 3 +- .../config/__tests__/importExport.spec.ts | 60 +- 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/__tests__/api.spec.ts | 2 +- .../__tests__/checkExistApiConfig.spec.ts | 7 + src/shared/checkExistApiConfig.ts | 7 +- webview-ui/src/components/chat/ChatRow.tsx | 40 +- .../src/components/settings/ApiOptions.tsx | 15 +- .../src/components/settings/constants.ts | 3 + .../settings/providers/ClaudeCode.tsx | 71 ++ .../ClaudeCodeRateLimitDashboard.tsx | 181 +++++ .../components/settings/providers/index.ts | 1 + .../__tests__/providerModelConfig.spec.ts | 1 + .../settings/utils/providerModelConfig.ts | 1 + .../hooks/__tests__/useSelectedModel.spec.ts | 71 ++ .../components/ui/hooks/useSelectedModel.ts | 10 + 33 files changed, 4107 insertions(+), 46 deletions(-) create mode 100644 packages/types/src/providers/__tests__/claude-code.spec.ts create mode 100644 packages/types/src/providers/claude-code.ts create mode 100644 src/api/providers/__tests__/claude-code-caching.spec.ts create mode 100644 src/api/providers/__tests__/claude-code.spec.ts create mode 100644 src/api/providers/claude-code.ts create mode 100644 src/integrations/claude-code/__tests__/oauth.spec.ts create mode 100644 src/integrations/claude-code/__tests__/streaming-client.spec.ts create mode 100644 src/integrations/claude-code/oauth.ts create mode 100644 src/integrations/claude-code/streaming-client.ts create mode 100644 webview-ui/src/components/settings/providers/ClaudeCode.tsx create 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 fc7bee2268a..cedf9a3e2f3 100644 --- a/packages/types/src/__tests__/provider-settings.test.ts +++ b/packages/types/src/__tests__/provider-settings.test.ts @@ -7,6 +7,11 @@ 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 ba652269b14..9b6f8328b83 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -7,6 +7,7 @@ import { basetenModels, bedrockModels, cerebrasModels, + claudeCodeModels, deepSeekModels, doubaoModels, featherlessModels, @@ -122,6 +123,7 @@ export const providerNames = [ "bedrock", "baseten", "cerebras", + "claude-code", "doubao", "deepseek", "featherless", @@ -197,6 +199,8 @@ 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,6 +429,7 @@ 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,6 +471,7 @@ 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,6 +560,7 @@ 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 +600,7 @@ export const modelIdKeysByProvider: Record = { */ // Providers that use Anthropic-style API protocol. -export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "bedrock", "minimax"] +export const ANTHROPIC_STYLE_PROVIDERS: ProviderName[] = ["anthropic", "claude-code", "bedrock", "minimax"] export const getApiProtocol = (provider: ProviderName | undefined, modelId?: string): "anthropic" | "openai" => { if (provider && ANTHROPIC_STYLE_PROVIDERS.includes(provider)) { @@ -640,6 +647,7 @@ 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 new file mode 100644 index 00000000000..5ed66209a53 --- /dev/null +++ b/packages/types/src/providers/__tests__/claude-code.spec.ts @@ -0,0 +1,46 @@ +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 new file mode 100644 index 00000000000..2cc24f690e5 --- /dev/null +++ b/packages/types/src/providers/claude-code.ts @@ -0,0 +1,154 @@ +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, + 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, + 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, + 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 2018954bbdd..6e56ee87295 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -3,6 +3,7 @@ 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" @@ -38,6 +39,7 @@ 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" @@ -126,6 +128,8 @@ export function getProviderDefaultModelId( return deepInfraDefaultModelId case "vscode-lm": return vscodeLlmDefaultModelId + case "claude-code": + return claudeCodeDefaultModelId case "cerebras": return cerebrasDefaultModelId case "sambanova": diff --git a/packages/types/src/vscode-extension-host.ts b/packages/types/src/vscode-extension-host.ts index f7d034f4fa8..d32d2fcd702 100644 --- a/packages/types/src/vscode-extension-host.ts +++ b/packages/types/src/vscode-extension-host.ts @@ -95,6 +95,7 @@ export interface ExtensionMessage { | "interactionRequired" | "browserSessionUpdate" | "browserSessionNavigate" + | "claudeCodeRateLimits" | "customToolsResult" | "modes" | "taskWithAggregatedCosts" @@ -409,6 +410,7 @@ export type ExtensionState = Pick< remoteControlEnabled: boolean taskSyncEnabled: boolean featureRoomoteControlEnabled: boolean + claudeCodeIsAuthenticated?: boolean openAiCodexIsAuthenticated?: boolean debug?: boolean } @@ -537,6 +539,8 @@ export interface WebviewMessage { | "cloudLandingPageSignIn" | "rooCloudSignOut" | "rooCloudManualUrl" + | "claudeCodeSignIn" + | "claudeCodeSignOut" | "openAiCodexSignIn" | "openAiCodexSignOut" | "switchOrganization" @@ -591,6 +595,7 @@ export interface WebviewMessage { | "openDebugApiHistory" | "openDebugUiHistory" | "downloadErrorDiagnostics" + | "requestClaudeCodeRateLimits" | "requestOpenAiCodexRateLimits" | "refreshCustomTools" | "requestModes" diff --git a/src/api/index.ts b/src/api/index.ts index 1995380a68d..711b0f1cd15 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -29,6 +29,7 @@ import { HuggingFaceHandler, ChutesHandler, LiteLLMHandler, + ClaudeCodeHandler, QwenCodeHandler, SambaNovaHandler, IOIntelligenceHandler, @@ -125,6 +126,8 @@ 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 new file mode 100644 index 00000000000..a0996ab244b --- /dev/null +++ b/src/api/providers/__tests__/claude-code-caching.spec.ts @@ -0,0 +1,169 @@ +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 new file mode 100644 index 00000000000..6f5ccbda979 --- /dev/null +++ b/src/api/providers/__tests__/claude-code.spec.ts @@ -0,0 +1,606 @@ +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( + expect.objectContaining({ + 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 are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) + tools: expect.any(Array), + toolChoice: expect.any(Object), + 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( + expect.objectContaining({ + accessToken: "test-access-token", + model: "claude-sonnet-4-5", + systemPrompt, + messages, + maxTokens: 32768, // model maxTokens from claudeCodeModels definition + thinking: { type: "disabled" }, + // Tools are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) + tools: expect.any(Array), + toolChoice: expect.any(Object), + 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( + expect.objectContaining({ + 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 are now always present (minimum 6 from ALWAYS_AVAILABLE_TOOLS) + tools: expect.any(Array), + toolChoice: expect.any(Object), + 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 new file mode 100644 index 00000000000..db7eaae5224 --- /dev/null +++ b/src/api/providers/claude-code.ts @@ -0,0 +1,378 @@ +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) + + const anthropicTools = convertOpenAIToolsToAnthropic(metadata?.tools ?? []) + const anthropicToolChoice = convertOpenAIToolChoice(metadata?.tool_choice, metadata?.parallelToolCalls) + + // 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 141839e29f9..1e0ae50c9d2 100644 --- a/src/api/providers/index.ts +++ b/src/api/providers/index.ts @@ -3,6 +3,7 @@ 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/ProviderSettingsManager.ts b/src/core/config/ProviderSettingsManager.ts index bf145f09c2d..420ab332b24 100644 --- a/src/core/config/ProviderSettingsManager.ts +++ b/src/core/config/ProviderSettingsManager.ts @@ -183,8 +183,7 @@ export class ProviderSettingsManager { if (!providerProfiles.migrations.claudeCodeLegacySettingsMigrated) { // These keys were used by the removed local Claude Code CLI wrapper. for (const apiConfig of Object.values(providerProfiles.apiConfigs)) { - // Cast to string for comparison since "claude-code" is no longer a valid ProviderName - if ((apiConfig.apiProvider as string) !== "claude-code") continue + if (apiConfig.apiProvider !== "claude-code") continue const config = apiConfig as unknown as Record if ("claudeCodePath" in config) { diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 9aee8693c67..9abcb14bf4b 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -68,6 +68,15 @@ 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") { + return { + id: config.apiModelId || "claude-sonnet-4-5", + info: { + supportsReasoningBudget: false, + requiredReasoningBudget: false, + }, + } + } if (config.apiProvider === "anthropic" && config.apiModelId === "claude-3-5-sonnet-20241022") { return { id: "claude-3-5-sonnet-20241022", @@ -476,17 +485,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 ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) const mockFileContent = JSON.stringify({ providerProfiles: { - currentApiConfigName: "openai-provider", + currentApiConfigName: "claude-code-provider", apiConfigs: { - "openai-provider": { - apiProvider: "openai" as ProviderName, - apiModelId: "gpt-4", - id: "openai-id", + "claude-code-provider": { + apiProvider: "claude-code" as ProviderName, + apiModelId: "claude-3-5-sonnet-20241022", + id: "claude-code-id", apiKey: "test-key", // No modelMaxTokens or modelMaxThinkingTokens fields }, @@ -504,7 +514,7 @@ describe("importExport", () => { mockProviderSettingsManager.export.mockResolvedValue(previousProviderProfiles) mockProviderSettingsManager.listConfig.mockResolvedValue([ - { name: "openai-provider", id: "openai-id", apiProvider: "openai" as ProviderName }, + { name: "claude-code-provider", id: "claude-code-id", apiProvider: "claude-code" as ProviderName }, { name: "default", id: "default-id", apiProvider: "anthropic" as ProviderName }, ]) @@ -521,21 +531,21 @@ describe("importExport", () => { expect(mockProviderSettingsManager.export).toHaveBeenCalled() expect(mockProviderSettingsManager.import).toHaveBeenCalledWith({ - currentApiConfigName: "openai-provider", + currentApiConfigName: "claude-code-provider", apiConfigs: { default: { apiProvider: "anthropic" as ProviderName, id: "default-id" }, - "openai-provider": { - apiProvider: "openai" as ProviderName, - apiModelId: "gpt-4", + "claude-code-provider": { + apiProvider: "claude-code" as ProviderName, + apiModelId: "claude-3-5-sonnet-20241022", apiKey: "test-key", - id: "openai-id", + id: "claude-code-id", }, }, modeApiConfigs: {}, }) expect(mockContextProxy.setValues).toHaveBeenCalledWith({ mode: "code", autoApprovalEnabled: true }) - expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "openai-provider") + expect(mockContextProxy.setValue).toHaveBeenCalledWith("currentApiConfigName", "claude-code-provider") }) }) @@ -1713,27 +1723,27 @@ describe("importExport", () => { it.each([ { testCase: "supportsReasoningBudget is false", - providerName: "deepseek-provider", - modelId: "deepseek-chat", - providerId: "deepseek-id", + providerName: "claude-code-provider", + modelId: "claude-sonnet-4-5", + providerId: "claude-code-id", }, { testCase: "requiredReasoningBudget is false", - providerName: "deepseek-provider-2", - modelId: "deepseek-coder", - providerId: "deepseek-id-2", + providerName: "claude-code-provider-2", + modelId: "claude-sonnet-4-5", + providerId: "claude-code-id-2", }, { testCase: "both supportsReasoningBudget and requiredReasoningBudget are false", - providerName: "deepseek-provider-3", - modelId: "deepseek-reasoner", - providerId: "deepseek-id-3", + providerName: "claude-code-provider-3", + modelId: "claude-3-5-haiku-20241022", + providerId: "claude-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 deepseek provider which uses apiModelId and has supportsReasoningBudget: false + // Using claude-code provider which has supportsReasoningBudget: false and requiredReasoningBudget: false ;(vscode.window.showSaveDialog as Mock).mockResolvedValue({ fsPath: "/mock/path/roo-code-settings.json", @@ -1745,12 +1755,12 @@ describe("importExport", () => { // Wait for initialization to complete await realProviderSettingsManager.initialize() - // Save a deepseek provider config with token fields + // Save a claude-code provider config with token fields await realProviderSettingsManager.saveConfig(providerName, { - apiProvider: "deepseek" as ProviderName, + apiProvider: "claude-code" as ProviderName, apiModelId: modelId, id: providerId, - deepSeekApiKey: "test-key", + apiKey: "test-key", modelMaxTokens: 4096, // This should be removed during export modelMaxThinkingTokens: 2048, // This should be removed during export }) diff --git a/src/core/webview/ClineProvider.ts b/src/core/webview/ClineProvider.ts index 5335e372f0d..b4e5fa8a344 100644 --- a/src/core/webview/ClineProvider.ts +++ b/src/core/webview/ClineProvider.ts @@ -2223,6 +2223,14 @@ 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 + } + })(), openAiCodexIsAuthenticated: await (async () => { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") diff --git a/src/core/webview/webviewMessageHandler.ts b/src/core/webview/webviewMessageHandler.ts index 41968ffb1fd..726ccf909b0 100644 --- a/src/core/webview/webviewMessageHandler.ts +++ b/src/core/webview/webviewMessageHandler.ts @@ -2391,6 +2391,45 @@ export const webviewMessageHandler = async ( break } + case "claudeCodeSignIn": { + try { + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + const authUrl = claudeCodeOAuthManager.startAuthorizationFlow() + + // Open the authorization URL in the browser + await vscode.env.openExternal(vscode.Uri.parse(authUrl)) + + // Wait for the callback in a separate promise (non-blocking) + claudeCodeOAuthManager + .waitForCallback() + .then(async () => { + vscode.window.showInformationMessage("Successfully signed in to Claude Code") + await provider.postStateToWebview() + }) + .catch((error) => { + provider.log(`Claude Code OAuth callback failed: ${error}`) + if (!String(error).includes("timed out")) { + vscode.window.showErrorMessage(`Claude Code sign in failed: ${error.message || error}`) + } + }) + } catch (error) { + provider.log(`Claude Code OAuth failed: ${error}`) + vscode.window.showErrorMessage("Claude Code sign in failed.") + } + break + } + case "claudeCodeSignOut": { + try { + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + await claudeCodeOAuthManager.clearCredentials() + vscode.window.showInformationMessage("Signed out from Claude Code") + await provider.postStateToWebview() + } catch (error) { + provider.log(`Claude Code sign out failed: ${error}`) + vscode.window.showErrorMessage("Claude Code sign out failed.") + } + break + } case "openAiCodexSignIn": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") @@ -3234,6 +3273,37 @@ export const webviewMessageHandler = async ( break } + case "requestClaudeCodeRateLimits": { + try { + const { claudeCodeOAuthManager } = await import("../../integrations/claude-code/oauth") + const accessToken = await claudeCodeOAuthManager.getAccessToken() + + if (!accessToken) { + provider.postMessageToWebview({ + type: "claudeCodeRateLimits", + error: "Not authenticated with Claude Code", + }) + break + } + + const { fetchRateLimitInfo } = await import("../../integrations/claude-code/streaming-client") + const rateLimits = await fetchRateLimitInfo(accessToken) + + provider.postMessageToWebview({ + type: "claudeCodeRateLimits", + values: rateLimits, + }) + } catch (error) { + const errorMessage = error instanceof Error ? error.message : String(error) + provider.log(`Error fetching Claude Code rate limits: ${errorMessage}`) + provider.postMessageToWebview({ + type: "claudeCodeRateLimits", + error: errorMessage, + }) + } + break + } + case "requestOpenAiCodexRateLimits": { try { const { openAiCodexOAuthManager } = await import("../../integrations/openai-codex/oauth") diff --git a/src/extension.ts b/src/extension.ts index bcfbe339932..b58f3ce0fa0 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -27,6 +27,7 @@ import { ContextProxy } from "./core/config/ContextProxy" import { ClineProvider } from "./core/webview/ClineProvider" import { DIFF_VIEW_URI_SCHEME } from "./integrations/editor/DiffViewProvider" import { TerminalRegistry } from "./integrations/terminal/TerminalRegistry" +import { claudeCodeOAuthManager } from "./integrations/claude-code/oauth" import { openAiCodexOAuthManager } from "./integrations/openai-codex/oauth" import { McpServerManager } from "./services/mcp/McpServerManager" import { CodeIndexManager } from "./services/code-index/manager" @@ -150,6 +151,9 @@ export async function activate(context: vscode.ExtensionContext) { // Initialize terminal shell execution handlers. TerminalRegistry.initialize() + // Initialize Claude Code OAuth manager for direct API access. + claudeCodeOAuthManager.initialize(context, (message) => outputChannel.appendLine(message)) + // Initialize OpenAI Codex OAuth manager for ChatGPT subscription-based access. openAiCodexOAuthManager.initialize(context, (message) => outputChannel.appendLine(message)) diff --git a/src/integrations/claude-code/__tests__/oauth.spec.ts b/src/integrations/claude-code/__tests__/oauth.spec.ts new file mode 100644 index 00000000000..7de75ec5292 --- /dev/null +++ b/src/integrations/claude-code/__tests__/oauth.spec.ts @@ -0,0 +1,235 @@ +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 new file mode 100644 index 00000000000..8ccb108827d --- /dev/null +++ b/src/integrations/claude-code/__tests__/streaming-client.spec.ts @@ -0,0 +1,585 @@ +import { CLAUDE_CODE_API_CONFIG } from "../streaming-client" + +describe("Claude Code Streaming Client", () => { + describe("CLAUDE_CODE_API_CONFIG", () => { + test("should have correct API endpoint", () => { + expect(CLAUDE_CODE_API_CONFIG.endpoint).toBe("https://api.anthropic.com/v1/messages") + }) + + test("should have correct API version", () => { + expect(CLAUDE_CODE_API_CONFIG.version).toBe("2023-06-01") + }) + + test("should have correct default betas", () => { + expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("claude-code-20250219") + expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("oauth-2025-04-20") + expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("interleaved-thinking-2025-05-14") + expect(CLAUDE_CODE_API_CONFIG.defaultBetas).toContain("fine-grained-tool-streaming-2025-05-14") + }) + + test("should have correct user agent", () => { + expect(CLAUDE_CODE_API_CONFIG.userAgent).toMatch(/^Roo-Code\/\d+\.\d+\.\d+$/) + }) + }) + + describe("createStreamingMessage", () => { + let originalFetch: typeof global.fetch + + beforeEach(() => { + originalFetch = global.fetch + }) + + afterEach(() => { + global.fetch = originalFetch + }) + + test("should make request with correct headers", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + expect(mockFetch).toHaveBeenCalledWith( + expect.stringContaining(CLAUDE_CODE_API_CONFIG.endpoint), + expect.objectContaining({ + method: "POST", + headers: expect.objectContaining({ + Authorization: "Bearer test-token", + "Content-Type": "application/json", + "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, + Accept: "text/event-stream", + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, + }), + }), + ) + }) + + test("should include correct body parameters", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + maxTokens: 4096, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + expect(body.model).toBe("claude-3-5-sonnet-20241022") + expect(body.stream).toBe(true) + expect(body.max_tokens).toBe(4096) + // System prompt should have cache_control on the user-provided text + expect(body.system).toEqual([ + { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, + { type: "text", text: "You are helpful", cache_control: { type: "ephemeral" } }, + ]) + // Messages should have cache_control on the last user message + expect(body.messages).toEqual([ + { + role: "user", + content: [{ type: "text", text: "Hello", cache_control: { type: "ephemeral" } }], + }, + ]) + }) + + test("should add cache breakpoints to last two user messages", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [ + { role: "user", content: "First message" }, + { role: "assistant", content: "Response" }, + { role: "user", content: "Second message" }, + { role: "assistant", content: "Another response" }, + { role: "user", content: "Third message" }, + ], + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // Only the last two user messages should have cache_control + expect(body.messages[0].content).toBe("First message") // No cache_control + expect(body.messages[2].content).toEqual([ + { type: "text", text: "Second message", cache_control: { type: "ephemeral" } }, + ]) + expect(body.messages[4].content).toEqual([ + { type: "text", text: "Third message", cache_control: { type: "ephemeral" } }, + ]) + }) + + test("should filter out non-Anthropic block types", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "Internal reasoning" }, // Should be filtered + { type: "thoughtSignature", data: "encrypted" }, // Should be filtered + { type: "text", text: "Response" }, + ], + }, + { + role: "user", + content: [{ type: "text", text: "Follow up" }], + }, + ] as any, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // The assistant message should only have the text block + expect(body.messages[1].content).toEqual([{ type: "text", text: "Response" }]) + }) + + test("should preserve thinking and redacted_thinking blocks", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [ + { + role: "user", + content: [{ type: "text", text: "Hello" }], + }, + { + role: "assistant", + content: [ + { type: "thinking", thinking: "Let me think...", signature: "abc123" }, + { type: "text", text: "Response" }, + ], + }, + { + role: "user", + content: [{ type: "tool_result", tool_use_id: "123", content: "result" }], + }, + ] as any, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // Thinking blocks should be preserved + expect(body.messages[1].content).toContainEqual({ + type: "thinking", + thinking: "Let me think...", + signature: "abc123", + }) + // Tool result blocks should be preserved + expect(body.messages[2].content).toContainEqual({ + type: "tool_result", + tool_use_id: "123", + content: "result", + }) + }) + + // Dropped: conversion of internal `reasoning` + `thoughtSignature` blocks into + // Anthropic `thinking` blocks. The Claude Code integration now relies on the + // Anthropic-native `thinking` block format persisted by Task. + + test("should strip reasoning_details from messages (provider switching)", async () => { + // When switching from OpenRouter/Roo to Claude Code, messages may have + // reasoning_details fields that the Anthropic API doesn't accept + // This causes errors like: "messages.3.reasoning_details: Extra inputs are not permitted" + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + // Simulate messages with reasoning_details (added by OpenRouter for Gemini/o-series) + const messagesWithReasoningDetails = [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [{ type: "text", text: "I'll help with that." }], + // This field is added by OpenRouter/Roo providers for Gemini/OpenAI reasoning + reasoning_details: [{ type: "summary_text", summary: "Thinking about the request" }], + }, + { role: "user", content: "Follow up question" }, + ] + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: messagesWithReasoningDetails as any, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // The assistant message should NOT have reasoning_details + expect(body.messages[1]).not.toHaveProperty("reasoning_details") + // But should still have the content + expect(body.messages[1].content).toContainEqual( + expect.objectContaining({ + type: "text", + text: "I'll help with that.", + }), + ) + // Only role and content should be present + expect(Object.keys(body.messages[1])).toEqual(["role", "content"]) + }) + + test("should strip other non-standard message fields", async () => { + // Ensure any non-standard fields are stripped from messages + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockResolvedValue({ done: true, value: undefined }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const messagesWithExtraFields = [ + { + role: "user", + content: "Hello", + customField: "should be stripped", + metadata: { foo: "bar" }, + }, + { + role: "assistant", + content: [{ type: "text", text: "Response" }], + internalId: "123", + timestamp: Date.now(), + }, + ] + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: messagesWithExtraFields as any, + }) + + // Consume the stream + for await (const _ of stream) { + // Just consume + } + + const call = mockFetch.mock.calls[0] + const body = JSON.parse(call[1].body) + + // All messages should only have role and content + body.messages.forEach((msg: Record) => { + expect(Object.keys(msg).filter((k) => k !== "role" && k !== "content")).toHaveLength(0) + }) + }) + + test("should yield error chunk on non-ok response", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: false, + status: 401, + statusText: "Unauthorized", + text: vi.fn().mockResolvedValue('{"error":{"message":"Invalid API key"}}'), + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "invalid-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toHaveLength(1) + expect(chunks[0].type).toBe("error") + expect((chunks[0] as { type: "error"; error: string }).error).toBe("Invalid API key") + }) + + test("should yield error chunk when no response body", async () => { + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: null, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks).toHaveLength(1) + expect(chunks[0].type).toBe("error") + expect((chunks[0] as { type: "error"; error: string }).error).toBe("No response body") + }) + + test("should parse text SSE events correctly", async () => { + const sseData = [ + 'event: content_block_start\ndata: {"index":0,"content_block":{"type":"text","text":"Hello"}}\n\n', + 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"text_delta","text":" world"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ] + + let readIndex = 0 + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockImplementation(() => { + if (readIndex < sseData.length) { + const value = new TextEncoder().encode(sseData[readIndex++]) + return Promise.resolve({ done: false, value }) + } + return Promise.resolve({ done: true, value: undefined }) + }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + // Should have text chunks and usage + expect(chunks.some((c) => c.type === "text")).toBe(true) + expect(chunks.filter((c) => c.type === "text")).toEqual([ + { type: "text", text: "Hello" }, + { type: "text", text: " world" }, + ]) + }) + + test("should parse thinking/reasoning SSE events correctly", async () => { + const sseData = [ + 'event: content_block_start\ndata: {"index":0,"content_block":{"type":"thinking","thinking":"Let me think..."}}\n\n', + 'event: content_block_delta\ndata: {"index":0,"delta":{"type":"thinking_delta","thinking":" more thoughts"}}\n\n', + "event: message_stop\ndata: {}\n\n", + ] + + let readIndex = 0 + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockImplementation(() => { + if (readIndex < sseData.length) { + const value = new TextEncoder().encode(sseData[readIndex++]) + return Promise.resolve({ done: false, value }) + } + return Promise.resolve({ done: true, value: undefined }) + }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + expect(chunks.filter((c) => c.type === "reasoning")).toEqual([ + { type: "reasoning", text: "Let me think..." }, + { type: "reasoning", text: " more thoughts" }, + ]) + }) + + test("should track and yield usage from message events", async () => { + const sseData = [ + 'event: message_start\ndata: {"message":{"usage":{"input_tokens":10,"output_tokens":0,"cache_read_input_tokens":5}}}\n\n', + 'event: message_delta\ndata: {"usage":{"output_tokens":20}}\n\n', + "event: message_stop\ndata: {}\n\n", + ] + + let readIndex = 0 + const mockFetch = vi.fn().mockResolvedValue({ + ok: true, + body: { + getReader: () => ({ + read: vi.fn().mockImplementation(() => { + if (readIndex < sseData.length) { + const value = new TextEncoder().encode(sseData[readIndex++]) + return Promise.resolve({ done: false, value }) + } + return Promise.resolve({ done: true, value: undefined }) + }), + releaseLock: vi.fn(), + }), + }, + }) + global.fetch = mockFetch + + const { createStreamingMessage } = await import("../streaming-client") + + const stream = createStreamingMessage({ + accessToken: "test-token", + model: "claude-3-5-sonnet-20241022", + systemPrompt: "You are helpful", + messages: [{ role: "user", content: "Hello" }], + }) + + const chunks = [] + for await (const chunk of stream) { + chunks.push(chunk) + } + + const usageChunk = chunks.find((c) => c.type === "usage") + expect(usageChunk).toBeDefined() + expect(usageChunk).toMatchObject({ + type: "usage", + inputTokens: 10, + outputTokens: 20, + cacheReadTokens: 5, + }) + }) + }) +}) diff --git a/src/integrations/claude-code/oauth.ts b/src/integrations/claude-code/oauth.ts new file mode 100644 index 00000000000..5d7a929e1cc --- /dev/null +++ b/src/integrations/claude-code/oauth.ts @@ -0,0 +1,638 @@ +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 new file mode 100644 index 00000000000..b864995f2cd --- /dev/null +++ b/src/integrations/claude-code/streaming-client.ts @@ -0,0 +1,759 @@ +import type { Anthropic } from "@anthropic-ai/sdk" +import type { ClaudeCodeRateLimitInfo } from "@roo-code/types" +import { Package } from "../../shared/package" + +/** + * Set of content block types that are valid for Anthropic API. + * Only these types will be passed through to the API. + * See: https://docs.anthropic.com/en/api/messages + */ +const VALID_ANTHROPIC_BLOCK_TYPES = new Set([ + "text", + "image", + "tool_use", + "tool_result", + "thinking", + "redacted_thinking", + "document", +]) + +type ContentBlockWithType = { type: string } + +/** + * Filters out non-Anthropic content blocks from messages before sending to the API. + * + * NOTE: This function performs FILTERING ONLY - no type conversion is performed. + * Blocks are either kept as-is or removed entirely based on the allowlist. + * + * Uses an allowlist approach - only blocks with types in VALID_ANTHROPIC_BLOCK_TYPES are kept. + * This automatically filters out: + * - Internal "reasoning" blocks (Roo Code's internal representation) - NOT converted to "thinking" + * - Gemini's "thoughtSignature" blocks + * - Any other unknown block types + * + * IMPORTANT: This function also strips message-level fields that are not part of the Anthropic API: + * - `reasoning_details` (added by OpenRouter/Roo providers for Gemini/OpenAI reasoning) + * - Any other non-standard fields added by other providers + * + * We preserve ALL "thinking" blocks (Anthropic's native extended thinking format) for these reasons: + * 1. Rewind functionality - users need to be able to go back in conversation history + * 2. Claude Opus 4.5+ preserves thinking blocks by default (per Anthropic docs) + * 3. Interleaved thinking requires thinking blocks to be passed back for tool use continuations + * + * The API will handle thinking blocks appropriately based on the model: + * - Claude Opus 4.5+: thinking blocks preserved (enables cache optimization) + * - Older models: thinking blocks stripped from prior turns automatically + */ +function filterNonAnthropicBlocks(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { + const result: Anthropic.Messages.MessageParam[] = [] + + for (const message of messages) { + // Extract ONLY the standard Anthropic message fields (role, content) + // This strips out any extra fields like `reasoning_details` that other providers + // may have added to the messages (e.g., OpenRouter adds reasoning_details for Gemini/o-series) + const { role, content } = message + + if (typeof content === "string") { + // Return a clean message with only role and content + result.push({ role, content }) + continue + } + + // Filter out invalid block types (allowlist) + const filteredContent = content.filter((block) => + VALID_ANTHROPIC_BLOCK_TYPES.has((block as ContentBlockWithType).type), + ) + + // If all content was filtered out, skip this message + if (filteredContent.length === 0) { + continue + } + + // Return a clean message with only role and content (no extra fields) + result.push({ + role, + content: filteredContent, + }) + } + + return result +} + +/** + * Adds cache_control breakpoints to the last two user messages for prompt caching. + * This follows Anthropic's recommended pattern: + * - Cache the system prompt (handled separately) + * - Cache the last text block of the second-to-last user message + * - Cache the last text block of the last user message + * + * According to Anthropic docs: + * - System prompts and tools remain cached despite thinking parameter changes + * - Message cache breakpoints are invalidated when thinking parameters change + * - When using extended thinking, thinking blocks from previous turns are stripped from context + */ +function addMessageCacheBreakpoints(messages: Anthropic.Messages.MessageParam[]): Anthropic.Messages.MessageParam[] { + // Find indices of user messages + const userMsgIndices = messages.reduce( + (acc, msg, index) => (msg.role === "user" ? [...acc, index] : acc), + [] as number[], + ) + + const lastUserMsgIndex = userMsgIndices[userMsgIndices.length - 1] ?? -1 + const secondLastUserMsgIndex = userMsgIndices[userMsgIndices.length - 2] ?? -1 + + return messages.map((message, index) => { + // Only add cache control to the last two user messages + if (index !== lastUserMsgIndex && index !== secondLastUserMsgIndex) { + return message + } + + // Handle string content + if (typeof message.content === "string") { + return { + ...message, + content: [ + { + type: "text" as const, + text: message.content, + cache_control: { type: "ephemeral" as const }, + }, + ], + } + } + + // Handle array content - add cache_control to the last text block + const contentWithCache = message.content.map((block, blockIndex) => { + // Find the last text block index + let lastTextIndex = -1 + for (let i = message.content.length - 1; i >= 0; i--) { + if ((message.content[i] as { type: string }).type === "text") { + lastTextIndex = i + break + } + } + + // Only add cache_control to text blocks (the last one specifically) + if (blockIndex === lastTextIndex && (block as { type: string }).type === "text") { + const textBlock = block as { type: "text"; text: string } + return { + type: "text" as const, + text: textBlock.text, + cache_control: { type: "ephemeral" as const }, + } + } + + return block + }) + + return { + ...message, + content: contentWithCache, + } + }) +} + +// API Configuration +export const CLAUDE_CODE_API_CONFIG = { + endpoint: "https://api.anthropic.com/v1/messages", + version: "2023-06-01", + defaultBetas: [ + "prompt-caching-2024-07-31", + "claude-code-20250219", + "oauth-2025-04-20", + "interleaved-thinking-2025-05-14", + "fine-grained-tool-streaming-2025-05-14", + ], + userAgent: `Roo-Code/${Package.version}`, +} as const + +/** + * SSE Event types from Anthropic streaming API + */ +export type SSEEventType = + | "message_start" + | "content_block_start" + | "content_block_delta" + | "content_block_stop" + | "message_delta" + | "message_stop" + | "ping" + | "error" + +export interface SSEEvent { + event: SSEEventType + data: unknown +} + +/** + * Thinking configuration for extended thinking mode + */ +export type ThinkingConfig = + | { + type: "enabled" + budget_tokens: number + } + | { + type: "disabled" + } + +/** + * Stream message request options + */ +export interface StreamMessageOptions { + accessToken: string + model: string + systemPrompt: string + messages: Anthropic.Messages.MessageParam[] + maxTokens?: number + thinking?: ThinkingConfig + tools?: Anthropic.Messages.Tool[] + toolChoice?: Anthropic.Messages.ToolChoice + metadata?: { + user_id?: string + } + signal?: AbortSignal +} + +/** + * SSE Parser state that persists across chunks + * This is necessary because SSE events can be split across multiple chunks + */ +interface SSEParserState { + buffer: string + currentEvent: string | null + currentData: string[] +} + +/** + * Creates initial SSE parser state + */ +function createSSEParserState(): SSEParserState { + return { + buffer: "", + currentEvent: null, + currentData: [], + } +} + +/** + * Parses SSE lines from a text chunk + * Returns parsed events and updates the state for the next chunk + * + * The state persists across chunks to handle events that span multiple chunks: + * - buffer: incomplete line from previous chunk + * - currentEvent: event type if we've seen "event:" but not the complete event + * - currentData: accumulated data lines for the current event + */ +function parseSSEChunk(chunk: string, state: SSEParserState): { events: SSEEvent[]; state: SSEParserState } { + const events: SSEEvent[] = [] + const lines = (state.buffer + chunk).split("\n") + + // Start with the accumulated state + let currentEvent = state.currentEvent + let currentData = [...state.currentData] + let remaining = "" + + for (let i = 0; i < lines.length; i++) { + const line = lines[i] + + // If this is the last line and doesn't end with newline, it might be incomplete + if (i === lines.length - 1 && !chunk.endsWith("\n") && line !== "") { + remaining = line + continue + } + + // Empty line signals end of event + if (line === "") { + if (currentEvent && currentData.length > 0) { + try { + const dataStr = currentData.join("\n") + const data = dataStr === "[DONE]" ? null : JSON.parse(dataStr) + events.push({ + event: currentEvent as SSEEventType, + data, + }) + } catch { + // Skip malformed events + console.error("[claude-code-streaming] Failed to parse SSE data:", currentData.join("\n")) + } + } + currentEvent = null + currentData = [] + continue + } + + // Parse event type + if (line.startsWith("event: ")) { + currentEvent = line.slice(7) + continue + } + + // Parse data + if (line.startsWith("data: ")) { + currentData.push(line.slice(6)) + continue + } + } + + // Return updated state for next chunk + return { + events, + state: { + buffer: remaining, + currentEvent, + currentData, + }, + } +} + +/** + * Stream chunk types that the handler can yield + */ +export interface StreamTextChunk { + type: "text" + text: string +} + +export interface StreamReasoningChunk { + type: "reasoning" + text: string +} + +/** + * A complete thinking block with signature, used for tool use continuations. + * According to Anthropic docs: + * - During tool use, you must pass thinking blocks back to the API for the last assistant message + * - Include the complete unmodified block back to the API to maintain reasoning continuity + * - The signature field is used to verify that thinking blocks were generated by Claude + */ +export interface StreamThinkingCompleteChunk { + type: "thinking_complete" + index: number + thinking: string + signature: string +} + +export interface StreamToolCallPartialChunk { + type: "tool_call_partial" + index: number + id?: string + name?: string + arguments?: string +} + +export interface StreamUsageChunk { + type: "usage" + inputTokens: number + outputTokens: number + cacheReadTokens?: number + cacheWriteTokens?: number + totalCost?: number +} + +export interface StreamErrorChunk { + type: "error" + error: string +} + +export type StreamChunk = + | StreamTextChunk + | StreamReasoningChunk + | StreamThinkingCompleteChunk + | StreamToolCallPartialChunk + | StreamUsageChunk + | StreamErrorChunk + +/** + * Creates a streaming message request to the Anthropic API using OAuth + */ +export async function* createStreamingMessage(options: StreamMessageOptions): AsyncGenerator { + const { accessToken, model, systemPrompt, messages, maxTokens, thinking, tools, toolChoice, metadata, signal } = + options + + // Filter out non-Anthropic blocks before processing + const sanitizedMessages = filterNonAnthropicBlocks(messages) + + // Add cache breakpoints to the last two user messages + // According to Anthropic docs: + // - System prompts and tools remain cached despite thinking parameter changes + // - Message cache breakpoints are invalidated when thinking parameters change + // - We cache the last two user messages for optimal cache hit rates + const messagesWithCache = addMessageCacheBreakpoints(sanitizedMessages) + + // Build request body - match Claude Code format exactly + const body: Record = { + model, + stream: true, + messages: messagesWithCache, + } + + // Only include max_tokens if explicitly provided + if (maxTokens !== undefined) { + body.max_tokens = maxTokens + } + + // Add thinking configuration for extended thinking mode + if (thinking) { + body.thinking = thinking + } + + // System prompt as array of content blocks (Claude Code format) + // Prepend Claude Code branding as required by the API + // Add cache_control to the last text block for prompt caching + // System prompt caching is preserved even when thinking parameters change + body.system = [ + { type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }, + ...(systemPrompt ? [{ type: "text", text: systemPrompt, cache_control: { type: "ephemeral" } }] : []), + ] + + // Metadata with user_id is required for Claude Code + if (metadata) { + body.metadata = metadata + } + + if (tools && tools.length > 0) { + body.tools = tools + // Default tool_choice to "auto" when tools are provided (as per spec example) + body.tool_choice = toolChoice || { type: "auto" } + } else if (toolChoice) { + body.tool_choice = toolChoice + } + + // Build minimal headers + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, + "Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","), + Accept: "text/event-stream", + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, + } + + // Make the request + const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal, + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `API request failed: ${response.status} ${response.statusText}` + try { + const errorJson = JSON.parse(errorText) + if (errorJson.error?.message) { + errorMessage = errorJson.error.message + } + } catch { + if (errorText) { + errorMessage += ` - ${errorText}` + } + } + yield { type: "error", error: errorMessage } + return + } + + if (!response.body) { + yield { type: "error", error: "No response body" } + return + } + + // Track usage across events + let totalInputTokens = 0 + let totalOutputTokens = 0 + let cacheReadTokens = 0 + let cacheWriteTokens = 0 + + // Track content blocks by index for proper assembly + // This is critical for interleaved thinking - we need to capture complete thinking blocks + // with their signatures so they can be passed back to the API for tool use continuations + const contentBlocks: Map< + number, + { + type: string + text: string + signature?: string + id?: string + name?: string + arguments?: string + } + > = new Map() + + // Read the stream + const reader = response.body.getReader() + const decoder = new TextDecoder() + let sseState = createSSEParserState() + + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + + const chunk = decoder.decode(value, { stream: true }) + const result = parseSSEChunk(chunk, sseState) + sseState = result.state + const events = result.events + + for (const event of events) { + const eventData = event.data as Record | null + + if (!eventData) { + continue + } + + switch (event.event) { + case "message_start": { + const message = eventData.message as Record + if (!message) { + break + } + const usage = message.usage as Record | undefined + if (usage) { + totalInputTokens += usage.input_tokens || 0 + totalOutputTokens += usage.output_tokens || 0 + cacheReadTokens += usage.cache_read_input_tokens || 0 + cacheWriteTokens += usage.cache_creation_input_tokens || 0 + } + break + } + + case "content_block_start": { + const contentBlock = eventData.content_block as Record + const index = eventData.index as number + + if (contentBlock) { + switch (contentBlock.type) { + case "text": + // Initialize text block tracking + contentBlocks.set(index, { + type: "text", + text: (contentBlock.text as string) || "", + }) + if (contentBlock.text) { + yield { type: "text", text: contentBlock.text as string } + } + break + case "thinking": + // Initialize thinking block tracking - critical for interleaved thinking + // We need to accumulate the text and capture the signature + contentBlocks.set(index, { + type: "thinking", + text: (contentBlock.thinking as string) || "", + }) + if (contentBlock.thinking) { + yield { type: "reasoning", text: contentBlock.thinking as string } + } + break + case "tool_use": + contentBlocks.set(index, { + type: "tool_use", + text: "", + id: contentBlock.id as string, + name: contentBlock.name as string, + arguments: "", + }) + yield { + type: "tool_call_partial", + index, + id: contentBlock.id as string, + name: contentBlock.name as string, + arguments: undefined, + } + break + } + } + break + } + + case "content_block_delta": { + const delta = eventData.delta as Record + const index = eventData.index as number + const block = contentBlocks.get(index) + + if (delta) { + switch (delta.type) { + case "text_delta": + if (delta.text) { + // Accumulate text + if (block && block.type === "text") { + block.text += delta.text as string + } + yield { type: "text", text: delta.text as string } + } + break + case "thinking_delta": + if (delta.thinking) { + // Accumulate thinking text + if (block && block.type === "thinking") { + block.text += delta.thinking as string + } + yield { type: "reasoning", text: delta.thinking as string } + } + break + case "signature_delta": + // Capture the signature for the thinking block + // This is critical for interleaved thinking - the signature + // must be included when passing thinking blocks back to the API + if (delta.signature && block && block.type === "thinking") { + block.signature = delta.signature as string + } + break + case "input_json_delta": + if (block && block.type === "tool_use") { + block.arguments = (block.arguments || "") + (delta.partial_json as string) + } + yield { + type: "tool_call_partial", + index, + id: undefined, + name: undefined, + arguments: delta.partial_json as string, + } + break + } + } + break + } + + case "content_block_stop": { + // When a content block completes, emit complete thinking blocks + // This enables the caller to preserve them for tool use continuations + const index = eventData.index as number + const block = contentBlocks.get(index) + + if (block && block.type === "thinking" && block.signature) { + // Emit the complete thinking block with signature + // This is required for interleaved thinking with tool use + yield { + type: "thinking_complete", + index, + thinking: block.text, + signature: block.signature, + } + } + break + } + + case "message_delta": { + const usage = eventData.usage as Record | undefined + if (usage && usage.output_tokens !== undefined) { + // output_tokens in message_delta is the running total, not a delta + // So we replace rather than add + totalOutputTokens = usage.output_tokens + } + break + } + + case "message_stop": { + // Yield final usage chunk + yield { + type: "usage", + inputTokens: totalInputTokens, + outputTokens: totalOutputTokens, + cacheReadTokens: cacheReadTokens > 0 ? cacheReadTokens : undefined, + cacheWriteTokens: cacheWriteTokens > 0 ? cacheWriteTokens : undefined, + } + break + } + + case "error": { + const errorData = eventData.error as Record + yield { + type: "error", + error: (errorData?.message as string) || "Unknown streaming error", + } + break + } + } + } + } + } finally { + reader.releaseLock() + } +} + +/** + * Parse rate limit headers from a response into a structured format + */ +function parseRateLimitHeaders(headers: Headers): ClaudeCodeRateLimitInfo { + const getHeader = (name: string): string | null => headers.get(name) + const parseFloat = (val: string | null): number => (val ? Number.parseFloat(val) : 0) + const parseInt = (val: string | null): number => (val ? Number.parseInt(val, 10) : 0) + + return { + fiveHour: { + status: getHeader("anthropic-ratelimit-unified-5h-status") || "unknown", + utilization: parseFloat(getHeader("anthropic-ratelimit-unified-5h-utilization")), + resetTime: parseInt(getHeader("anthropic-ratelimit-unified-5h-reset")), + }, + weekly: { + status: getHeader("anthropic-ratelimit-unified-7d_sonnet-status") || "unknown", + utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d_sonnet-utilization")), + resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d_sonnet-reset")), + }, + weeklyUnified: { + status: getHeader("anthropic-ratelimit-unified-7d-status") || "unknown", + utilization: parseFloat(getHeader("anthropic-ratelimit-unified-7d-utilization")), + resetTime: parseInt(getHeader("anthropic-ratelimit-unified-7d-reset")), + }, + representativeClaim: getHeader("anthropic-ratelimit-unified-representative-claim") || undefined, + overage: { + status: getHeader("anthropic-ratelimit-unified-overage-status") || "unknown", + disabledReason: getHeader("anthropic-ratelimit-unified-overage-disabled-reason") || undefined, + }, + fallbackPercentage: parseFloat(getHeader("anthropic-ratelimit-unified-fallback-percentage")) || undefined, + organizationId: getHeader("anthropic-organization-id") || undefined, + fetchedAt: Date.now(), + } +} + +/** + * Fetch rate limit information by making a minimal API call + * Uses a small request to get the response headers containing rate limit data + */ +export async function fetchRateLimitInfo(accessToken: string): Promise { + // Build minimal request body - use haiku for speed and lowest cost + const body = { + model: "claude-haiku-4-5", + max_tokens: 1, + system: [{ type: "text", text: "You are Claude Code, Anthropic's official CLI for Claude." }], + messages: [{ role: "user", content: "hi" }], + } + + // Build minimal headers + const headers: Record = { + Authorization: `Bearer ${accessToken}`, + "Content-Type": "application/json", + "Anthropic-Version": CLAUDE_CODE_API_CONFIG.version, + "Anthropic-Beta": CLAUDE_CODE_API_CONFIG.defaultBetas.join(","), + "User-Agent": CLAUDE_CODE_API_CONFIG.userAgent, + } + + // Make the request + const response = await fetch(`${CLAUDE_CODE_API_CONFIG.endpoint}?beta=true`, { + method: "POST", + headers, + body: JSON.stringify(body), + signal: AbortSignal.timeout(30000), + }) + + if (!response.ok) { + const errorText = await response.text() + let errorMessage = `API request failed: ${response.status} ${response.statusText}` + try { + const errorJson = JSON.parse(errorText) + if (errorJson.error?.message) { + errorMessage = errorJson.error.message + } + } catch { + if (errorText) { + errorMessage += ` - ${errorText}` + } + } + throw new Error(errorMessage) + } + + // Parse rate limit headers from the response + return parseRateLimitHeaders(response.headers) +} diff --git a/src/shared/__tests__/api.spec.ts b/src/shared/__tests__/api.spec.ts index f8830d8b647..278a97424c2 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 maxTokens is within 20% of context window", () => { + test("should return model maxTokens when not using claude-code provider and maxTokens is within 20% of context window", () => { const settings: ProviderSettings = { apiProvider: "anthropic", } diff --git a/src/shared/__tests__/checkExistApiConfig.spec.ts b/src/shared/__tests__/checkExistApiConfig.spec.ts index 55dae005f25..826cc792257 100644 --- a/src/shared/__tests__/checkExistApiConfig.spec.ts +++ b/src/shared/__tests__/checkExistApiConfig.spec.ts @@ -67,6 +67,13 @@ describe("checkExistKey", () => { expect(checkExistKey(config)).toBe(true) }) + it("should return true for claude-code provider without API key", () => { + const config: ProviderSettings = { + apiProvider: "claude-code", + } + expect(checkExistKey(config)).toBe(true) + }) + it("should return true for openai-codex provider without API key", () => { const config: ProviderSettings = { apiProvider: "openai-codex", diff --git a/src/shared/checkExistApiConfig.ts b/src/shared/checkExistApiConfig.ts index ccbda63ae02..37b37ea7a36 100644 --- a/src/shared/checkExistApiConfig.ts +++ b/src/shared/checkExistApiConfig.ts @@ -5,8 +5,11 @@ export function checkExistKey(config: ProviderSettings | undefined) { return false } - // Special case for fake-ai, openai-codex, qwen-code, and roo providers which don't need any configuration. - if (config.apiProvider && ["fake-ai", "openai-codex", "qwen-code", "roo"].includes(config.apiProvider)) { + // Special case for fake-ai, claude-code, openai-codex, qwen-code, and roo providers which don't need any configuration. + if ( + config.apiProvider && + ["fake-ai", "claude-code", "openai-codex", "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 257c7309a61..e71f92dc415 100644 --- a/webview-ui/src/components/chat/ChatRow.tsx +++ b/webview-ui/src/components/chat/ChatRow.tsx @@ -1113,20 +1113,32 @@ export const ChatRowContent = ({ let body = t(`chat:apiRequest.failed`) let retryInfo, rawError, code, docsURL if (message.text !== undefined) { - // Try to show richer error message for that code, if available - const potentialCode = parseInt(message.text.substring(0, 3)) - if (!isNaN(potentialCode) && potentialCode >= 400) { - code = potentialCode - const stringForError = `chat:apiRequest.errorMessage.${code}` - if (i18n.exists(stringForError)) { - body = t(stringForError) - // Fill this out in upcoming PRs - // Do not remove this - // switch(code) { - // case ERROR_CODE: - // docsURL = ??? - // break; - // } + // Check for Claude Code authentication error first + if (message.text.includes("Not authenticated with Claude Code")) { + body = t("chat:apiRequest.errorMessage.claudeCodeNotAuthenticated") + docsURL = "roocode://settings?provider=claude-code" + } else { + // Try to show richer error message for that code, if available + const potentialCode = parseInt(message.text.substring(0, 3)) + if (!isNaN(potentialCode) && potentialCode >= 400) { + code = potentialCode + const stringForError = `chat:apiRequest.errorMessage.${code}` + if (i18n.exists(stringForError)) { + body = t(stringForError) + // Fill this out in upcoming PRs + // Do not remove this + // switch(code) { + // case ERROR_CODE: + // docsURL = ??? + // break; + // } + } else { + body = t("chat:apiRequest.errorMessage.unknown") + docsURL = + "mailto:support@roocode.com?subject=Unknown API Error&body=[Please include full error details]" + } + } 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") diff --git a/webview-ui/src/components/settings/ApiOptions.tsx b/webview-ui/src/components/settings/ApiOptions.tsx index bddbbe802d2..a1d676e8292 100644 --- a/webview-ui/src/components/settings/ApiOptions.tsx +++ b/webview-ui/src/components/settings/ApiOptions.tsx @@ -16,6 +16,7 @@ import { openAiCodexDefaultModelId, anthropicDefaultModelId, doubaoDefaultModelId, + claudeCodeDefaultModelId, qwenCodeDefaultModelId, geminiDefaultModelId, deepSeekDefaultModelId, @@ -77,6 +78,7 @@ import { Bedrock, Cerebras, Chutes, + ClaudeCode, DeepSeek, Doubao, Gemini, @@ -146,7 +148,8 @@ const ApiOptions = ({ setErrorMessage, }: ApiOptionsProps) => { const { t } = useAppTranslation() - const { organizationAllowList, cloudIsAuthenticated, openAiCodexIsAuthenticated } = useExtensionState() + const { organizationAllowList, cloudIsAuthenticated, claudeCodeIsAuthenticated, openAiCodexIsAuthenticated } = + useExtensionState() const [customHeaders, setCustomHeaders] = useState<[string, string][]>(() => { const headers = apiConfiguration?.openAiHeaders || {} @@ -344,6 +347,7 @@ const ApiOptions = ({ litellm: { field: "litellmModelId", default: litellmDefaultModelId }, anthropic: { field: "apiModelId", default: anthropicDefaultModelId }, cerebras: { field: "apiModelId", default: cerebrasDefaultModelId }, + "claude-code": { field: "apiModelId", default: claudeCodeDefaultModelId }, "openai-codex": { field: "apiModelId", default: openAiCodexDefaultModelId }, "qwen-code": { field: "apiModelId", default: qwenCodeDefaultModelId }, "openai-native": { field: "apiModelId", default: openAiNativeDefaultModelId }, @@ -558,6 +562,15 @@ const ApiOptions = ({ /> )} + {selectedProvider === "claude-code" && ( + + )} + {selectedProvider === "openai-codex" && ( >> = { anthropic: anthropicModels, + "claude-code": claudeCodeModels, bedrock: bedrockModels, cerebras: cerebrasModels, deepseek: deepSeekModels, @@ -50,6 +52,7 @@ export const PROVIDERS = [ { value: "openrouter", label: "OpenRouter", proxy: false }, { value: "deepinfra", label: "DeepInfra", proxy: false }, { value: "anthropic", label: "Anthropic", proxy: false }, + { value: "claude-code", label: "Claude Code", proxy: false }, { value: "cerebras", label: "Cerebras", proxy: false }, { value: "gemini", label: "Google Gemini", proxy: false }, { value: "doubao", label: "Doubao", proxy: false }, diff --git a/webview-ui/src/components/settings/providers/ClaudeCode.tsx b/webview-ui/src/components/settings/providers/ClaudeCode.tsx new file mode 100644 index 00000000000..9dfcf81c86f --- /dev/null +++ b/webview-ui/src/components/settings/providers/ClaudeCode.tsx @@ -0,0 +1,71 @@ +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 new file mode 100644 index 00000000000..9b152c27177 --- /dev/null +++ b/webview-ui/src/components/settings/providers/ClaudeCodeRateLimitDashboard.tsx @@ -0,0 +1,181 @@ +import React, { useEffect, useState, useCallback } from "react" +import type { ClaudeCodeRateLimitInfo } from "@roo-code/types" +import { vscode } from "@src/utils/vscode" + +interface ClaudeCodeRateLimitDashboardProps { + isAuthenticated: boolean +} + +/** + * Formats a Unix timestamp reset time into a human-readable duration + */ +function formatResetTime(resetTimestamp: number): string { + if (!resetTimestamp) return "N/A" + + const now = Date.now() / 1000 // Current time in seconds + const diff = resetTimestamp - now + + if (diff <= 0) return "Now" + + const hours = Math.floor(diff / 3600) + const minutes = Math.floor((diff % 3600) / 60) + + if (hours > 24) { + const days = Math.floor(hours / 24) + const remainingHours = hours % 24 + return `${days}d ${remainingHours}h` + } + + if (hours > 0) { + return `${hours}h ${minutes}m` + } + + return `${minutes}m` +} + +/** + * Formats utilization as a percentage + */ +function formatUtilization(utilization: number): string { + return `${(utilization * 100).toFixed(1)}%` +} + +/** + * Progress bar component for displaying usage + */ +const UsageProgressBar: React.FC<{ utilization: number; label: string }> = ({ utilization, label }) => { + const percentage = Math.min(utilization * 100, 100) + const isWarning = percentage >= 70 + const isCritical = percentage >= 90 + + return ( +
+
{label}
+
+
+
+
+ ) +} + +export const ClaudeCodeRateLimitDashboard: React.FC = ({ isAuthenticated }) => { + const [rateLimits, setRateLimits] = useState(null) + const [isLoading, setIsLoading] = useState(false) + const [error, setError] = useState(null) + + const fetchRateLimits = useCallback(() => { + if (!isAuthenticated) { + setRateLimits(null) + setError(null) + return + } + + setIsLoading(true) + setError(null) + vscode.postMessage({ type: "requestClaudeCodeRateLimits" }) + }, [isAuthenticated]) + + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + const message = event.data + if (message.type === "claudeCodeRateLimits") { + setIsLoading(false) + if (message.error) { + setError(message.error) + setRateLimits(null) + } else if (message.values) { + setRateLimits(message.values) + setError(null) + } + } + } + + window.addEventListener("message", handleMessage) + return () => window.removeEventListener("message", handleMessage) + }, []) + + // Fetch rate limits when authenticated + useEffect(() => { + if (isAuthenticated) { + fetchRateLimits() + } + }, [isAuthenticated, fetchRateLimits]) + + if (!isAuthenticated) { + return null + } + + if (isLoading && !rateLimits) { + return ( +
+
Loading rate limits...
+
+ ) + } + + if (error) { + return ( +
+
+
Failed to load rate limits
+ +
+
+ ) + } + + if (!rateLimits) { + return null + } + + return ( +
+
+
Usage Limits
+
+ +
+ {/* 5-hour limit */} +
+
+ + Limit: {rateLimits.representativeClaim || "5-hour"} + + + {formatUtilization(rateLimits.fiveHour.utilization)} used • resets in{" "} + {formatResetTime(rateLimits.fiveHour.resetTime)} + +
+ +
+ + {/* Weekly limit (if available) */} + {rateLimits.weeklyUnified && rateLimits.weeklyUnified.utilization > 0 && ( +
+
+ Weekly + + {formatUtilization(rateLimits.weeklyUnified.utilization)} used • resets in{" "} + {formatResetTime(rateLimits.weeklyUnified.resetTime)} + +
+ +
+ )} +
+
+ ) +} diff --git a/webview-ui/src/components/settings/providers/index.ts b/webview-ui/src/components/settings/providers/index.ts index bca620d052d..e28cc257706 100644 --- a/webview-ui/src/components/settings/providers/index.ts +++ b/webview-ui/src/components/settings/providers/index.ts @@ -2,6 +2,7 @@ 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/settings/utils/__tests__/providerModelConfig.spec.ts b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts index db3581634a6..6677d6cd197 100644 --- a/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts +++ b/webview-ui/src/components/settings/utils/__tests__/providerModelConfig.spec.ts @@ -168,6 +168,7 @@ describe("providerModelConfig", () => { expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain("ollama") expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain("lmstudio") expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain("vscode-lm") + expect(PROVIDERS_WITH_CUSTOM_MODEL_UI).toContain("claude-code") }) it("does not include static providers using generic picker", () => { diff --git a/webview-ui/src/components/settings/utils/providerModelConfig.ts b/webview-ui/src/components/settings/utils/providerModelConfig.ts index f0079a78e12..d302d5b82a8 100644 --- a/webview-ui/src/components/settings/utils/providerModelConfig.ts +++ b/webview-ui/src/components/settings/utils/providerModelConfig.ts @@ -132,6 +132,7 @@ export const PROVIDERS_WITH_CUSTOM_MODEL_UI: ProviderName[] = [ "requesty", "unbound", "deepinfra", + "claude-code", "openai", // OpenAI Compatible "litellm", "io-intelligence", 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 e42ba33fd0e..1d42856fad0 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -412,6 +412,77 @@ 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 8eac6fa7403..471409a8876 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -19,6 +19,8 @@ import { groqModels, vscodeLlmModels, vscodeLlmDefaultModelId, + claudeCodeModels, + normalizeClaudeCodeModelId, openAiCodexModels, sambaNovaModels, doubaoModels, @@ -314,6 +316,14 @@ 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] From 621ec5892678ab292fdb1ee7da924b7416716fa2 Mon Sep 17 00:00:00 2001 From: My Name Date: Fri, 23 Jan 2026 11:10:45 +0100 Subject: [PATCH 02/14] Rebrand to Klaus Code (fork of Roo Code) - Rename project from "Roo Code" to "Klaus Code" across 338 files - Update README.md with fork notice explaining: - Why this fork exists (Claude Code provider removal) - Link to original commit 7f854c0 - Original Roo Code project links preserved - Update package.json: name, publisher, author, repository - Update all i18n locale files with new branding - Update user-facing strings in source code Original Roo Code content preserved below fork notice in README. Technical VS Code identifiers (roo-cline) kept for compatibility. Co-Authored-By: Claude Opus 4.5 --- .github/pull_request_template.md | 6 +- .roo/commands/cli-release.md | 2 +- .roo/commands/release.md | 2 +- .roo/skills/evals-context/SKILL.md | 4 +- CHANGELOG.md | 92 ++++++------- CLAUDE.md | 98 ++++++++++++++ CONTRIBUTING.md | 12 +- PRIVACY.md | 22 ++-- README.md | 30 ++++- apps/cli/CHANGELOG.md | 4 +- apps/cli/README.md | 20 +-- apps/cli/docs/AGENT_LOOP.md | 2 +- apps/cli/install.sh | 6 +- apps/cli/package.json | 2 +- apps/cli/scripts/release.sh | 6 +- apps/cli/src/agent/agent-state.ts | 2 +- apps/cli/src/agent/extension-client.ts | 4 +- apps/cli/src/agent/extension-host.ts | 2 +- apps/cli/src/commands/cli/run.ts | 8 +- apps/cli/src/index.ts | 8 +- apps/cli/src/ui/components/Header.tsx | 2 +- .../onboarding/OnboardingScreen.tsx | 2 +- apps/cli/src/ui/theme.ts | 2 +- apps/vscode-e2e/src/suite/extension.test.ts | 2 +- apps/vscode-e2e/src/suite/modes.test.ts | 2 +- apps/vscode-e2e/src/suite/subtasks.test.ts | 2 +- apps/vscode-e2e/src/suite/task.test.ts | 2 +- .../src/suite/tools/apply-diff.test.ts | 2 +- .../src/suite/tools/execute-command.test.ts | 2 +- .../src/suite/tools/list-files.test.ts | 2 +- .../src/suite/tools/read-file.test.ts | 2 +- .../src/suite/tools/search-files.test.ts | 2 +- .../src/suite/tools/use-mcp-tool.test.ts | 6 +- .../src/suite/tools/write-to-file.test.ts | 2 +- apps/vscode-nightly/package.json | 2 +- apps/vscode-nightly/package.nls.nightly.json | 10 +- apps/web-evals/src/app/layout.tsx | 2 +- apps/web-evals/src/app/runs/new/new-run.tsx | 10 +- apps/web-roo-code/src/app/api/og/route.tsx | 2 +- apps/web-roo-code/src/app/cloud/page.tsx | 6 +- apps/web-roo-code/src/app/cloud/team/page.tsx | 6 +- apps/web-roo-code/src/app/enterprise/page.tsx | 38 +++--- apps/web-roo-code/src/app/evals/evals.tsx | 2 +- apps/web-roo-code/src/app/extension/page.tsx | 2 +- apps/web-roo-code/src/app/layout.tsx | 4 +- .../src/app/legal/cookies/page.tsx | 4 +- .../src/app/legal/subprocessors/page.tsx | 4 +- apps/web-roo-code/src/app/page.tsx | 4 +- .../src/app/pr-fixer/content-a.tsx | 6 +- apps/web-roo-code/src/app/pricing/page.tsx | 22 ++-- apps/web-roo-code/src/app/privacy/page.tsx | 44 +++---- apps/web-roo-code/src/app/provider/page.tsx | 16 +-- .../src/app/reviewer/content-b.ts | 6 +- apps/web-roo-code/src/app/reviewer/content.ts | 6 +- apps/web-roo-code/src/app/slack/page.tsx | 14 +- apps/web-roo-code/src/app/terms/page.tsx | 2 +- apps/web-roo-code/src/app/terms/terms.md | 124 +++++++++--------- .../src/components/chromes/footer.tsx | 10 +- .../src/components/chromes/nav-bar.tsx | 18 +-- .../components/enterprise/contact-form.tsx | 4 +- .../src/components/homepage/faq-section.tsx | 52 ++++---- .../src/components/homepage/features.tsx | 2 +- .../components/homepage/install-section.tsx | 4 +- .../homepage/option-overview-section.tsx | 4 +- .../components/homepage/pillars-section.tsx | 2 +- .../src/components/homepage/testimonials.tsx | 12 +- .../components/homepage/whats-new-button.tsx | 2 +- .../components/slack/slack-thread-demo.tsx | 10 +- apps/web-roo-code/src/lib/seo.ts | 10 +- apps/web-roo-code/src/lib/structured-data.ts | 2 +- locales/ca/CONTRIBUTING.md | 12 +- locales/ca/README.md | 20 +-- locales/de/CONTRIBUTING.md | 12 +- locales/de/README.md | 20 +-- locales/es/CONTRIBUTING.md | 12 +- locales/es/README.md | 20 +-- locales/fr/CONTRIBUTING.md | 12 +- locales/fr/README.md | 20 +-- locales/hi/CONTRIBUTING.md | 12 +- locales/hi/README.md | 12 +- locales/id/CONTRIBUTING.md | 12 +- locales/id/README.md | 20 +-- locales/it/CONTRIBUTING.md | 12 +- locales/it/README.md | 20 +-- locales/ja/CONTRIBUTING.md | 12 +- locales/ja/README.md | 20 +-- locales/ko/CONTRIBUTING.md | 12 +- locales/ko/README.md | 20 +-- locales/nl/CONTRIBUTING.md | 12 +- locales/nl/README.md | 20 +-- locales/pl/CONTRIBUTING.md | 12 +- locales/pl/README.md | 20 +-- locales/pt-BR/CONTRIBUTING.md | 12 +- locales/pt-BR/README.md | 20 +-- locales/ru/CONTRIBUTING.md | 12 +- locales/ru/README.md | 20 +-- locales/tr/CONTRIBUTING.md | 12 +- locales/tr/README.md | 20 +-- locales/vi/CONTRIBUTING.md | 12 +- locales/vi/README.md | 20 +-- locales/zh-CN/CONTRIBUTING.md | 12 +- locales/zh-CN/README.md | 20 +-- locales/zh-TW/CONTRIBUTING.md | 12 +- locales/zh-TW/README.md | 20 +-- package.json | 2 +- packages/build/package.json | 2 +- packages/build/src/__tests__/index.test.ts | 4 +- packages/cloud/package.json | 2 +- packages/cloud/src/WebAuthService.ts | 26 ++-- .../src/__tests__/WebAuthService.spec.ts | 22 ++-- .../bridge/__tests__/ExtensionChannel.test.ts | 2 +- .../src/bridge/__tests__/TaskChannel.test.ts | 2 +- packages/core/package.json | 2 +- packages/evals/ADDING-EVALS.md | 6 +- packages/evals/ARCHITECTURE.md | 2 +- packages/evals/README.md | 8 +- packages/evals/package.json | 2 +- packages/evals/scripts/setup.sh | 8 +- packages/evals/src/cli/runTaskInCli.ts | 2 +- packages/evals/src/cli/utils.ts | 2 +- packages/ipc/README.md | 2 +- packages/ipc/package.json | 2 +- packages/telemetry/package.json | 2 +- .../telemetry/src/PostHogTelemetryClient.ts | 2 +- packages/types/npm/README.md | 6 +- packages/types/npm/package.metadata.json | 4 +- packages/types/src/cookie-consent.ts | 2 +- packages/types/src/global-settings.ts | 2 +- packages/types/src/image-generation.ts | 2 +- packages/types/src/provider-settings.ts | 4 +- packages/types/src/providers/chutes.ts | 2 +- packages/types/src/providers/roo.ts | 4 +- scripts/install-vsix.js | 4 +- src/__mocks__/vscode.js | 2 +- src/activate/CodeActionProvider.ts | 10 +- .../__tests__/registerCommands.spec.ts | 2 +- src/activate/registerCommands.ts | 4 +- src/api/providers/__tests__/cerebras.spec.ts | 4 +- src/api/providers/__tests__/constants.spec.ts | 2 +- src/api/providers/__tests__/openai.spec.ts | 2 +- .../providers/__tests__/openrouter.spec.ts | 2 +- src/api/providers/__tests__/requesty.spec.ts | 4 +- src/api/providers/__tests__/roo.spec.ts | 4 +- .../__tests__/vercel-ai-gateway.spec.ts | 2 +- src/api/providers/constants.ts | 2 +- .../providers/fetchers/__tests__/roo.spec.ts | 8 +- src/api/providers/fetchers/modelCache.ts | 2 +- src/api/providers/fetchers/roo.ts | 20 +-- src/api/providers/lm-studio.ts | 4 +- src/api/providers/roo.ts | 4 +- .../utils/__tests__/error-handler.spec.ts | 4 +- src/api/providers/utils/image-generation.ts | 6 +- src/api/providers/vscode-lm.ts | 70 +++++----- src/api/transform/anthropic-filter.ts | 2 +- src/api/transform/vscode-lm-format.ts | 4 +- .../__tests__/multi-search-replace.spec.ts | 8 +- src/core/tools/GenerateImageTool.ts | 2 +- src/core/webview/ClineProvider.ts | 6 +- .../__tests__/diagnosticsHandler.spec.ts | 2 +- src/core/webview/diagnosticsHandler.ts | 2 +- src/core/webview/webviewMessageHandler.ts | 2 +- src/core/webview/worktree/handlers.ts | 2 +- src/extension.ts | 10 +- src/i18n/locales/ca/common.json | 8 +- src/i18n/locales/ca/tools.json | 2 +- src/i18n/locales/de/common.json | 8 +- src/i18n/locales/de/tools.json | 2 +- src/i18n/locales/en/common.json | 8 +- src/i18n/locales/en/tools.json | 2 +- src/i18n/locales/es/common.json | 8 +- src/i18n/locales/es/tools.json | 2 +- src/i18n/locales/fr/common.json | 8 +- src/i18n/locales/fr/tools.json | 2 +- src/i18n/locales/hi/common.json | 8 +- src/i18n/locales/hi/tools.json | 2 +- src/i18n/locales/id/common.json | 8 +- src/i18n/locales/id/tools.json | 2 +- src/i18n/locales/it/common.json | 8 +- src/i18n/locales/it/tools.json | 2 +- src/i18n/locales/ja/common.json | 8 +- src/i18n/locales/ja/tools.json | 2 +- src/i18n/locales/ko/common.json | 8 +- src/i18n/locales/ko/tools.json | 2 +- src/i18n/locales/nl/common.json | 8 +- src/i18n/locales/nl/tools.json | 2 +- src/i18n/locales/pl/common.json | 8 +- src/i18n/locales/pl/tools.json | 2 +- src/i18n/locales/pt-BR/common.json | 8 +- src/i18n/locales/pt-BR/tools.json | 2 +- src/i18n/locales/ru/common.json | 8 +- src/i18n/locales/ru/tools.json | 2 +- src/i18n/locales/tr/common.json | 8 +- src/i18n/locales/tr/tools.json | 2 +- src/i18n/locales/vi/common.json | 8 +- src/i18n/locales/vi/tools.json | 2 +- src/i18n/locales/zh-CN/common.json | 8 +- src/i18n/locales/zh-CN/tools.json | 2 +- src/i18n/locales/zh-TW/common.json | 8 +- src/i18n/locales/zh-TW/tools.json | 2 +- .../claude-code/streaming-client.ts | 2 +- .../__tests__/extract-text-from-xlsx.test.ts | 4 +- .../terminal/BaseTerminalProcess.ts | 2 +- src/integrations/terminal/Terminal.ts | 2 +- .../__tests__/TerminalProcess.spec.ts | 2 +- .../TerminalProcessExec.bash.spec.ts | 2 +- .../__tests__/TerminalProcessExec.cmd.spec.ts | 2 +- .../TerminalProcessExec.pwsh.spec.ts | 2 +- .../__tests__/TerminalRegistry.spec.ts | 10 +- src/package.json | 15 ++- src/package.nls.ca.json | 14 +- src/package.nls.de.json | 14 +- src/package.nls.es.json | 14 +- src/package.nls.fr.json | 14 +- src/package.nls.hi.json | 14 +- src/package.nls.id.json | 14 +- src/package.nls.it.json | 14 +- src/package.nls.ja.json | 14 +- src/package.nls.json | 18 +-- src/package.nls.ko.json | 14 +- src/package.nls.nl.json | 14 +- src/package.nls.pl.json | 14 +- src/package.nls.pt-BR.json | 14 +- src/package.nls.ru.json | 14 +- src/package.nls.tr.json | 14 +- src/package.nls.vi.json | 14 +- src/package.nls.zh-CN.json | 14 +- src/package.nls.zh-TW.json | 14 +- .../checkpoints/ShadowCheckpointService.ts | 2 +- .../__tests__/ShadowCheckpointService.spec.ts | 8 +- .../embedders/__tests__/openrouter.spec.ts | 2 +- .../code-index/embedders/openrouter.ts | 2 +- .../__tests__/frontmatter-commands.spec.ts | 4 +- src/services/mcp/McpHub.ts | 2 +- src/services/mdm/__tests__/MdmService.spec.ts | 8 +- .../__tests__/markdownParser.spec.ts | 4 +- src/utils/networkProxy.ts | 4 +- .../chat/__tests__/TaskActions.spec.tsx | 6 +- .../__tests__/CloudUpsellDialog.spec.tsx | 6 +- .../cloud/__tests__/CloudView.spec.tsx | 24 ++-- .../MarketplaceViewStateManager.ts | 4 +- .../settings/ImageGenerationSettings.tsx | 2 +- .../src/components/settings/constants.ts | 2 +- .../src/components/settings/providers/Roo.tsx | 2 +- .../ui/hooks/useRooCreditBalance.ts | 2 +- .../welcome/WelcomeViewProvider.tsx | 2 +- webview-ui/src/i18n/locales/ca/chat.json | 24 ++-- webview-ui/src/i18n/locales/ca/cloud.json | 18 +-- webview-ui/src/i18n/locales/ca/settings.json | 18 +-- webview-ui/src/i18n/locales/ca/welcome.json | 22 ++-- webview-ui/src/i18n/locales/ca/worktrees.json | 2 +- webview-ui/src/i18n/locales/de/chat.json | 24 ++-- webview-ui/src/i18n/locales/de/cloud.json | 18 +-- webview-ui/src/i18n/locales/de/settings.json | 18 +-- webview-ui/src/i18n/locales/de/welcome.json | 22 ++-- webview-ui/src/i18n/locales/de/worktrees.json | 2 +- webview-ui/src/i18n/locales/en/chat.json | 20 +-- webview-ui/src/i18n/locales/en/cloud.json | 18 +-- webview-ui/src/i18n/locales/en/settings.json | 18 +-- webview-ui/src/i18n/locales/en/welcome.json | 22 ++-- webview-ui/src/i18n/locales/en/worktrees.json | 2 +- webview-ui/src/i18n/locales/es/chat.json | 24 ++-- webview-ui/src/i18n/locales/es/cloud.json | 18 +-- webview-ui/src/i18n/locales/es/settings.json | 18 +-- webview-ui/src/i18n/locales/es/welcome.json | 22 ++-- webview-ui/src/i18n/locales/es/worktrees.json | 2 +- webview-ui/src/i18n/locales/fr/chat.json | 24 ++-- webview-ui/src/i18n/locales/fr/cloud.json | 18 +-- webview-ui/src/i18n/locales/fr/settings.json | 18 +-- webview-ui/src/i18n/locales/fr/welcome.json | 20 +-- webview-ui/src/i18n/locales/fr/worktrees.json | 2 +- webview-ui/src/i18n/locales/hi/chat.json | 24 ++-- webview-ui/src/i18n/locales/hi/cloud.json | 14 +- webview-ui/src/i18n/locales/hi/settings.json | 16 +-- webview-ui/src/i18n/locales/hi/welcome.json | 22 ++-- webview-ui/src/i18n/locales/hi/worktrees.json | 2 +- webview-ui/src/i18n/locales/id/chat.json | 24 ++-- webview-ui/src/i18n/locales/id/cloud.json | 18 +-- webview-ui/src/i18n/locales/id/mcp.json | 2 +- webview-ui/src/i18n/locales/id/prompts.json | 2 +- webview-ui/src/i18n/locales/id/settings.json | 20 +-- webview-ui/src/i18n/locales/id/welcome.json | 22 ++-- webview-ui/src/i18n/locales/id/worktrees.json | 2 +- webview-ui/src/i18n/locales/it/chat.json | 24 ++-- webview-ui/src/i18n/locales/it/cloud.json | 18 +-- webview-ui/src/i18n/locales/it/settings.json | 18 +-- webview-ui/src/i18n/locales/it/welcome.json | 22 ++-- webview-ui/src/i18n/locales/it/worktrees.json | 2 +- webview-ui/src/i18n/locales/ja/chat.json | 24 ++-- webview-ui/src/i18n/locales/ja/cloud.json | 18 +-- webview-ui/src/i18n/locales/ja/settings.json | 18 +-- webview-ui/src/i18n/locales/ja/welcome.json | 22 ++-- webview-ui/src/i18n/locales/ja/worktrees.json | 2 +- webview-ui/src/i18n/locales/ko/chat.json | 24 ++-- webview-ui/src/i18n/locales/ko/cloud.json | 18 +-- webview-ui/src/i18n/locales/ko/settings.json | 18 +-- webview-ui/src/i18n/locales/ko/welcome.json | 22 ++-- webview-ui/src/i18n/locales/ko/worktrees.json | 2 +- webview-ui/src/i18n/locales/nl/chat.json | 24 ++-- webview-ui/src/i18n/locales/nl/cloud.json | 18 +-- webview-ui/src/i18n/locales/nl/settings.json | 18 +-- webview-ui/src/i18n/locales/nl/welcome.json | 22 ++-- webview-ui/src/i18n/locales/nl/worktrees.json | 2 +- webview-ui/src/i18n/locales/pl/chat.json | 24 ++-- webview-ui/src/i18n/locales/pl/cloud.json | 18 +-- webview-ui/src/i18n/locales/pl/settings.json | 18 +-- webview-ui/src/i18n/locales/pl/welcome.json | 22 ++-- webview-ui/src/i18n/locales/pl/worktrees.json | 2 +- webview-ui/src/i18n/locales/pt-BR/chat.json | 24 ++-- webview-ui/src/i18n/locales/pt-BR/cloud.json | 18 +-- .../src/i18n/locales/pt-BR/settings.json | 16 +-- .../src/i18n/locales/pt-BR/welcome.json | 22 ++-- .../src/i18n/locales/pt-BR/worktrees.json | 2 +- webview-ui/src/i18n/locales/ru/chat.json | 24 ++-- webview-ui/src/i18n/locales/ru/cloud.json | 18 +-- webview-ui/src/i18n/locales/ru/settings.json | 18 +-- webview-ui/src/i18n/locales/ru/welcome.json | 22 ++-- webview-ui/src/i18n/locales/ru/worktrees.json | 2 +- webview-ui/src/i18n/locales/tr/chat.json | 24 ++-- webview-ui/src/i18n/locales/tr/cloud.json | 18 +-- webview-ui/src/i18n/locales/tr/settings.json | 18 +-- webview-ui/src/i18n/locales/tr/welcome.json | 22 ++-- webview-ui/src/i18n/locales/tr/worktrees.json | 2 +- webview-ui/src/i18n/locales/vi/chat.json | 24 ++-- webview-ui/src/i18n/locales/vi/cloud.json | 18 +-- webview-ui/src/i18n/locales/vi/settings.json | 16 +-- webview-ui/src/i18n/locales/vi/welcome.json | 22 ++-- webview-ui/src/i18n/locales/vi/worktrees.json | 2 +- webview-ui/src/i18n/locales/zh-CN/chat.json | 24 ++-- webview-ui/src/i18n/locales/zh-CN/cloud.json | 18 +-- .../src/i18n/locales/zh-CN/settings.json | 16 +-- .../src/i18n/locales/zh-CN/welcome.json | 22 ++-- .../src/i18n/locales/zh-CN/worktrees.json | 2 +- webview-ui/src/i18n/locales/zh-TW/chat.json | 24 ++-- webview-ui/src/i18n/locales/zh-TW/cloud.json | 18 +-- .../src/i18n/locales/zh-TW/settings.json | 18 +-- .../src/i18n/locales/zh-TW/welcome.json | 22 ++-- .../src/i18n/locales/zh-TW/worktrees.json | 2 +- webview-ui/src/utils/docLinks.ts | 2 +- 338 files changed, 1946 insertions(+), 1819 deletions(-) create mode 100644 CLAUDE.md diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md index e83e44cd66d..e32452bacb6 100644 --- a/.github/pull_request_template.md +++ b/.github/pull_request_template.md @@ -1,5 +1,5 @@ -### Roo Code Task Context (Optional) +### Klaus Code Task Context (Optional) diff --git a/.roo/commands/cli-release.md b/.roo/commands/cli-release.md index 70b3698528d..eb57e8d2fea 100644 --- a/.roo/commands/cli-release.md +++ b/.roo/commands/cli-release.md @@ -1,5 +1,5 @@ --- -description: "Create a new release of the Roo Code CLI" +description: "Create a new release of the Klaus Code CLI" argument-hint: "[version-description]" mode: code --- diff --git a/.roo/commands/release.md b/.roo/commands/release.md index 2e09783a58e..2cee9ab6533 100644 --- a/.roo/commands/release.md +++ b/.roo/commands/release.md @@ -1,5 +1,5 @@ --- -description: "Create a new release of the Roo Code extension" +description: "Create a new release of the Klaus Code extension" argument-hint: patch | minor | major mode: code --- diff --git a/.roo/skills/evals-context/SKILL.md b/.roo/skills/evals-context/SKILL.md index 985b788b94f..5c60783237b 100644 --- a/.roo/skills/evals-context/SKILL.md +++ b/.roo/skills/evals-context/SKILL.md @@ -1,6 +1,6 @@ --- name: evals-context -description: Provides context about the Roo Code evals system structure in this monorepo. Use when tasks mention "evals", "evaluation", "eval runs", "eval exercises", or working with the evals infrastructure. Helps distinguish between the evals execution system (packages/evals, apps/web-evals) and the public website evals display page (apps/web-roo-code/src/app/evals). +description: Provides context about the Klaus Code evals system structure in this monorepo. Use when tasks mention "evals", "evaluation", "eval runs", "eval exercises", or working with the evals infrastructure. Helps distinguish between the evals execution system (packages/evals, apps/web-evals) and the public website evals display page (apps/web-roo-code/src/app/evals). --- # Evals Codebase Context @@ -115,7 +115,7 @@ The evals system is a distributed evaluation platform that runs AI coding tasks **Key components:** - **Controller**: Orchestrates eval runs, spawns runners, manages task queue (p-queue) -- **Runner**: Isolated Docker container with VS Code + Roo Code extension + language runtimes +- **Runner**: Isolated Docker container with VS Code + Klaus Code extension + language runtimes - **Redis**: Pub/sub for real-time events (NOT task queuing) - **PostgreSQL**: Stores runs, tasks, metrics diff --git a/CHANGELOG.md b/CHANGELOG.md index 9eb498b7f50..3df1969d394 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -# Roo Code Changelog +# Klaus Code Changelog ## [3.42.0] - 2026-01-22 @@ -22,14 +22,14 @@ - Fix: Remove custom condensing model option (PR #10901 by @hannesrudolph) - Unify user content tags to for consistent prompt formatting (#10658 by @hannesrudolph, PR #10723 by @app/roomote) - Clarify linked SKILL.md file handling in prompts (PR #10907 by @hannesrudolph) -- Fix: Padding on Roo Code Cloud teaser (PR #10889 by @app/roomote) +- Fix: Padding on Klaus Code Cloud teaser (PR #10889 by @app/roomote) ## [3.41.3] - 2026-01-18 - Fix: Thinking block word-breaking to prevent horizontal scroll in the chat UI (PR #10806 by @roomote) -- Add Claude-like CLI flags and authentication fixes for the Roo Code CLI (PR #10797 by @cte) +- Add Claude-like CLI flags and authentication fixes for the Klaus Code CLI (PR #10797 by @cte) - Improve CLI authentication by using a redirect instead of a fetch (PR #10799 by @cte) -- Fix: Roo Code Router fixes for the CLI (PR #10789 by @cte) +- Fix: Klaus Code Router fixes for the CLI (PR #10789 by @cte) - Release CLI v0.0.48 with latest improvements (PR #10800 by @cte) - Release CLI v0.0.47 (PR #10798 by @cte) - Revert E2E tests enablement to address stability issues (PR #10794 by @cte) @@ -91,10 +91,10 @@ ## [3.39.3] - 2026-01-10 -![3.39.3 Release - Roo Code Router](/releases/3.39.3-release.png) +![3.39.3 Release - Klaus Code Router](/releases/3.39.3-release.png) -- Rename Roo Code Cloud Provider to Roo Code Router for clearer branding (PR #10560 by @roomote) -- Update Roo Code Router service name throughout the codebase (PR #10607 by @mrubens) +- Rename Klaus Code Cloud Provider to Klaus Code Router for clearer branding (PR #10560 by @roomote) +- Update Klaus Code Router service name throughout the codebase (PR #10607 by @mrubens) - Update router name in types for consistency (PR #10605 by @mrubens) - Improve ExtensionHost code organization and cleanup (PR #10600 by @cte) - Add local installation option to CLI release script for testing (PR #10597 by @cte) @@ -139,8 +139,8 @@ - Filter @ mention file search results using .rooignore (#10169 by @jerrill-johnson-bitwerx, PR #10174 by @roomote) - Add image support documentation to read_file native tool description (#10440 by @nabilfreeman, PR #10442 by @roomote) - Add zai-glm-4.7 to Cerebras models (PR #10500 by @sebastiand-cerebras) -- VSCode shim and basic CLI for running Roo Code headlessly (PR #10452 by @cte) -- Add CLI installer for headless Roo Code (PR #10474 by @cte) +- VSCode shim and basic CLI for running Klaus Code headlessly (PR #10452 by @cte) +- Add CLI installer for headless Klaus Code (PR #10474 by @cte) - Add option to use CLI for evals (PR #10456 by @cte) - Remember last Roo model selection in web-evals and add evals skill (PR #10470 by @hannesrudolph) - Tweak the style of follow up suggestion modes (PR #9260 by @mrubens) @@ -205,7 +205,7 @@ - Fix: Drain queued messages while waiting for ask to prevent message loss (PR #10315 by @hannesrudolph) - Feat: Add grace retry for empty assistant messages to improve reliability (PR #10297 by @hannesrudolph) - Feat: Enable mergeToolResultText for all OpenAI-compatible providers for better tool result handling (PR #10299 by @hannesrudolph) -- Feat: Enable mergeToolResultText for Roo Code Router (PR #10301 by @hannesrudolph) +- Feat: Enable mergeToolResultText for Klaus Code Router (PR #10301 by @hannesrudolph) - Feat: Strengthen native tool-use guidance in prompts for improved model behavior (PR #10311 by @hannesrudolph) - UX: Account-centric signup flow for improved onboarding experience (PR #10306 by @brunobergher) @@ -499,7 +499,7 @@ - Native tool calling support expanded across many providers: Bedrock (PR #9698 by @mrubens), Cerebras (PR #9692 by @mrubens), Chutes with auto-detection from API (PR #9715 by @daniel-lxs), DeepInfra (PR #9691 by @mrubens), DeepSeek and Doubao (PR #9671 by @daniel-lxs), Groq (PR #9673 by @daniel-lxs), LiteLLM (PR #9719 by @daniel-lxs), Ollama (PR #9696 by @mrubens), OpenAI-compatible providers (PR #9676 by @daniel-lxs), Requesty (PR #9672 by @daniel-lxs), Unbound (PR #9699 by @mrubens), Vercel AI Gateway (PR #9697 by @mrubens), Vertex Gemini (PR #9678 by @daniel-lxs), and xAI with new Grok 4 Fast and Grok 4.1 Fast models (PR #9690 by @mrubens) - Fix: Preserve tool_use blocks in summary for parallel tool calls (#9700 by @SilentFlower, PR #9714 by @SilentFlower) - Default Grok Code Fast to native tools for better performance (PR #9717 by @mrubens) -- UX improvements to the Roo Code Router-centric onboarding flow (PR #9709 by @brunobergher) +- UX improvements to the Klaus Code Router-centric onboarding flow (PR #9709 by @brunobergher) - UX toolbar cleanup and settings consolidation for a cleaner interface (PR #9710 by @brunobergher) - Add model-specific tool customization via `excludedTools` and `includedTools` configuration (PR #9641 by @daniel-lxs) - Add new `apply_patch` native tool for more efficient file editing operations (PR #9663 by @hannesrudolph) @@ -557,13 +557,13 @@ - Set native tools as default for minimax-m2 and claude-haiku-4.5 (PR #9586 by @daniel-lxs) - Make single file read only apply to XML tools (PR #9600 by @mrubens) - Enhance web-evals dashboard with dynamic tool columns and UX improvements (PR #9592 by @hannesrudolph) -- Revert "Add support for Roo Code Cloud as an embeddings provider" while we fix some issues (PR #9602 by @mrubens) +- Revert "Add support for Klaus Code Cloud as an embeddings provider" while we fix some issues (PR #9602 by @mrubens) ## [3.34.4] - 2025-11-25 ![3.34.4 Release - BFL Image Generation](/releases/3.34.4-release.png) -- Add new Black Forest Labs image generation models, free on Roo Code Cloud and also available on OpenRouter (PR #9587 and #9589 by @mrubens) +- Add new Black Forest Labs image generation models, free on Klaus Code Cloud and also available on OpenRouter (PR #9587 and #9589 by @mrubens) - Fix: Preserve dynamic MCP tool names in native mode API history to prevent tool name mismatches (PR #9559 by @daniel-lxs) - Fix: Preserve tool_use blocks in summary message during condensing with native tools to maintain conversation context (PR #9582 by @daniel-lxs) @@ -575,9 +575,9 @@ - Add Claude Opus 4.5 model to Claude Code provider (PR #9560 by @mrubens) - Add Claude Opus 4.5 model to Bedrock provider (#9571 by @pisicode, PR #9572 by @roomote) - Enable caching for Opus 4.5 model to improve performance (#9567 by @iainRedro, PR #9568 by @roomote) -- Add support for Roo Code Cloud as an embeddings provider (PR #9543 by @mrubens) +- Add support for Klaus Code Cloud as an embeddings provider (PR #9543 by @mrubens) - Fix ask_followup_question streaming issue and add missing tool cases (PR #9561 by @daniel-lxs) -- Add contact links to About Roo Code settings page (PR #9570 by @roomote) +- Add contact links to About Klaus Code settings page (PR #9570 by @roomote) - Switch from asdf to mise-en-place in bare-metal evals setup script (PR #9548 by @cte) ## [3.34.2] - 2025-11-24 @@ -586,7 +586,7 @@ - Add support for Claude Opus 4.5 in Anthropic and Vertex providers (PR #9541 by @daniel-lxs) - Add support for Claude Opus 4.5 in OpenRouter with prompt caching and reasoning budget (PR #9540 by @daniel-lxs) -- Add Roo Code Cloud as an image generation provider (PR #9528 by @mrubens) +- Add Klaus Code Cloud as an image generation provider (PR #9528 by @mrubens) - Fix: Gracefully skip unsupported content blocks in Gemini transformer (PR #9537 by @daniel-lxs) - Fix: Flush LiteLLM cache when credentials change on refresh (PR #9536 by @daniel-lxs) - Fix: Ensure XML parser state matches tool protocol on config update (PR #9535 by @daniel-lxs) @@ -598,7 +598,7 @@ - Show the prompt for image generation in the UI (PR #9505 by @mrubens) - Fix double todo list display issue (PR #9517 by @mrubens) - Add tracking for cloud synced messages (PR #9518 by @mrubens) -- Enable the Roo Code Router in evals (PR #9492 by @cte) +- Enable the Klaus Code Router in evals (PR #9492 by @cte) ## [3.34.0] - 2025-11-21 @@ -678,7 +678,7 @@ - Use VSCode theme color for outline button borders (PR #9336 by @app/roomote) - Replace broken badgen.net badges with shields.io (PR #9318 by @app/roomote) - Add max git status files setting to evals (PR #9322 by @mrubens) -- Roo Code Router pricing page and changes elsewhere (PR #9195 by @brunobergher) +- Klaus Code Router pricing page and changes elsewhere (PR #9195 by @brunobergher) ## [3.32.1] - 2025-11-14 @@ -704,7 +704,7 @@ ![3.31.3 Release - Kangaroo Decrypting a Message](/releases/3.31.3-release.png) - Fix: OpenAI Native encrypted_content handling and remove gpt-5-chat-latest verbosity flag (#9225 by @politsin, PR by @hannesrudolph) -- Fix: Roo Code Router Anthropic input token normalization to avoid double-counting (thanks @hannesrudolph!) +- Fix: Klaus Code Router Anthropic input token normalization to avoid double-counting (thanks @hannesrudolph!) - Refactor: Rename sliding-window to context-management and truncateConversationIfNeeded to manageContext (thanks @hannesrudolph!) ## [3.31.2] - 2025-11-12 @@ -844,7 +844,7 @@ - Add token-budget based file reading with intelligent preview to avoid context overruns (thanks @daniel-lxs!) - Enable browser-use tool for all image-capable models (#8116 by @hannesrudolph, PR by @app/roomote!) -- Add dynamic model loading for Roo Code Router (thanks @app/roomote!) +- Add dynamic model loading for Klaus Code Router (thanks @app/roomote!) - Fix: Respect nested .gitignore files in search_files (#7921 by @hannesrudolph, PR by @daniel-lxs) - Fix: Preserve trailing newlines in stripLineNumbers for apply_diff (#8020 by @liyi3c, PR by @app/roomote) - Fix: Exclude max tokens field for models that don't support it in export (#7944 by @hannesrudolph, PR by @elianiva) @@ -1002,7 +1002,7 @@ - UX: Responsive Auto-Approve (thanks @brunobergher!) - Add telemetry retry queue for network resilience (thanks @daniel-lxs!) - Fix: Transform keybindings in nightly build to fix command+y shortcut (thanks @app/roomote!) -- New code-supernova stealth model in the Roo Code Router (thanks @mrubens!) +- New code-supernova stealth model in the Klaus Code Router (thanks @mrubens!) ## [3.28.3] - 2025-09-16 @@ -1040,8 +1040,8 @@ ![3.28.1 Release - Kangaroo riding rocket to the clouds](/releases/3.28.1-release.png) -- Announce Roo Code Cloud! -- Add cloud task button for opening tasks in Roo Code Cloud (thanks @app/roomote!) +- Announce Klaus Code Cloud! +- Add cloud task button for opening tasks in Klaus Code Cloud (thanks @app/roomote!) - Make Posthog telemetry the default (thanks @mrubens!) - Show notification when the checkpoint initialization fails (thanks @app/roomote!) - Bust cache in generated image preview (thanks @mrubens!) @@ -1050,9 +1050,9 @@ ## [3.28.0] - 2025-09-10 -![3.28.0 Release - Continue tasks in Roo Code Cloud](/releases/3.28.0-release.png) +![3.28.0 Release - Continue tasks in Klaus Code Cloud](/releases/3.28.0-release.png) -- feat: Continue tasks in Roo Code Cloud (thanks @brunobergher!) +- feat: Continue tasks in Klaus Code Cloud (thanks @brunobergher!) - feat: Support connecting to Cloud without redirect handling (thanks @mrubens!) - feat: Add toggle to control task syncing to Cloud (thanks @jr!) - feat: Add click-to-edit, ESC-to-cancel, and fix padding consistency for chat messages (#7788 by @hannesrudolph, PR by @app/roomote) @@ -1090,7 +1090,7 @@ ![3.26.7 Release - OpenAI Service Tiers](/releases/3.26.7-release.png) - Feature: Add OpenAI Responses API service tiers (flex/priority) with UI selector and pricing (thanks @hannesrudolph!) -- Feature: Add DeepInfra as a model provider in Roo Code (#7661 by @Thachnh, PR by @Thachnh) +- Feature: Add DeepInfra as a model provider in Klaus Code (#7661 by @Thachnh, PR by @Thachnh) - Feature: Update kimi-k2-0905-preview and kimi-k2-turbo-preview models on the Moonshot provider (thanks @CellenLee!) - Feature: Add kimi-k2-0905-preview to Groq, Moonshot, and Fireworks (thanks @daniel-lxs and Cline!) - Fix: Prevent countdown timer from showing in history for answered follow-up questions (#7624 by @XuyiK, PR by @daniel-lxs) @@ -1213,11 +1213,11 @@ ## [3.25.19] - 2025-08-19 -- Fix issue where new users couldn't select the Roo Code Router (thanks @daniel-lxs!) +- Fix issue where new users couldn't select the Klaus Code Router (thanks @daniel-lxs!) ## [3.25.18] - 2025-08-19 -- Add new stealth Sonic model through the Roo Code Router +- Add new stealth Sonic model through the Klaus Code Router - Fix: respect enableReasoningEffort setting when determining reasoning usage (#7048 by @ikbencasdoei, PR by @app/roomote) - Fix: prevent duplicate LM Studio models with case-insensitive deduplication (#6954 by @fbuechler, PR by @daniel-lxs) - Feat: simplify ask_followup_question prompt documentation (thanks @daniel-lxs!) @@ -1432,7 +1432,7 @@ ## [3.23.19] - 2025-07-23 -- Add Roo Code Cloud Waitlist CTAs (thanks @brunobergher!) +- Add Klaus Code Cloud Waitlist CTAs (thanks @brunobergher!) - Split commands on newlines when evaluating auto-approve - Smarter auto-deny of commands @@ -2052,7 +2052,7 @@ - Fix display issue of the programming language dropdown in the code block component (thanks @zhangtony239) - MCP server errors are now captured and shown in a new "Errors" tab (thanks @robertheadley) - Error logging will no longer break MCP functionality if the server is properly connected (thanks @ksze) -- You can now toggle the `terminal.integrated.inheritEnv` VSCode setting directly for the Roo Code settings (thanks @KJ7LNW) +- You can now toggle the `terminal.integrated.inheritEnv` VSCode setting directly for the Klaus Code settings (thanks @KJ7LNW) - Add `gemini-2.5-pro-preview-05-06` to the Vertex and Gemini providers (thanks @zetaloop) - Ensure evals exercises are up-to-date before running evals (thanks @shariqriazz) - Lots of general UI improvements (thanks @elianiva) @@ -2069,7 +2069,7 @@ ## [3.15.4] - 2025-05-04 -- Fix a nasty bug that would cause Roo Code to hang, particularly in orchestrator mode +- Fix a nasty bug that would cause Klaus Code to hang, particularly in orchestrator mode - Improve Gemini caching efficiency ## [3.15.3] - 2025-05-02 @@ -2108,8 +2108,8 @@ - Improve the auto-approve toggle buttons for some high-contrast VSCode themes - Offload expensive count token operations to a web worker (thanks @samhvw8) - Improve support for mult-root workspaces (thanks @snoyiatk) -- Simplify and streamline Roo Code's quick actions -- Allow Roo Code settings to be imported from the welcome screen (thanks @julionav) +- Simplify and streamline Klaus Code's quick actions +- Allow Klaus Code settings to be imported from the welcome screen (thanks @julionav) - Remove unused types (thanks @wkordalski) - Improve the performance of mode switching (thanks @dlab-anton) - Fix importing & exporting of custom modes (thanks @julionav) @@ -2273,7 +2273,7 @@ - Improve readFileTool XML output format (thanks @KJ7LNW!) - Add o1-pro support (thanks @arthurauffray!) - Follow symlinked rules files/directories to allow for more flexible rule setups -- Focus Roo Code in the sidebar when running tasks in the sidebar via the API +- Focus Klaus Code in the sidebar when running tasks in the sidebar via the API - Improve subtasks UI ## [3.11.10] - 2025-04-08 @@ -2295,7 +2295,7 @@ - Enhance Rust tree-sitter parser with advanced language structures (thanks @KJ7LNW!) - Persist settings on api.setConfiguration (thanks @gtaylor!) - Add deep links to settings sections -- Add command to focus Roo Code input field (thanks @axkirillov!) +- Add command to focus Klaus Code input field (thanks @axkirillov!) - Add resize and hover actions to the browser (thanks @SplittyDev!) - Add resumeTask and isTaskInHistory to the API (thanks @franekp!) - Fix bug displaying boolean/numeric suggested answers @@ -2344,7 +2344,7 @@ - Fix issue where prompts and settings tabs were not scrollable when accessed from dropdown menus - Update AWS region dropdown menu to the most recent data (thanks @Smartsheet-JB-Brown!) - Fix prompt enhancement for Bedrock (thanks @Smartsheet-JB-Brown!) -- Allow processes to access the Roo Code API via a unix socket +- Allow processes to access the Klaus Code API via a unix socket - Improve zh-TW Traditional Chinese translations (thanks @PeterDaveHello!) - Add support for Azure AI Inference Service with DeepSeek-V3 model (thanks @thomasjeung!) - Fix off-by-one error in tree-sitter line numbers @@ -2381,7 +2381,7 @@ - Fix list_code_definition_names to support files (thanks @KJ7LNW!) - Refactor tool-calling logic to make the code a lot easier to work with (thanks @diarmidmackenzie, @bramburn, @KJ7LNW, and everyone else who helped!) - Prioritize “Add to Context” in the code actions and include line numbers (thanks @samhvw8!) -- Add an activation command that other extensions can use to interface with Roo Code (thanks @gtaylor!) +- Add an activation command that other extensions can use to interface with Klaus Code (thanks @gtaylor!) - Preserve language characters in file @-mentions (thanks @aheizi!) - Browser tool improvements (thanks @afshawnlotfi!) - Display info about partial reads in the chat row @@ -2473,7 +2473,7 @@ ## [3.9.0] - 2025-03-18 -- Internationalize Roo Code into Catalan, German, Spanish, French, Hindi, Italian, Japanese, Korean, Polish, Portuguese, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese (thanks @feifei325!) +- Internationalize Klaus Code into Catalan, German, Spanish, French, Hindi, Italian, Japanese, Korean, Polish, Portuguese, Turkish, Vietnamese, Simplified Chinese, and Traditional Chinese (thanks @feifei325!) - Bring back support for MCP over SSE (thanks @aheizi!) - Add a text-to-speech option to have Roo talk to you as it works (thanks @heyseth!) - Choose a specific provider when using OpenRouter (thanks PhunkyBob!) @@ -2553,17 +2553,17 @@ ## [3.8.0] - 2025-03-07 -- Add opt-in telemetry to help us improve Roo Code faster (thanks Cline!) +- Add opt-in telemetry to help us improve Klaus Code faster (thanks Cline!) - Fix terminal overload / gray screen of death, and other terminal issues - Add a new experimental diff editing strategy that applies multiple diff edits at once (thanks @qdaxb!) -- Add support for a .rooignore to prevent Roo Code from read/writing certain files, with a setting to also exclude them from search/lists (thanks Cline!) +- Add support for a .rooignore to prevent Klaus Code from read/writing certain files, with a setting to also exclude them from search/lists (thanks Cline!) - Update the new_task tool to return results to the parent task on completion, supporting better orchestration (thanks @shaybc!) - Support running Roo in multiple editor windows simultaneously (thanks @samhvw8!) - Make checkpoints asynchronous and exclude more files to speed them up - Redesign the settings page to make it easier to navigate - Add credential-based authentication for Vertex AI, enabling users to easily switch between Google Cloud accounts (thanks @eonghk!) - Update the DeepSeek provider with the correct baseUrl and track caching correctly (thanks @olweraltuve!) -- Add a new “Human Relay” provider that allows you to manually copy information to a Web AI when needed, and then paste the AI's response back into Roo Code (thanks @NyxJae)! +- Add a new “Human Relay” provider that allows you to manually copy information to a Web AI when needed, and then paste the AI's response back into Klaus Code (thanks @NyxJae)! - Add observability for OpenAI providers (thanks @refactorthis!) - Support speculative decoding for LM Studio local models (thanks @adamwlarson!) - Improve UI for mode/provider selectors in chat @@ -2652,7 +2652,7 @@ ## [3.7.0] - 2025-02-24 -- Introducing Roo Code 3.7, with support for the new Claude Sonnet 3.7. Because who cares about skipping version numbers anymore? Thanks @lupuletic and @cte for the PRs! +- Introducing Klaus Code 3.7, with support for the new Claude Sonnet 3.7. Because who cares about skipping version numbers anymore? Thanks @lupuletic and @cte for the PRs! ## [3.3.26] - 2025-02-27 @@ -2841,7 +2841,7 @@ - Ask and Architect modes can now edit markdown files - Custom modes can now be restricted to specific file patterns (for example, a technical writer who can only edit markdown files 👋) - Support for configuring the Bedrock provider with AWS Profiles -- New Roo Code community Discord at https://roocode.com/discord! +- New Klaus Code community Discord at https://roocode.com/discord! ## [3.2.8] @@ -2873,9 +2873,9 @@ ## [3.2.0 - 3.2.2] -- **Name Change From Roo Cline to Roo Code:** We're excited to announce our new name! After growing beyond 50,000 installations, we've rebranded from Roo Cline to Roo Code to better reflect our identity as we chart our own course. +- **Name Change From Roo Cline to Klaus Code:** We're excited to announce our new name! After growing beyond 50,000 installations, we've rebranded from Roo Cline to Klaus Code to better reflect our identity as we chart our own course. -- **Custom Modes:** Create your own personas for Roo Code! While our built-in modes (Code, Architect, Ask) are still here, you can now shape entirely new ones: +- **Custom Modes:** Create your own personas for Klaus Code! While our built-in modes (Code, Architect, Ask) are still here, you can now shape entirely new ones: - Define custom prompts - Choose which tools each mode can access - Create specialized assistants for any workflow @@ -2934,7 +2934,7 @@ Join us at https://www.reddit.com/r/RooCode to share your custom modes and be pa ## [3.0.0] -- This release adds chat modes! Now you can ask Roo Code questions about system architecture or the codebase without immediately jumping into writing code. You can even assign different API configuration profiles to each mode if you prefer to use different models for thinking vs coding. Would love feedback in the new Roo Code Reddit! https://www.reddit.com/r/RooCode +- This release adds chat modes! Now you can ask Klaus Code questions about system architecture or the codebase without immediately jumping into writing code. You can even assign different API configuration profiles to each mode if you prefer to use different models for thinking vs coding. Would love feedback in the new Klaus Code Reddit! https://www.reddit.com/r/RooCode ## [2.2.46] diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000000..53ce78a35bf --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,98 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project Overview + +Klaus Code is an AI-powered VS Code extension that assists with coding tasks. It's a TypeScript monorepo using pnpm workspaces and Turborepo. + +## Build and Development Commands + +```bash +# Install dependencies +pnpm install + +# Run all linting +pnpm lint + +# Run all type checking +pnpm check-types + +# Run all tests +pnpm test + +# Format code +pnpm format + +# Build all packages +pnpm build + +# Build and package VSIX +pnpm vsix + +# Clean all build artifacts +pnpm clean +``` + +### Running Individual Tests + +Tests use Vitest. Run tests from within the correct workspace directory: + +```bash +# Backend tests (src/) +cd src && npx vitest run path/to/test-file.test.ts + +# Webview UI tests +cd webview-ui && npx vitest run src/path/to/test-file.test.ts +``` + +Do NOT run `npx vitest run src/...` from the project root - this causes "vitest: command not found" errors. + +### Development Mode + +Press F5 in VS Code to launch the extension in debug mode. Changes hot reload automatically. + +## Repository Structure + +- `src/` - Main VS Code extension (backend) + - `api/providers/` - LLM provider integrations (Anthropic, OpenAI, Gemini, Bedrock, etc.) + - `core/` - Agent core logic + - `task/Task.ts` - Main agent task orchestration + - `tools/` - Tool implementations (ReadFile, WriteToFile, ExecuteCommand, etc.) + - `webview/ClineProvider.ts` - Bridge between extension and webview + - `config/ContextProxy.ts` - State management for settings + - `prompts/` - System prompt construction + - `services/` - Supporting services (MCP, code indexing, checkpoints, etc.) + - `integrations/` - VS Code integrations (terminal, editor, workspace) +- `webview-ui/` - React frontend (Vite, Tailwind, Radix UI) +- `packages/` - Shared packages + - `types/` - Shared TypeScript types + - `core/` - Core utilities + - `cloud/` - Cloud service integration + - `telemetry/` - Telemetry service +- `apps/` - Additional applications (CLI, e2e tests, web apps) + +## Architecture Notes + +### Settings View Pattern + +When working on `SettingsView`, inputs must bind to the local `cachedState`, NOT the live `useExtensionState()`. The `cachedState` acts as a buffer for user edits, isolating them from the `ContextProxy` source-of-truth until the user explicitly clicks "Save". Wiring inputs directly to the live state causes race conditions. + +### JSON File Writing + +Use `safeWriteJson(filePath, data)` from `src/utils/safeWriteJson.ts` instead of `JSON.stringify` with file-write operations. This utility: +- Creates parent directories automatically +- Prevents data corruption via atomic writes with locking +- Streams writes to minimize memory footprint + +Test files are exempt from this rule. + +### Styling + +Use Tailwind CSS classes instead of inline style objects. VSCode CSS variables must be added to `webview-ui/src/index.css` before using them in Tailwind classes. + +## Code Quality Rules + +- Never disable lint rules without explicit user approval +- Ensure all tests pass before submitting changes +- The `vi`, `describe`, `test`, `it` functions from Vitest are globally available (defined in tsconfig.json) - no need to import them diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 869b59a16da..8a221cef7d4 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -11,9 +11,9 @@
-# Contributing to Roo Code +# Contributing to Klaus Code -Roo Code is a community-driven project, and we deeply value every contribution. To streamline collaboration, we operate on an [Issue-First](#issue-first-approach) basis, meaning all [Pull Requests (PRs)](#submitting-a-pull-request) must first be linked to a GitHub Issue. Please review this guide carefully. +Klaus Code is a community-driven project, and we deeply value every contribution. To streamline collaboration, we operate on an [Issue-First](#issue-first-approach) basis, meaning all [Pull Requests (PRs)](#submitting-a-pull-request) must first be linked to a GitHub Issue. Please review this guide carefully. ## Table of Contents @@ -52,7 +52,7 @@ Our roadmap guides the project's direction. Align your contributions with these Mention alignment with these areas in your PRs. -### 3. Join the Roo Code Community +### 3. Join the Klaus Code Community - **Primary:** Join our [Discord](https://discord.gg/roocode) and DM **Hannes Rudolph (`hrudolph`)**. - **Alternative:** Experienced contributors can engage directly via [GitHub Projects](https://github.com/orgs/RooCodeInc/projects/1). @@ -79,7 +79,7 @@ All contributions start with a GitHub Issue using our skinny templates. ### Deciding What to Work On - Check the [GitHub Project](https://github.com/orgs/RooCodeInc/projects/1) for "Issue [Unassigned]" issues. -- For docs, visit [Roo Code Docs](https://github.com/RooCodeInc/Roo-Code-Docs). +- For docs, visit [Klaus Code Docs](https://github.com/RooCodeInc/Roo-Code-Docs). ### Reporting Bugs @@ -87,7 +87,7 @@ All contributions start with a GitHub Issue using our skinny templates. - Create a new bug using the ["Bug Report" template](https://github.com/RooCodeInc/Roo-Code/issues/new/choose) with: - Clear, numbered reproduction steps - Expected vs actual result - - Roo Code version (required); API provider/model if relevant + - Klaus Code version (required); API provider/model if relevant - **Security issues**: Report privately via [security advisories](https://github.com/RooCodeInc/Roo-Code/security/advisories/new). ## Development & Submission Process @@ -138,4 +138,4 @@ pnpm install ## Legal -By contributing, you agree your contributions will be licensed under the Apache 2.0 License, consistent with Roo Code's licensing. +By contributing, you agree your contributions will be licensed under the Apache 2.0 License, consistent with Klaus Code's licensing. diff --git a/PRIVACY.md b/PRIVACY.md index 02e8e151034..2a16364037e 100644 --- a/PRIVACY.md +++ b/PRIVACY.md @@ -1,29 +1,29 @@ -# Roo Code Privacy Policy +# Klaus Code Privacy Policy **Last Updated: September 11th, 2025** -Roo Code respects your privacy and is committed to transparency about how we handle your data. Below is a simple breakdown of where key pieces of data go—and, importantly, where they don’t. +Klaus Code respects your privacy and is committed to transparency about how we handle your data. Below is a simple breakdown of where key pieces of data go—and, importantly, where they don’t. ### **Where Your Data Goes (And Where It Doesn’t)** -- **Code & Files**: Roo Code accesses files on your local machine when needed for AI-assisted features. When you send commands to Roo Code, relevant files may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. If you select Roo Code Cloud as the model provider (proxy mode), your code may transit Roo Code servers only to forward it to the upstream provider. We do not store your code; it is deleted immediately after forwarding. Otherwise, your code is sent directly to the provider. AI providers may store data per their privacy policies. -- **Commands**: Any commands executed through Roo Code happen on your local environment. However, when you use AI-powered features, the relevant code and context from your commands may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not have access to or store this data, but AI providers may process it per their privacy policies. -- **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. If you choose Roo Code Cloud as the provider (proxy mode), prompts may transit Roo Code servers only to forward them to the upstream model and are not stored. +- **Code & Files**: Klaus Code accesses files on your local machine when needed for AI-assisted features. When you send commands to Klaus Code, relevant files may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. If you select Klaus Code Cloud as the model provider (proxy mode), your code may transit Klaus Code servers only to forward it to the upstream provider. We do not store your code; it is deleted immediately after forwarding. Otherwise, your code is sent directly to the provider. AI providers may store data per their privacy policies. +- **Commands**: Any commands executed through Klaus Code happen on your local environment. However, when you use AI-powered features, the relevant code and context from your commands may be transmitted to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not have access to or store this data, but AI providers may process it per their privacy policies. +- **Prompts & AI Requests**: When you use AI-powered features, your prompts and relevant project context are sent to your chosen AI model provider (e.g., OpenAI, Anthropic, OpenRouter) to generate responses. We do not store or process this data. These AI providers have their own privacy policies and may store data per their terms of service. If you choose Klaus Code Cloud as the provider (proxy mode), prompts may transit Klaus Code servers only to forward them to the upstream model and are not stored. - **API Keys & Credentials**: If you enter an API key (e.g., to connect an AI model), it is stored locally on your device and never sent to us or any third party, except the provider you have chosen. -- **Telemetry (Usage Data)**: We collect anonymous feature usage and error data to help us improve Roo Code. This telemetry is powered by PostHog and includes your VS Code machine ID, feature usage patterns, and exception reports. This telemetry does **not** collect personally identifiable information, your code, or AI prompts. You can opt out of this telemetry at any time through the settings. -- **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Roo Code makes a secure API call to Roo Code's backend servers to retrieve listing information. These requests send only the query parameters (e.g., extension version, search term) necessary to fulfill the request and do not include your code, prompts, or personally identifiable information. +- **Telemetry (Usage Data)**: We collect anonymous feature usage and error data to help us improve Klaus Code. This telemetry is powered by PostHog and includes your VS Code machine ID, feature usage patterns, and exception reports. This telemetry does **not** collect personally identifiable information, your code, or AI prompts. You can opt out of this telemetry at any time through the settings. +- **Marketplace Requests**: When you browse or search the Marketplace for Model Configuration Profiles (MCPs) or Custom Modes, Klaus Code makes a secure API call to Klaus Code's backend servers to retrieve listing information. These requests send only the query parameters (e.g., extension version, search term) necessary to fulfill the request and do not include your code, prompts, or personally identifiable information. ### **How We Use Your Data (If Collected)** -- We use telemetry to understand feature usage and improve Roo Code. +- We use telemetry to understand feature usage and improve Klaus Code. - We do **not** sell or share your data. - We do **not** train any models on your data. ### **Your Choices & Control** - You can run models locally to prevent data being sent to third-parties. -- Telemetry collection is enabled by default to help us improve Roo Code, but you can opt out at any time through the settings. -- You can delete Roo Code to stop all data collection. +- Telemetry collection is enabled by default to help us improve Klaus Code, but you can opt out at any time through the settings. +- You can delete Klaus Code to stop all data collection. ### **Security & Updates** @@ -35,4 +35,4 @@ For any privacy-related questions, reach out to us at support@roocode.com. --- -By using Roo Code, you agree to this Privacy Policy. +By using Klaus Code, you agree to this Privacy Policy. diff --git a/README.md b/README.md index 75f37762f93..f54867ba239 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,31 @@ +# Klaus Code + +> A fork of [Roo Code](https://github.com/RooCodeInc/Roo-Code) that preserves the Claude Code provider feature. + +## Fork Notice + +This is **Klaus Code**, a community fork of Roo Code. + +### Why this fork exists + +Roo Code removed the Claude Code provider in [commit 7f854c0](https://github.com/RooCodeInc/Roo-Code/commit/7f854c0dd7ed25dac68a2310346708b4b64b48d9). This fork restores and maintains that feature, allowing users to authenticate with Claude Code OAuth tokens. + +### Key differences from upstream + +- **Claude Code Provider**: Restored and maintained +- **Branding**: Renamed from "Roo Code" to "Klaus Code" + +### Original project + +- **Roo Code original link**: https://github.com/RooCodeInc/Roo-Code +- **Roo Code documentation**: https://docs.roocode.com + +--- + +## Below is the original content before fork: + +--- +

VS Code Marketplace X @@ -173,4 +201,4 @@ We love community contributions! Get started by reading our [CONTRIBUTING.md](CO --- -**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can’t wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding! +**Enjoy Roo Code!** Whether you keep it on a short leash or let it roam autonomously, we can't wait to see what you build. If you have questions or feature ideas, drop by our [Reddit community](https://www.reddit.com/r/RooCode/) or [Discord](https://discord.gg/roocode). Happy coding! diff --git a/apps/cli/CHANGELOG.md b/apps/cli/CHANGELOG.md index 0babc28fd80..8e957226601 100644 --- a/apps/cli/CHANGELOG.md +++ b/apps/cli/CHANGELOG.md @@ -33,12 +33,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Skip onboarding flow when a provider is explicitly specified via `--provider` flag or saved in settings - Unified permission flags: Combined `-y`, `--yes`, and `--dangerously-skip-permissions` into a single option for Claude Code-like CLI compatibility -- Improved Roo Code Router authentication flow and error messaging +- Improved Klaus Code Router authentication flow and error messaging ### Fixed - Removed unnecessary timeout that could cause issues with long-running tasks -- Fixed authentication token validation for Roo Code Router provider +- Fixed authentication token validation for Klaus Code Router provider ## [0.0.45] - 2026-01-08 diff --git a/apps/cli/README.md b/apps/cli/README.md index d4405364405..518be759f09 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -1,16 +1,16 @@ # @roo-code/cli -Command Line Interface for Roo Code - Run the Roo Code agent from the terminal without VSCode. +Command Line Interface for Klaus Code - Run the Klaus Code agent from the terminal without VSCode. ## Overview -This CLI uses the `@roo-code/vscode-shim` package to provide a VSCode API compatibility layer, allowing the main Roo Code extension to run in a Node.js environment. +This CLI uses the `@roo-code/vscode-shim` package to provide a VSCode API compatibility layer, allowing the main Klaus Code extension to run in a Node.js environment. ## Installation ### Quick Install (Recommended) -Install the Roo Code CLI with a single command: +Install the Klaus Code CLI with a single command: ```bash curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh @@ -101,12 +101,12 @@ In non-interactive mode: - Followup questions show a 60-second timeout, then auto-select the first suggestion - Typing any key cancels the timeout and allows manual input -### Roo Code Cloud Authentication +### Klaus Code Cloud Authentication -To use Roo Code Cloud features (like the provider proxy), you need to authenticate: +To use Klaus Code Cloud features (like the provider proxy), you need to authenticate: ```bash -# Log in to Roo Code Cloud (opens browser) +# Log in to Klaus Code Cloud (opens browser) roo auth login # Check authentication status @@ -118,7 +118,7 @@ roo auth logout The `auth login` command: -1. Opens your browser to authenticate with Roo Code Cloud +1. Opens your browser to authenticate with Klaus Code Cloud 2. Receives a secure token via localhost callback 3. Stores the token in `~/.config/roo/credentials.json` @@ -128,7 +128,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo ``` ┌──────┐ ┌─────────┐ ┌───────────────┐ -│ CLI │ │ Browser │ │ Roo Code Cloud│ +│ CLI │ │ Browser │ │ Klaus Code Cloud│ └──┬───┘ └────┬────┘ └───────┬───────┘ │ │ │ │ Open auth URL │ │ @@ -167,7 +167,7 @@ Tokens are valid for 90 days. The CLI will prompt you to re-authenticate when yo | Command | Description | | ----------------- | ---------------------------------- | -| `roo auth login` | Authenticate with Roo Code Cloud | +| `roo auth login` | Authenticate with Klaus Code Cloud | | `roo auth logout` | Clear stored authentication token | | `roo auth status` | Show current authentication status | @@ -187,7 +187,7 @@ The CLI will look for API keys in environment variables if not provided via `--a | Variable | Description | | ----------------- | -------------------------------------------------------------------- | -| `ROO_WEB_APP_URL` | Override the Roo Code Cloud URL (default: `https://app.roocode.com`) | +| `ROO_WEB_APP_URL` | Override the Klaus Code Cloud URL (default: `https://app.roocode.com`) | ## Architecture diff --git a/apps/cli/docs/AGENT_LOOP.md b/apps/cli/docs/AGENT_LOOP.md index a7b1d9eed40..ef41a63944c 100644 --- a/apps/cli/docs/AGENT_LOOP.md +++ b/apps/cli/docs/AGENT_LOOP.md @@ -1,6 +1,6 @@ # CLI Agent Loop -This document explains how the Roo Code CLI detects and tracks the agent loop state. +This document explains how the Klaus Code CLI detects and tracks the agent loop state. ## Overview diff --git a/apps/cli/install.sh b/apps/cli/install.sh index 1b01e51aa58..3ffb36baa8c 100755 --- a/apps/cli/install.sh +++ b/apps/cli/install.sh @@ -1,5 +1,5 @@ #!/bin/sh -# Roo Code CLI Installer +# Klaus Code CLI Installer # Usage: curl -fsSL https://raw.githubusercontent.com/RooCodeInc/Roo-Code/main/apps/cli/install.sh | sh # # Environment variables: @@ -267,7 +267,7 @@ verify_install() { # Print success message print_success() { echo "" - printf "${GREEN}${BOLD}✓ Roo Code CLI installed successfully!${NC}\n" + printf "${GREEN}${BOLD}✓ Klaus Code CLI installed successfully!${NC}\n" echo "" echo " Installation: $INSTALL_DIR" echo " Binary: $BIN_DIR/roo" @@ -287,7 +287,7 @@ main() { echo "" printf "${BLUE}${BOLD}" echo " ╭─────────────────────────────────╮" - echo " │ Roo Code CLI Installer │" + echo " │ Klaus Code CLI Installer │" echo " ╰─────────────────────────────────╯" printf "${NC}" echo "" diff --git a/apps/cli/package.json b/apps/cli/package.json index 6348bbe020a..1eb46bb9013 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@roo-code/cli", "version": "0.0.49", - "description": "Roo Code CLI - Run the Roo Code agent from the command line", + "description": "Klaus Code CLI - Run the Klaus Code agent from the command line", "private": true, "type": "module", "main": "dist/index.js", diff --git a/apps/cli/scripts/release.sh b/apps/cli/scripts/release.sh index 7e736db3dbc..3759af2bf09 100755 --- a/apps/cli/scripts/release.sh +++ b/apps/cli/scripts/release.sh @@ -1,5 +1,5 @@ #!/bin/bash -# Roo Code CLI Release Script +# Klaus Code CLI Release Script # # Usage: # ./apps/cli/scripts/release.sh [options] [version] @@ -568,7 +568,7 @@ EOF fi gh release create "$TAG" \ - --title "Roo Code CLI v$VERSION" \ + --title "Klaus Code CLI v$VERSION" \ --notes "$RELEASE_NOTES" \ --prerelease \ --target "$COMMIT_SHA" \ @@ -667,7 +667,7 @@ main() { echo "" printf "${BLUE}${BOLD}" echo " ╭─────────────────────────────────╮" - echo " │ Roo Code CLI Release Script │" + echo " │ Klaus Code CLI Release Script │" echo " ╰─────────────────────────────────╯" printf "${NC}" diff --git a/apps/cli/src/agent/agent-state.ts b/apps/cli/src/agent/agent-state.ts index ca4a099ccab..8bd722c0f60 100644 --- a/apps/cli/src/agent/agent-state.ts +++ b/apps/cli/src/agent/agent-state.ts @@ -2,7 +2,7 @@ * Agent Loop State Detection * * This module provides the core logic for detecting the current state of the - * Roo Code agent loop. The state is determined by analyzing the clineMessages + * Klaus Code agent loop. The state is determined by analyzing the clineMessages * array, specifically the last message's type and properties. * * Key insight: The agent loop stops whenever a message with `type: "ask"` arrives, diff --git a/apps/cli/src/agent/extension-client.ts b/apps/cli/src/agent/extension-client.ts index c2d77dfdd91..21a32425a6a 100644 --- a/apps/cli/src/agent/extension-client.ts +++ b/apps/cli/src/agent/extension-client.ts @@ -1,5 +1,5 @@ /** - * Roo Code Client + * Klaus Code Client * * This is the main entry point for the client library. It provides a high-level * API for: @@ -84,7 +84,7 @@ export interface ExtensionClientConfig { // ============================================================================= /** - * ExtensionClient is the main interface for interacting with the Roo Code extension. + * ExtensionClient is the main interface for interacting with the Klaus Code extension. * * Basic usage: * ```typescript diff --git a/apps/cli/src/agent/extension-host.ts b/apps/cli/src/agent/extension-host.ts index e1f55a30d1f..6e29c64e20c 100644 --- a/apps/cli/src/agent/extension-host.ts +++ b/apps/cli/src/agent/extension-host.ts @@ -1,5 +1,5 @@ /** - * ExtensionHost - Loads and runs the Roo Code extension in CLI mode + * ExtensionHost - Loads and runs the Klaus Code extension in CLI mode * * This class is a thin coordination layer responsible for: * 1. Creating the vscode-shim mock diff --git a/apps/cli/src/commands/cli/run.ts b/apps/cli/src/commands/cli/run.ts index 663ed5cf750..1ebb1275ddb 100644 --- a/apps/cli/src/commands/cli/run.ts +++ b/apps/cli/src/commands/cli/run.ts @@ -83,7 +83,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption exitOnComplete: effectiveExitOnComplete, } - // Roo Code Cloud Authentication + // Klaus Code Cloud Authentication if (isOnboardingEnabled) { let { onboardingProviderChoice } = settings @@ -112,12 +112,12 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption extensionHostOptions.apiKey = rooToken extensionHostOptions.user = me.user } catch { - console.error("[CLI] Your Roo Code Router token is not valid.") + console.error("[CLI] Your Klaus Code Router token is not valid.") console.error("[CLI] Please run: roo auth login") process.exit(1) } } else { - console.error("[CLI] Your Roo Code Router token is missing.") + console.error("[CLI] Your Klaus Code Router token is missing.") console.error("[CLI] Please run: roo auth login") process.exit(1) } @@ -139,7 +139,7 @@ export async function run(promptArg: string | undefined, flagOptions: FlagOption if (!extensionHostOptions.apiKey) { if (extensionHostOptions.provider === "roo") { - console.error("[CLI] Error: Authentication with Roo Code Cloud failed or was cancelled.") + console.error("[CLI] Error: Authentication with Klaus Code Cloud failed or was cancelled.") console.error("[CLI] Please run: roo auth login") console.error("[CLI] Or use --api-key to provide your own API key.") } else { diff --git a/apps/cli/src/index.ts b/apps/cli/src/index.ts index 5b663c2bdcd..001fa09ba3a 100644 --- a/apps/cli/src/index.ts +++ b/apps/cli/src/index.ts @@ -8,7 +8,7 @@ const program = new Command() program .name("roo") - .description("Roo Code CLI - starts an interactive session by default, use -p/--print for non-interactive output") + .description("Klaus Code CLI - starts an interactive session by default, use -p/--print for non-interactive output") .version(VERSION) program @@ -37,11 +37,11 @@ program ) .action(run) -const authCommand = program.command("auth").description("Manage authentication for Roo Code Cloud") +const authCommand = program.command("auth").description("Manage authentication for Klaus Code Cloud") authCommand .command("login") - .description("Authenticate with Roo Code Cloud") + .description("Authenticate with Klaus Code Cloud") .option("-v, --verbose", "Enable verbose output", false) .action(async (options: { verbose: boolean }) => { const result = await login({ verbose: options.verbose }) @@ -50,7 +50,7 @@ authCommand authCommand .command("logout") - .description("Log out from Roo Code Cloud") + .description("Log out from Klaus Code Cloud") .option("-v, --verbose", "Enable verbose output", false) .action(async (options: { verbose: boolean }) => { const result = await logout({ verbose: options.verbose }) diff --git a/apps/cli/src/ui/components/Header.tsx b/apps/cli/src/ui/components/Header.tsx index 040e2759188..cdbea8c0ce4 100644 --- a/apps/cli/src/ui/components/Header.tsx +++ b/apps/cli/src/ui/components/Header.tsx @@ -32,7 +32,7 @@ function Header({ const { columns } = useTerminalSize() const homeDir = process.env.HOME || process.env.USERPROFILE || "" - const title = `Roo Code CLI v${version}` + const title = `Klaus Code CLI v${version}` const remainingDashes = Math.max(0, columns - `── ${title} `.length) return ( diff --git a/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx b/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx index 86c15f5b274..d858de2ebe9 100644 --- a/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx +++ b/apps/cli/src/ui/components/onboarding/OnboardingScreen.tsx @@ -16,7 +16,7 @@ export function OnboardingScreen({ onSelect }: OnboardingScreenProps) { Welcome! How would you like to connect to an LLM provider? setSearchQuery(e.target.value)} - className="w-full rounded-full border border-input bg-background px-10 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2" - /> - -

- {filteredAndSortedModels.length} of {nonDeprecatedCount} models -
- - -
-
- - -
-
- - - - -
-
- {loading && ( -
- -

Loading model list...

-
- )} - - {error && ( -
- -

Oops, couldn't load the model list.

-

Try again in a bit please.

-
- )} - - {!loading && !error && filteredAndSortedModels.length === 0 && ( -
- -

No models match your search.

-

- Keep in mind we don't have every model under the sun – only the ones we think - are worth using. -
- You can always use a third-party provider to access a wider selection. -

-
- )} - - {!loading && !error && filteredAndSortedModels.length > 0 && ( -
- {filteredAndSortedModels.map((model) => ( - - ))} -
- )} -
-
- - - {/* FAQ Section */} -
- -
-
-

Frequently Asked Questions

-
-
- {faqs.map((faq, index) => ( -
-

{faq.question}

-

{faq.answer}

-
- ))} -
-
-
- - ) -} diff --git a/apps/web-roo-code/src/app/provider/pricing/components/model-card.tsx b/apps/web-roo-code/src/app/provider/pricing/components/model-card.tsx deleted file mode 100644 index 26f35457912..00000000000 --- a/apps/web-roo-code/src/app/provider/pricing/components/model-card.tsx +++ /dev/null @@ -1,190 +0,0 @@ -import { ModelWithTotalPrice } from "@/lib/types/models" -import { formatCurrency, formatTokens } from "@/lib/formatters" -import { - ArrowLeftToLine, - ArrowRightToLine, - Building2, - Check, - Expand, - Gift, - HardDriveDownload, - HardDriveUpload, - RulerDimensionLine, - ChevronDown, - ChevronUp, -} from "lucide-react" -import { useState } from "react" - -interface ModelCardProps { - model: ModelWithTotalPrice -} - -export function ModelCard({ model }: ModelCardProps) { - // Prices are per token, multiply by 1M to get price per million tokens - const inputPrice = parseFloat(model.pricing.input) * 1_000_000 - const outputPrice = parseFloat(model.pricing.output) * 1_000_000 - const cacheReadPrice = parseFloat(model.pricing.input_cache_read || "0") * 1_000_000 - const cacheWritePrice = parseFloat(model.pricing.input_cache_write || "0") * 1_000_000 - - const free = model.tags.includes("free") - // Filter tags to only show vision and reasoning - const displayTags = model.tags.filter((tag) => tag === "vision" || tag === "reasoning") - - // Mobile collapsed/expanded state - const [expanded, setExpanded] = useState(false) - - return ( -
- {/* Header: always visible */} -
-

- {model.name} - {free && ( - - - Free! - - )} -

-

- {model.description} -

-
- - {/* Content - pinned to bottom */} -
- - - {/* Provider: always visible if present */} - {model.owned_by && ( - - - - - )} - - {/* Context Window: always visible */} - - - - - - {/* Max Output Tokens: always visible on >=sm, expandable on mobile */} - - - - - - {/* Input Price: always visible */} - - - - - - {/* Output Price: always visible */} - - - - - - {/* Cache pricing: only visible on mobile when expanded, always visible on >=sm */} - {cacheReadPrice > 0 && ( - - - - - )} - - {cacheWritePrice > 0 && ( - - - - - )} - - {/* Tags row: only show if there are vision or reasoning tags */} - {displayTags.length > 0 && ( - - - - - )} - - {/* Mobile-only toggle row */} - - - - -
- - Provider - {model.owned_by}
- - Context Window - {formatTokens(model.context_window)}
- - Max Output Tokens - {formatTokens(model.max_tokens)}
- - Input Price - - {inputPrice === 0 ? "Free" : `${formatCurrency(inputPrice)}/1M tokens`} -
- - Output Price - - {outputPrice === 0 ? "Free" : `${formatCurrency(outputPrice)}/1M tokens`} -
- - Cache Read - {formatCurrency(cacheReadPrice)}/1M tokens
- - Cache Write - {formatCurrency(cacheWritePrice)}/1M tokens
Features - {displayTags.map((tag) => ( - - - {tag} - - ))} -
- -
-
-
- ) -} diff --git a/apps/web-roo-code/src/app/reviewer/content-b.ts b/apps/web-roo-code/src/app/reviewer/content-b.ts deleted file mode 100644 index d187bdc3364..00000000000 --- a/apps/web-roo-code/src/app/reviewer/content-b.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { type AgentPageContent } from "@/app/shared/agent-page-content" - -// Workaround for next/image choking on these for some reason -import hero from "/public/heroes/agent-reviewer.png" - -// Re-export for convenience -export type { AgentPageContent } - -export const content: AgentPageContent = { - agentName: "PR Reviewer", - hero: { - icon: "GitPullRequest", - heading: "Code reviews that catch what other AI tools (and most humans) miss.", - paragraphs: [ - "Run-of-the-mill, token-saving AI code review tools will surely catch syntax errors and style issues, but they'll usually miss the bugs that actually matter: logic flaws, security vulnerabilities, and misunderstood requirements.", - "Klaus Code's PR Reviewer uses advanced reasoning models and full repository context to find the issues that slip through—before they reach production.", - ], - image: { - url: hero.src, - width: 800, - height: 474, - alt: "Example of a code review generated by Klaus Code PR Reviewer", - }, - crossAgentLink: { - text: "Works great with", - links: [ - { - text: "PR Fixer Agent", - href: "/pr-fixer", - icon: "Wrench", - }, - ], - }, - cta: { - buttonText: "Try now for free", - disclaimer: "", - tracking: "&agent=reviewer", - }, - }, - howItWorks: { - heading: "How It Works", - steps: [ - { - title: "1. Connect Your Repository", - description: - "Link your GitHub repository and configure which branches and pull requests should be reviewed.", - icon: "GitPullRequest", - }, - { - title: "2. Add Your API Key", - description: - "Provide your AI provider API key and set your review preferences, custom rules, and quality standards.", - icon: "Key", - }, - { - title: "3. Get Review Comments", - description: - "Every pull request gets detailed GitHub comments in minutes from a Klaus Code agent highlighting issues and suggesting improvements.", - icon: "MessageSquareCode", - }, - ], - }, - whyBetter: { - heading: "Why Roo's PR Reviewer is different", - features: [ - { - title: "Bring your own key, get uncompromised reviews", - paragraphs: [ - "Most AI review tools use fixed pricing, which means they skimp on tokens to protect their margins. That leads to shallow analysis and missed issues.", - "With Roo, you bring your own API key. We optimize prompts for depth, not cost-cutting, so reviews focus on real problems like business logic, security vulnerabilities, and architectural issues.", - ], - icon: "Blocks", - }, - { - title: "Advanced reasoning that understands what matters", - description: - "We leverage state-of-the-art reasoning models with sophisticated workflows: diff analysis, context gathering, impact mapping, and contract validation. This catches the subtle bugs that surface-level tools miss—misunderstood requirements, edge cases, and integration risks.", - icon: "ListChecks", - }, - { - title: "Repository-aware, not snippet-aware", - description: - "Roo analyzes your entire codebase context—dependency graphs, code ownership, team conventions, and historical patterns. It understands how changes interact with existing systems, not just whether individual lines look correct.", - icon: "BookMarked", - }, - ], - }, - cta: { - heading: "Ready for better code reviews?", - description: "Start finding the issues that matter with AI-powered reviews built for depth, not cost-cutting.", - buttonText: "Try now for free", - }, -} diff --git a/apps/web-roo-code/src/app/reviewer/content.ts b/apps/web-roo-code/src/app/reviewer/content.ts deleted file mode 100644 index d187bdc3364..00000000000 --- a/apps/web-roo-code/src/app/reviewer/content.ts +++ /dev/null @@ -1,93 +0,0 @@ -import { type AgentPageContent } from "@/app/shared/agent-page-content" - -// Workaround for next/image choking on these for some reason -import hero from "/public/heroes/agent-reviewer.png" - -// Re-export for convenience -export type { AgentPageContent } - -export const content: AgentPageContent = { - agentName: "PR Reviewer", - hero: { - icon: "GitPullRequest", - heading: "Code reviews that catch what other AI tools (and most humans) miss.", - paragraphs: [ - "Run-of-the-mill, token-saving AI code review tools will surely catch syntax errors and style issues, but they'll usually miss the bugs that actually matter: logic flaws, security vulnerabilities, and misunderstood requirements.", - "Klaus Code's PR Reviewer uses advanced reasoning models and full repository context to find the issues that slip through—before they reach production.", - ], - image: { - url: hero.src, - width: 800, - height: 474, - alt: "Example of a code review generated by Klaus Code PR Reviewer", - }, - crossAgentLink: { - text: "Works great with", - links: [ - { - text: "PR Fixer Agent", - href: "/pr-fixer", - icon: "Wrench", - }, - ], - }, - cta: { - buttonText: "Try now for free", - disclaimer: "", - tracking: "&agent=reviewer", - }, - }, - howItWorks: { - heading: "How It Works", - steps: [ - { - title: "1. Connect Your Repository", - description: - "Link your GitHub repository and configure which branches and pull requests should be reviewed.", - icon: "GitPullRequest", - }, - { - title: "2. Add Your API Key", - description: - "Provide your AI provider API key and set your review preferences, custom rules, and quality standards.", - icon: "Key", - }, - { - title: "3. Get Review Comments", - description: - "Every pull request gets detailed GitHub comments in minutes from a Klaus Code agent highlighting issues and suggesting improvements.", - icon: "MessageSquareCode", - }, - ], - }, - whyBetter: { - heading: "Why Roo's PR Reviewer is different", - features: [ - { - title: "Bring your own key, get uncompromised reviews", - paragraphs: [ - "Most AI review tools use fixed pricing, which means they skimp on tokens to protect their margins. That leads to shallow analysis and missed issues.", - "With Roo, you bring your own API key. We optimize prompts for depth, not cost-cutting, so reviews focus on real problems like business logic, security vulnerabilities, and architectural issues.", - ], - icon: "Blocks", - }, - { - title: "Advanced reasoning that understands what matters", - description: - "We leverage state-of-the-art reasoning models with sophisticated workflows: diff analysis, context gathering, impact mapping, and contract validation. This catches the subtle bugs that surface-level tools miss—misunderstood requirements, edge cases, and integration risks.", - icon: "ListChecks", - }, - { - title: "Repository-aware, not snippet-aware", - description: - "Roo analyzes your entire codebase context—dependency graphs, code ownership, team conventions, and historical patterns. It understands how changes interact with existing systems, not just whether individual lines look correct.", - icon: "BookMarked", - }, - ], - }, - cta: { - heading: "Ready for better code reviews?", - description: "Start finding the issues that matter with AI-powered reviews built for depth, not cost-cutting.", - buttonText: "Try now for free", - }, -} diff --git a/apps/web-roo-code/src/app/reviewer/page.tsx b/apps/web-roo-code/src/app/reviewer/page.tsx deleted file mode 100644 index 776ded6847f..00000000000 --- a/apps/web-roo-code/src/app/reviewer/page.tsx +++ /dev/null @@ -1,70 +0,0 @@ -import type { Metadata } from "next" - -import { SEO } from "@/lib/seo" -import { ogImageUrl } from "@/lib/og" -import { AgentLandingContent } from "@/app/shared/AgentLandingContent" -import { getContentVariant } from "@/app/shared/getContentVariant" -import { content as contentA } from "./content" -import { content as contentB } from "./content-b" - -const TITLE = "PR Reviewer" -const DESCRIPTION = - "Get comprehensive AI-powered PR reviews that save you time, not tokens. Bring your own API key and leverage advanced reasoning, repository-aware analysis, and actionable feedback to keep your PR queue moving." -const OG_DESCRIPTION = "AI-powered PR reviews that save you time, not tokens" -const PATH = "/reviewer" - -export const metadata: Metadata = { - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: `${SEO.url}${PATH}`, - }, - openGraph: { - title: TITLE, - description: DESCRIPTION, - url: `${SEO.url}${PATH}`, - siteName: SEO.name, - images: [ - { - url: ogImageUrl(TITLE, OG_DESCRIPTION), - width: 1200, - height: 630, - alt: TITLE, - }, - ], - locale: SEO.locale, - type: "website", - }, - twitter: { - card: SEO.twitterCard, - title: TITLE, - description: DESCRIPTION, - images: [ogImageUrl(TITLE, OG_DESCRIPTION)], - }, - keywords: [ - ...SEO.keywords, - "PR reviewer", - "code review", - "pull request review", - "AI code review", - "GitHub PR review", - "automated code review", - "repository-aware review", - "bring your own key", - "BYOK AI", - "code quality", - "development workflow", - "cloud agents", - "AI development team", - ], -} - -export default async function AgentReviewerPage({ searchParams }: { searchParams: Promise<{ v?: string }> }) { - const params = await searchParams - const content = getContentVariant(params, { - A: contentA, - B: contentB, - }) - - return -} diff --git a/apps/web-roo-code/src/app/robots.ts b/apps/web-roo-code/src/app/robots.ts deleted file mode 100644 index fcdda5031e8..00000000000 --- a/apps/web-roo-code/src/app/robots.ts +++ /dev/null @@ -1,13 +0,0 @@ -import type { MetadataRoute } from "next" -import { SEO } from "@/lib/seo" - -export default function robots(): MetadataRoute.Robots { - return { - rules: { - userAgent: "*", - allow: "/", - }, - sitemap: `${SEO.url}/sitemap.xml`, - host: SEO.url, - } -} diff --git a/apps/web-roo-code/src/app/shared/AgentLandingContent.tsx b/apps/web-roo-code/src/app/shared/AgentLandingContent.tsx deleted file mode 100644 index 4db166b9199..00000000000 --- a/apps/web-roo-code/src/app/shared/AgentLandingContent.tsx +++ /dev/null @@ -1,235 +0,0 @@ -"use client" - -import { - ArrowRight, - GitPullRequest, - Wrench, - Key, - MessageSquareCode, - Blocks, - ListChecks, - BookMarked, - History, - LucideIcon, -} from "lucide-react" -import Image from "next/image" -import Link from "next/link" - -import { Button } from "@/components/ui" -import { AnimatedBackground, UseExamplesSection } from "@/components/homepage" -import { EXTERNAL_LINKS } from "@/lib/constants" -import { type AgentPageContent, type IconName } from "./agent-page-content" - -/** - * Maps icon names to actual Lucide icon components - */ -const iconMap: Record = { - GitPullRequest, - Wrench, - Key, - MessageSquareCode, - Blocks, - ListChecks, - BookMarked, - History, -} - -/** - * Converts an icon name string to a Lucide icon component - */ -function getIcon(iconName?: IconName): LucideIcon | undefined { - return iconName ? iconMap[iconName] : undefined -} - -export function AgentLandingContent({ content }: { content: AgentPageContent }) { - return ( - <> - {/* Hero Section */} -
- - -
- - {/* How It Works Section */} -
-
-
-
-
-
-
-

- {content.howItWorks.heading} -

-
-
- -
-
    - {content.howItWorks.steps.map((step, index) => { - const Icon = getIcon(step.icon) - return ( -
  • - {Icon && } -

    - {step.title} -

    -
    - {step.description} -
    -
  • - ) - })} -
-
-
-
- - {/* Why Better Section */} -
-
-
-
-
-
-
-

- {content.whyBetter.heading} -

-
-
- -
-
    - {content.whyBetter.features.map((feature, index) => { - const Icon = getIcon(feature.icon) - return ( -
  • - {Icon && } -

    - {feature.title} -

    -
    - {feature.description &&

    {feature.description}

    } - {feature.paragraphs && - feature.paragraphs.map((paragraph, pIndex) => ( -

    {paragraph}

    - ))} -
    -
  • - ) - })} -
-
-
-
- - - - {/* CTA Section */} -
-
-
-

{content.cta.heading}

-

- {content.cta.description} -

- -
-
-
- - ) -} diff --git a/apps/web-roo-code/src/app/shared/agent-page-content.ts b/apps/web-roo-code/src/app/shared/agent-page-content.ts deleted file mode 100644 index 01a64e85472..00000000000 --- a/apps/web-roo-code/src/app/shared/agent-page-content.ts +++ /dev/null @@ -1,75 +0,0 @@ -/** - * Supported icon names that can be used in agent page content. - * These strings are mapped to actual Lucide components in the client. - */ -export type IconName = - | "GitPullRequest" - | "Wrench" - | "Key" - | "MessageSquareCode" - | "Blocks" - | "ListChecks" - | "BookMarked" - | "History" - -/** - * Generic content structure for agent landing pages. - * This interface can be reused across different agent pages (PR Reviewer, PR Fixer, etc.) - * to maintain consistency and enable A/B testing capabilities. - * - * Note: Icons are referenced by string names (not components) to support - * serialization from Server Components to Client Components. - */ -export interface AgentPageContent { - agentName: string - hero: { - /** Optional icon name to display in the hero section */ - icon?: IconName - heading: string - paragraphs: string[] - image?: { - url: string - width: number - height: number - alt?: string - } - crossAgentLink: { - text: string - links: Array<{ - text: string - href: string - icon?: IconName - }> - } - cta: { - buttonText: string - disclaimer: string - tracking: string - } - } - howItWorks: { - heading: string - steps: Array<{ - title: string - /** Supports rich text content including React components */ - description: string | React.ReactNode - icon?: IconName - }> - } - whyBetter: { - heading: string - features: Array<{ - title: string - /** Supports rich text content including React components */ - description?: string | React.ReactNode - /** Supports rich text content including React components */ - paragraphs?: Array - icon?: IconName - }> - } - cta: { - heading: string - description: string - buttonText: string - } -} diff --git a/apps/web-roo-code/src/app/shared/getContentVariant.ts b/apps/web-roo-code/src/app/shared/getContentVariant.ts deleted file mode 100644 index 0d8fccdde45..00000000000 --- a/apps/web-roo-code/src/app/shared/getContentVariant.ts +++ /dev/null @@ -1,36 +0,0 @@ -import type { AgentPageContent } from "./agent-page-content" - -/** - * Selects the appropriate content variant based on the query parameter. - * - * @param searchParams - The search parameters from the page props - * @param variants - A record mapping variant letters to content objects - * @returns The selected content variant, defaulting to variant 'A' if not found or invalid - * - * @example - * ```tsx - * const content = getContentVariant(searchParams, { - * A: contentA, - * B: contentB, - * C: contentC, - * }) - * ``` - */ -export function getContentVariant( - searchParams: { v?: string }, - variants: Record, -): AgentPageContent { - const variant = searchParams.v?.toUpperCase() - - // Return the specified variant if it exists, otherwise default to 'A' - if (variant && variants[variant]) { - return variants[variant] - } - - // Ensure 'A' variant always exists as fallback - if (!variants.A) { - throw new Error("Content variants must include variant 'A' as the default") - } - - return variants.A -} diff --git a/apps/web-roo-code/src/app/shell.tsx b/apps/web-roo-code/src/app/shell.tsx deleted file mode 100644 index 84a42bed21b..00000000000 --- a/apps/web-roo-code/src/app/shell.tsx +++ /dev/null @@ -1,18 +0,0 @@ -import { getGitHubStars, getVSCodeDownloads } from "@/lib/stats" - -import { NavBar, Footer } from "@/components/chromes" - -// Invalidate cache when a request comes in, at most once every hour. -export const revalidate = 3600 - -export default async function Shell({ children }: { children: React.ReactNode }) { - const [stars, downloads] = await Promise.all([getGitHubStars(), getVSCodeDownloads()]) - - return ( -
- -
{children}
-
-
- ) -} diff --git a/apps/web-roo-code/src/app/slack/page.tsx b/apps/web-roo-code/src/app/slack/page.tsx deleted file mode 100644 index a77601a3f3b..00000000000 --- a/apps/web-roo-code/src/app/slack/page.tsx +++ /dev/null @@ -1,401 +0,0 @@ -import { - ArrowRight, - Brain, - CreditCard, - GitBranch, - GraduationCap, - Link2, - MessageSquare, - Settings, - Shield, - Slack, - Users, - Zap, -} from "lucide-react" -import type { LucideIcon } from "lucide-react" -import type { Metadata } from "next" - -import { AnimatedBackground } from "@/components/homepage" -import { SlackThreadDemo } from "@/components/slack/slack-thread-demo" -import { Button } from "@/components/ui" -import { EXTERNAL_LINKS } from "@/lib/constants" -import { SEO } from "@/lib/seo" -import { ogImageUrl } from "@/lib/og" - -const TITLE = "Klaus Code for Slack" -const DESCRIPTION = - "Mention @Roomote in any channel to explain code, plan features, or ship a PR, all without leaving the conversation." -const OG_DESCRIPTION = "Your AI Team in Slack" -const PATH = "/slack" - -export const metadata: Metadata = { - title: TITLE, - description: DESCRIPTION, - alternates: { - canonical: `${SEO.url}${PATH}`, - }, - openGraph: { - title: TITLE, - description: DESCRIPTION, - url: `${SEO.url}${PATH}`, - siteName: SEO.name, - images: [ - { - url: ogImageUrl(TITLE, OG_DESCRIPTION), - width: 1200, - height: 630, - alt: TITLE, - }, - ], - locale: SEO.locale, - type: "website", - }, - twitter: { - card: SEO.twitterCard, - title: TITLE, - description: DESCRIPTION, - images: [ogImageUrl(TITLE, OG_DESCRIPTION)], - }, - keywords: [ - ...SEO.keywords, - "slack integration", - "slack bot", - "AI in slack", - "code assistant slack", - "@Roomote", - "team collaboration", - ], -} - -// Invalidate cache when a request comes in, at most once every hour. -export const revalidate = 3600 - -type ValueProp = { - icon: LucideIcon - title: string - description: string -} - -const VALUE_PROPS: ValueProp[] = [ - { - icon: GitBranch, - title: "Discussion to PR.", - description: - "Your team discusses a feature in Slack. @Roomote turns the discussion into a plan. Then builds it. All without leaving the conversation.", - }, - { - icon: Brain, - title: "Thread-aware.", - description: - '@Roomote reads the full thread before responding. Ask "Can we add caching here?" and it knows exactly what code you mean.', - }, - { - icon: Link2, - title: "Chain agents.", - description: - "Start with a Planner to spec it out. Then call the Coder to build it. Multi-step workflows, one Slack thread.", - }, - { - icon: Users, - title: "Open to all.", - description: - "Anyone on your team can ask @Roomote to fix bugs, build features, or investigate issues. Engineering gets looped in only when needed.", - }, - { - icon: GraduationCap, - title: "Built-in learning.", - description: "Public channel mentions show everyone how to leverage agents. Learn by watching.", - }, - { - icon: Shield, - title: "Safe by design.", - description: "Agents never touch main/master directly. They produce branches and PRs. You approve.", - }, -] - -type WorkflowStep = { - step: number - title: string - description: string -} - -const WORKFLOW_STEPS: WorkflowStep[] = [ - { - step: 1, - title: "Turn the discussion into a plan", - description: "Your team discusses a feature. When it gets complex, summon the Planner agent.", - }, - { - step: 2, - title: "Refine the plan in the thread", - description: - "The team reviews the spec in the thread, suggests changes, asks questions. Mention @Roomote again to refine.", - }, - { - step: 3, - title: "Build the plan", - description: "Once the plan looks good, hand it off to the Coder agent to implement.", - }, - { - step: 4, - title: "Review and ship", - description: "The Coder creates a branch and opens a PR. The team reviews, and the feature ships.", - }, -] - -type OnboardingStep = { - icon: LucideIcon - title: string - description: string - link?: { - href: string - text: string - } -} - -const ONBOARDING_STEPS: OnboardingStep[] = [ - { - icon: CreditCard, - title: "1. Team Plan", - description: "Slack requires a Team plan.", - link: { - href: EXTERNAL_LINKS.CLOUD_APP_TEAM_TRIAL, - text: "Start a free trial", - }, - }, - { - icon: Settings, - title: "2. Connect", - description: 'Sign in to Klaus Code Cloud and go to Settings. Click "Connect" next to Slack.', - }, - { - icon: Slack, - title: "3. Authorize", - description: "Authorize the Klaus Code app to access your Slack workspace.", - }, - { - icon: MessageSquare, - title: "4. Add to channels", - description: "Add @Roomote to the channels where you want it available.", - }, -] - -export default function SlackPage(): JSX.Element { - return ( - <> - {/* Hero Section */} -
- -
-
-
-
- - Powered by Klaus Code Cloud -
-

- @Roomote: Your AI Team in Slack -

-

- Mention @Roomote in any channel to explain code, plan features, or ship a PR, all - without leaving the conversation. -

- -
- -
- -
-
-
-
- - {/* Value Props Section */} -
-
-
-
-
-
-

- Why your team will love using Klaus Code in Slack -

-

- AI agents that understand context, chain together for complex work, and keep your team in - control. -

-
-
- {VALUE_PROPS.map((prop, index) => { - const Icon = prop.icon - return ( -
-
- -
-

{prop.title}

-

{prop.description}

-
- ) - })} -
-
-
- - {/* Featured Workflow Section */} -
-
-
-
-
- -
-
- - Featured Workflow -
-

- Thread to Shipped Feature -

-

- Turn Slack discussions into working code. No context lost, no meetings needed. -

-
- -
-
- {/* YouTube Video Embed */} -
-