diff --git a/packages/types/src/__tests__/claude-code.spec.ts b/packages/types/src/__tests__/claude-code.spec.ts new file mode 100644 index 0000000000..e07d6fae79 --- /dev/null +++ b/packages/types/src/__tests__/claude-code.spec.ts @@ -0,0 +1,55 @@ +import { describe, it, expect } from "vitest" + +import { claudeCodeModels, claudeCodeDefaultModelId } from "../providers/claude-code.js" +import { providerSettingsSchema, providerSettingsSchemaDiscriminated } from "../provider-settings.js" + +describe("claudeCodeModels", () => { + it("default model ID exists in the models record", () => { + expect(claudeCodeModels).toHaveProperty(claudeCodeDefaultModelId) + }) + + it("all models have inputPrice: 0 and outputPrice: 0", () => { + for (const [id, model] of Object.entries(claudeCodeModels)) { + expect(model.inputPrice, `${id}: inputPrice must be 0`).toBe(0) + expect(model.outputPrice, `${id}: outputPrice must be 0`).toBe(0) + } + }) +}) + +describe("claudeCodeSchema (via providerSettingsSchema)", () => { + it("accepts an empty object", () => { + const result = providerSettingsSchema.safeParse({}) + expect(result.success).toBe(true) + }) + + it("accepts claudeCodeCliPath as an optional string", () => { + const result = providerSettingsSchema.safeParse({ claudeCodeCliPath: "/usr/local/bin/claude" }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.claudeCodeCliPath).toBe("/usr/local/bin/claude") + } + }) + + it("rejects unknown keys in strict mode", () => { + const result = providerSettingsSchema.strict().safeParse({ claudeCodeApiKey: "should-not-exist" }) + expect(result.success).toBe(false) + }) +}) + +describe("claudeCodeSchema (via providerSettingsSchemaDiscriminated)", () => { + it("accepts apiProvider: 'claude-code' with no other fields", () => { + const result = providerSettingsSchemaDiscriminated.safeParse({ apiProvider: "claude-code" }) + expect(result.success).toBe(true) + }) + + it("accepts apiProvider: 'claude-code' with claudeCodeCliPath", () => { + const result = providerSettingsSchemaDiscriminated.safeParse({ + apiProvider: "claude-code", + claudeCodeCliPath: "/usr/local/bin/claude", + }) + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.apiProvider).toBe("claude-code") + } + }) +}) diff --git a/packages/types/src/provider-settings.ts b/packages/types/src/provider-settings.ts index 26c4dee7e1..a399f20142 100644 --- a/packages/types/src/provider-settings.ts +++ b/packages/types/src/provider-settings.ts @@ -6,6 +6,7 @@ import { anthropicModels, basetenModels, bedrockModels, + claudeCodeModels, deepSeekModels, fireworksModels, geminiModels, @@ -117,6 +118,7 @@ export const providerNames = [ "anthropic", "bedrock", "baseten", + "claude-code", "deepseek", "fireworks", "gemini", @@ -301,6 +303,11 @@ const openAiCodexSchema = apiModelIdProviderModelSchema.extend({ // No additional settings needed - uses OAuth authentication }) +const claudeCodeSchema = apiModelIdProviderModelSchema.extend({ + // No API key - authentication is handled externally via `claude login`. + claudeCodeCliPath: z.string().optional(), +}) + const openAiNativeSchema = apiModelIdProviderModelSchema.extend({ openAiNativeApiKey: z.string().optional(), openAiNativeBaseUrl: z.string().optional(), @@ -433,6 +440,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv geminiSchema.merge(z.object({ apiProvider: z.literal("gemini") })), geminiCliSchema.merge(z.object({ apiProvider: z.literal("gemini-cli") })), openAiCodexSchema.merge(z.object({ apiProvider: z.literal("openai-codex") })), + claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })), openAiNativeSchema.merge(z.object({ apiProvider: z.literal("openai-native") })), mistralSchema.merge(z.object({ apiProvider: z.literal("mistral") })), deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })), @@ -469,6 +477,7 @@ export const providerSettingsSchema = z.object({ ...geminiSchema.shape, ...geminiCliSchema.shape, ...openAiCodexSchema.shape, + ...claudeCodeSchema.shape, ...openAiNativeSchema.shape, ...mistralSchema.shape, ...deepSeekSchema.shape, @@ -545,6 +554,7 @@ export const modelIdKeysByProvider: Record = { bedrock: "apiModelId", vertex: "apiModelId", "openai-codex": "apiModelId", + "claude-code": "apiModelId", "openai-native": "openAiModelId", ollama: "ollamaModelId", lmstudio: "lmStudioModelId", @@ -668,6 +678,11 @@ export const MODELS_BY_PROVIDER: Record< label: "OpenAI - ChatGPT Plus/Pro", models: Object.keys(openAiCodexModels), }, + "claude-code": { + id: "claude-code", + label: "Claude Code (Subscription)", + models: Object.keys(claudeCodeModels), + }, "openai-native": { id: "openai-native", label: "OpenAI", diff --git a/packages/types/src/providers/claude-code.ts b/packages/types/src/providers/claude-code.ts new file mode 100644 index 0000000000..9955114723 --- /dev/null +++ b/packages/types/src/providers/claude-code.ts @@ -0,0 +1,156 @@ +import type { ModelInfo } from "../model.js" + +/** + * Claude Code (Subscription) Provider + * + * This provider shells out to the user's own locally-installed and + * already-authenticated `claude` CLI (Pro/Max/Team/Enterprise subscription), + * instead of an Anthropic API key. No credentials are read, stored, or + * transmitted by this extension. + * + * Key differences from anthropic: + * - Uses the local CLI session instead of API keys + * - Subscription-based pricing (no per-token costs) + * - Model availability depends on the authenticated subscription + * + * Pricing below (0 at runtime) mirrors the real per-token list prices from + * `anthropic.ts` as of 2026-07-04, for reference only. + * + * contextWindow/maxTokens are grounded against live `claude --model + * --output-format json` output (`modelUsage[id].contextWindow` / + * `.maxOutputTokens`) rather than copied from `anthropic.ts`, since the + * Agent SDK/CLI path reports different max output caps than the direct API + * for the same model name (e.g. 64K here vs. 128K direct for Opus/Sonnet 5/ + * Fable 5, 32K here vs. 64K direct for Haiku 4.5/Sonnet 4.6). + * + * `claude-sonnet-4-6[1m]` is a distinct, explicitly-selected model key (not + * a flag) that reports contextWindow: 1_000_000 vs. 200_000 for the bare + * `claude-sonnet-4-6` id. `claude-opus-4-6[1m]` was tried and did not + * resolve to a model (empty modelUsage), so no 1M variant is listed for it. + */ + +export type ClaudeCodeModelId = keyof typeof claudeCodeModels + +export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-5" + +/** + * Models available through the Claude Code CLI subscription flow. + * Costs are 0 as they are covered by the subscription. + */ +export const claudeCodeModels = { + "claude-fable-5": { + maxTokens: 64_000, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + // List price: $10/$50 per million in/out, $12.50 cache write, $1.00 cache read. + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + supportsReasoningBudget: true, + supportsReasoningBinary: true, + supportsTemperature: false, + description: "Claude Fable 5 via Claude Code subscription (Pro/Max/Team/Enterprise).", + }, + "claude-opus-4-8": { + maxTokens: 64_000, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + // List price: $5/$25 per million in/out, $6.25 cache write, $0.50 cache read. + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + supportsReasoningBudget: true, + supportsReasoningBinary: true, + supportsTemperature: false, + description: "Claude Opus 4.8 via Claude Code subscription (Pro/Max/Team/Enterprise).", + }, + "claude-opus-4-7": { + maxTokens: 64_000, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + // List price: $5/$25 per million in/out, $6.25 cache write, $0.50 cache read. + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + supportsReasoningBudget: true, + supportsReasoningBinary: true, + supportsTemperature: false, + description: "Claude Opus 4.7 via Claude Code subscription (Pro/Max/Team/Enterprise).", + }, + "claude-opus-4-6": { + maxTokens: 64_000, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + // List price: $5/$25 per million in/out, $6.25 cache write, $0.50 cache read. + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + supportsReasoningBudget: true, + description: "Claude Opus 4.6 via Claude Code subscription (Pro/Max/Team/Enterprise).", + }, + "claude-sonnet-5": { + maxTokens: 64_000, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + // List price: $2/$10 per million in/out (introductory, through Aug 31, 2026), + // $2.50 cache write, $0.20 cache read. + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + supportsReasoningBudget: true, + supportsReasoningBinary: true, + supportsTemperature: false, + description: "Claude Sonnet 5 via Claude Code subscription (Pro/Max/Team/Enterprise).", + }, + "claude-sonnet-4-6": { + maxTokens: 32_000, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + // List price: $3/$15 per million in/out, $3.75 cache write, $0.30 cache read. + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + supportsReasoningBudget: true, + description: "Claude Sonnet 4.6 via Claude Code subscription (Pro/Max/Team/Enterprise).", + }, + "claude-sonnet-4-6[1m]": { + maxTokens: 32_000, + contextWindow: 1_000_000, + supportsImages: true, + supportsPromptCache: true, + // Same list price as claude-sonnet-4-6; [1m] only changes context window. + // Tiered pricing above 200K would apply on the direct Anthropic API path + // but subscription usage is $0 either way. + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + supportsReasoningBudget: true, + description: "Claude Sonnet 4.6 (1M context) via Claude Code subscription (Pro/Max/Team/Enterprise).", + }, + "claude-haiku-4-5-20251001": { + maxTokens: 32_000, + contextWindow: 200_000, + supportsImages: true, + supportsPromptCache: true, + // List price: $1/$5 per million in/out, $1.25 cache write, $0.10 cache read. + inputPrice: 0, + outputPrice: 0, + cacheWritesPrice: 0, + cacheReadsPrice: 0, + supportsReasoningBudget: true, + description: "Claude Haiku 4.5 via Claude Code subscription (Pro/Max/Team/Enterprise).", + }, +} as const satisfies Record diff --git a/packages/types/src/providers/index.ts b/packages/types/src/providers/index.ts index f283cb474c..c1fe321420 100644 --- a/packages/types/src/providers/index.ts +++ b/packages/types/src/providers/index.ts @@ -1,6 +1,7 @@ export * from "./anthropic.js" export * from "./baseten.js" export * from "./bedrock.js" +export * from "./claude-code.js" export * from "./deepseek.js" export * from "./fireworks.js" export * from "./gemini.js" @@ -31,6 +32,7 @@ export * from "./zoo-gateway.js" import { anthropicDefaultModelId } from "./anthropic.js" import { basetenDefaultModelId } from "./baseten.js" import { bedrockDefaultModelId } from "./bedrock.js" +import { claudeCodeDefaultModelId } from "./claude-code.js" import { deepSeekDefaultModelId } from "./deepseek.js" import { fireworksDefaultModelId } from "./fireworks.js" import { geminiDefaultModelId } from "./gemini.js" @@ -77,6 +79,8 @@ export function getProviderDefaultModelId( return xaiDefaultModelId case "baseten": return basetenDefaultModelId + case "claude-code": + return claudeCodeDefaultModelId case "bedrock": return bedrockDefaultModelId case "vertex": diff --git a/src/core/config/__tests__/importExport.spec.ts b/src/core/config/__tests__/importExport.spec.ts index 9cbb364c92..94e5b9d6c4 100644 --- a/src/core/config/__tests__/importExport.spec.ts +++ b/src/core/config/__tests__/importExport.spec.ts @@ -548,7 +548,7 @@ describe("importExport", () => { describe("lenient import with invalid providers", () => { it("should sanitize profiles with invalid apiProvider and return warnings", async () => { - // Test importing a profile with a removed/invalid provider like "claude-code" + // Test importing a profile with a removed/invalid provider like "some-removed-provider" ;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }]) const mockFileContent = JSON.stringify({ @@ -561,7 +561,7 @@ describe("importExport", () => { id: "valid-id", }, "invalid-profile": { - apiProvider: "claude-code", // Invalid/removed provider + apiProvider: "some-removed-provider", // Invalid/removed provider apiKey: "some-key", id: "invalid-id", }, @@ -595,7 +595,7 @@ describe("importExport", () => { expect((result as { warnings?: string[] }).warnings).toBeDefined() expect((result as { warnings?: string[] }).warnings!.length).toBeGreaterThan(0) expect((result as { warnings?: string[] }).warnings![0]).toContain("invalid-profile") - expect((result as { warnings?: string[] }).warnings![0]).toContain("claude-code") + expect((result as { warnings?: string[] }).warnings![0]).toContain("some-removed-provider") // The valid profile should be imported expect(mockProviderSettingsManager.import).toHaveBeenCalled() @@ -853,8 +853,8 @@ describe("importExport", () => { apiKey: "key-2", id: "openai-id", }, - "old-claude-profile": { - apiProvider: "claude-code", // Removed provider + "old-removed-profile": { + apiProvider: "some-old-removed-provider", // Removed provider apiKey: "key-3", id: "claude-id", }, @@ -891,7 +891,7 @@ describe("importExport", () => { // Should have multiple warnings const warnings = (result as { warnings?: string[] }).warnings! expect(warnings.length).toBe(2) // Two profiles had invalid providers - expect(warnings.some((w) => w.includes("old-claude-profile"))).toBe(true) + expect(warnings.some((w) => w.includes("old-removed-profile"))).toBe(true) expect(warnings.some((w) => w.includes("another-invalid"))).toBe(true) // Valid profiles should be imported correctly @@ -900,7 +900,7 @@ describe("importExport", () => { expect(importedProfiles.apiConfigs["openai-profile"].apiProvider).toBe("openai") // Invalid provider profiles should have apiProvider removed - expect(importedProfiles.apiConfigs["old-claude-profile"].apiProvider).toBeUndefined() + expect(importedProfiles.apiConfigs["old-removed-profile"].apiProvider).toBeUndefined() expect(importedProfiles.apiConfigs["another-invalid"].apiProvider).toBeUndefined() }) 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 3e314a8941..685f95c574 100644 --- a/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts +++ b/webview-ui/src/components/ui/hooks/__tests__/useSelectedModel.spec.ts @@ -13,6 +13,8 @@ import { openAiModelInfoSaneDefaults, minimaxDefaultModelId, minimaxModels, + claudeCodeDefaultModelId, + claudeCodeModels, openRouterDefaultModelId, vscodeLlmModels, vscodeLlmDefaultModelId, @@ -848,6 +850,53 @@ describe("useSelectedModel", () => { }) }) + describe("claude-code provider", () => { + beforeEach(() => { + mockUseRouterModels.mockReturnValue({ + data: { + openrouter: {}, + requesty: {}, + litellm: {}, + }, + isLoading: false, + isError: false, + } as any) + + mockUseOpenRouterModelProviders.mockReturnValue({ + data: {}, + isLoading: false, + isError: false, + } as any) + }) + + it("should return default claude-code model when no custom model is specified", () => { + 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(claudeCodeDefaultModelId) + expect(result.current.info).toEqual(claudeCodeModels[claudeCodeDefaultModelId]) + }) + + it("should use custom model ID and info when model exists in claudeCodeModels", () => { + const apiConfiguration: ProviderSettings = { + apiProvider: "claude-code", + apiModelId: "claude-opus-4-8", + } + + const wrapper = createWrapper() + const { result } = renderHook(() => useSelectedModel(apiConfiguration), { wrapper }) + + expect(result.current.provider).toBe("claude-code") + expect(result.current.id).toBe("claude-opus-4-8") + expect(result.current.info).toEqual(claudeCodeModels["claude-opus-4-8"]) + }) + }) + describe("vscode-lm provider", () => { beforeEach(() => { mockUseRouterModels.mockReturnValue({ diff --git a/webview-ui/src/components/ui/hooks/useSelectedModel.ts b/webview-ui/src/components/ui/hooks/useSelectedModel.ts index f86619a1d4..8643fcb8fc 100644 --- a/webview-ui/src/components/ui/hooks/useSelectedModel.ts +++ b/webview-ui/src/components/ui/hooks/useSelectedModel.ts @@ -6,6 +6,7 @@ import { type RouterModels, anthropicModels, bedrockModels, + claudeCodeModels, deepSeekModels, moonshotModels, minimaxModels, @@ -358,6 +359,11 @@ function getSelectedModel({ const info = openAiCodexModels[id as keyof typeof openAiCodexModels] return { id, info } } + case "claude-code": { + const id = apiConfiguration.apiModelId ?? defaultModelId + const info = claudeCodeModels[id as keyof typeof claudeCodeModels] + return { id, info } + } case "vercel-ai-gateway": { const id = getValidatedModelId( apiConfiguration.vercelAiGatewayModelId,