Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
55 changes: 55 additions & 0 deletions packages/types/src/__tests__/claude-code.spec.ts
Original file line number Diff line number Diff line change
@@ -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"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why .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")
}
})
})
15 changes: 15 additions & 0 deletions packages/types/src/provider-settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
anthropicModels,
basetenModels,
bedrockModels,
claudeCodeModels,
deepSeekModels,
fireworksModels,
geminiModels,
Expand Down Expand Up @@ -117,6 +118,7 @@ export const providerNames = [
"anthropic",
"bedrock",
"baseten",
"claude-code",
"deepseek",
"fireworks",
"gemini",
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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") })),
Expand Down Expand Up @@ -469,6 +477,7 @@ export const providerSettingsSchema = z.object({
...geminiSchema.shape,
...geminiCliSchema.shape,
...openAiCodexSchema.shape,
...claudeCodeSchema.shape,
...openAiNativeSchema.shape,
...mistralSchema.shape,
...deepSeekSchema.shape,
Expand Down Expand Up @@ -545,6 +554,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
bedrock: "apiModelId",
vertex: "apiModelId",
"openai-codex": "apiModelId",
"claude-code": "apiModelId",
"openai-native": "openAiModelId",
ollama: "ollamaModelId",
lmstudio: "lmStudioModelId",
Expand Down Expand Up @@ -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",
Expand Down
156 changes: 156 additions & 0 deletions packages/types/src/providers/claude-code.ts
Original file line number Diff line number Diff line change
@@ -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 <id>
* --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<string, ModelInfo>
4 changes: 4 additions & 0 deletions packages/types/src/providers/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -77,6 +79,8 @@ export function getProviderDefaultModelId(
return xaiDefaultModelId
case "baseten":
return basetenDefaultModelId
case "claude-code":
return claudeCodeDefaultModelId
case "bedrock":
return bedrockDefaultModelId
case "vertex":
Expand Down
14 changes: 7 additions & 7 deletions src/core/config/__tests__/importExport.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand All @@ -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",
},
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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",
},
Expand Down Expand Up @@ -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
Expand All @@ -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()
})

Expand Down
Loading
Loading