Skip to content

Commit cf63e5f

Browse files
committed
feat(claude-code): add model catalog and provider settings types
1 parent 4e5f601 commit cf63e5f

7 files changed

Lines changed: 292 additions & 7 deletions

File tree

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
import { describe, it, expect } from "vitest"
2+
3+
import { claudeCodeModels, claudeCodeDefaultModelId } from "../providers/claude-code.js"
4+
import { providerSettingsSchema, providerSettingsSchemaDiscriminated } from "../provider-settings.js"
5+
6+
describe("claudeCodeModels", () => {
7+
it("default model ID exists in the models record", () => {
8+
expect(claudeCodeModels).toHaveProperty(claudeCodeDefaultModelId)
9+
})
10+
11+
it("all models have inputPrice: 0 and outputPrice: 0", () => {
12+
for (const [id, model] of Object.entries(claudeCodeModels)) {
13+
expect(model.inputPrice, `${id}: inputPrice must be 0`).toBe(0)
14+
expect(model.outputPrice, `${id}: outputPrice must be 0`).toBe(0)
15+
}
16+
})
17+
})
18+
19+
describe("claudeCodeSchema (via providerSettingsSchema)", () => {
20+
it("accepts an empty object", () => {
21+
const result = providerSettingsSchema.safeParse({})
22+
expect(result.success).toBe(true)
23+
})
24+
25+
it("accepts claudeCodeCliPath as an optional string", () => {
26+
const result = providerSettingsSchema.safeParse({ claudeCodeCliPath: "/usr/local/bin/claude" })
27+
expect(result.success).toBe(true)
28+
if (result.success) {
29+
expect(result.data.claudeCodeCliPath).toBe("/usr/local/bin/claude")
30+
}
31+
})
32+
33+
it("rejects unknown keys in strict mode", () => {
34+
const result = providerSettingsSchema.strict().safeParse({ claudeCodeApiKey: "should-not-exist" })
35+
expect(result.success).toBe(false)
36+
})
37+
})
38+
39+
describe("claudeCodeSchema (via providerSettingsSchemaDiscriminated)", () => {
40+
it("accepts apiProvider: 'claude-code' with no other fields", () => {
41+
const result = providerSettingsSchemaDiscriminated.safeParse({ apiProvider: "claude-code" })
42+
expect(result.success).toBe(true)
43+
})
44+
45+
it("accepts apiProvider: 'claude-code' with claudeCodeCliPath", () => {
46+
const result = providerSettingsSchemaDiscriminated.safeParse({
47+
apiProvider: "claude-code",
48+
claudeCodeCliPath: "/usr/local/bin/claude",
49+
})
50+
expect(result.success).toBe(true)
51+
if (result.success) {
52+
expect(result.data.apiProvider).toBe("claude-code")
53+
}
54+
})
55+
})

packages/types/src/provider-settings.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
anthropicModels,
77
basetenModels,
88
bedrockModels,
9+
claudeCodeModels,
910
deepSeekModels,
1011
fireworksModels,
1112
geminiModels,
@@ -117,6 +118,7 @@ export const providerNames = [
117118
"anthropic",
118119
"bedrock",
119120
"baseten",
121+
"claude-code",
120122
"deepseek",
121123
"fireworks",
122124
"gemini",
@@ -301,6 +303,11 @@ const openAiCodexSchema = apiModelIdProviderModelSchema.extend({
301303
// No additional settings needed - uses OAuth authentication
302304
})
303305

306+
const claudeCodeSchema = apiModelIdProviderModelSchema.extend({
307+
// No API key - authentication is handled externally via `claude login`.
308+
claudeCodeCliPath: z.string().optional(),
309+
})
310+
304311
const openAiNativeSchema = apiModelIdProviderModelSchema.extend({
305312
openAiNativeApiKey: z.string().optional(),
306313
openAiNativeBaseUrl: z.string().optional(),
@@ -433,6 +440,7 @@ export const providerSettingsSchemaDiscriminated = z.discriminatedUnion("apiProv
433440
geminiSchema.merge(z.object({ apiProvider: z.literal("gemini") })),
434441
geminiCliSchema.merge(z.object({ apiProvider: z.literal("gemini-cli") })),
435442
openAiCodexSchema.merge(z.object({ apiProvider: z.literal("openai-codex") })),
443+
claudeCodeSchema.merge(z.object({ apiProvider: z.literal("claude-code") })),
436444
openAiNativeSchema.merge(z.object({ apiProvider: z.literal("openai-native") })),
437445
mistralSchema.merge(z.object({ apiProvider: z.literal("mistral") })),
438446
deepSeekSchema.merge(z.object({ apiProvider: z.literal("deepseek") })),
@@ -469,6 +477,7 @@ export const providerSettingsSchema = z.object({
469477
...geminiSchema.shape,
470478
...geminiCliSchema.shape,
471479
...openAiCodexSchema.shape,
480+
...claudeCodeSchema.shape,
472481
...openAiNativeSchema.shape,
473482
...mistralSchema.shape,
474483
...deepSeekSchema.shape,
@@ -545,6 +554,7 @@ export const modelIdKeysByProvider: Record<TypicalProvider, ModelIdKey> = {
545554
bedrock: "apiModelId",
546555
vertex: "apiModelId",
547556
"openai-codex": "apiModelId",
557+
"claude-code": "apiModelId",
548558
"openai-native": "openAiModelId",
549559
ollama: "ollamaModelId",
550560
lmstudio: "lmStudioModelId",
@@ -668,6 +678,11 @@ export const MODELS_BY_PROVIDER: Record<
668678
label: "OpenAI - ChatGPT Plus/Pro",
669679
models: Object.keys(openAiCodexModels),
670680
},
681+
"claude-code": {
682+
id: "claude-code",
683+
label: "Claude Code (Subscription)",
684+
models: Object.keys(claudeCodeModels),
685+
},
671686
"openai-native": {
672687
id: "openai-native",
673688
label: "OpenAI",
Lines changed: 156 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,156 @@
1+
import type { ModelInfo } from "../model.js"
2+
3+
/**
4+
* Claude Code (Subscription) Provider
5+
*
6+
* This provider shells out to the user's own locally-installed and
7+
* already-authenticated `claude` CLI (Pro/Max/Team/Enterprise subscription),
8+
* instead of an Anthropic API key. No credentials are read, stored, or
9+
* transmitted by this extension.
10+
*
11+
* Key differences from anthropic:
12+
* - Uses the local CLI session instead of API keys
13+
* - Subscription-based pricing (no per-token costs)
14+
* - Model availability depends on the authenticated subscription
15+
*
16+
* Pricing below (0 at runtime) mirrors the real per-token list prices from
17+
* `anthropic.ts` as of 2026-07-04, for reference only.
18+
*
19+
* contextWindow/maxTokens are grounded against live `claude --model <id>
20+
* --output-format json` output (`modelUsage[id].contextWindow` /
21+
* `.maxOutputTokens`) rather than copied from `anthropic.ts`, since the
22+
* Agent SDK/CLI path reports different max output caps than the direct API
23+
* for the same model name (e.g. 64K here vs. 128K direct for Opus/Sonnet 5/
24+
* Fable 5, 32K here vs. 64K direct for Haiku 4.5/Sonnet 4.6).
25+
*
26+
* `claude-sonnet-4-6[1m]` is a distinct, explicitly-selected model key (not
27+
* a flag) that reports contextWindow: 1_000_000 vs. 200_000 for the bare
28+
* `claude-sonnet-4-6` id. `claude-opus-4-6[1m]` was tried and did not
29+
* resolve to a model (empty modelUsage), so no 1M variant is listed for it.
30+
*/
31+
32+
export type ClaudeCodeModelId = keyof typeof claudeCodeModels
33+
34+
export const claudeCodeDefaultModelId: ClaudeCodeModelId = "claude-sonnet-5"
35+
36+
/**
37+
* Models available through the Claude Code CLI subscription flow.
38+
* Costs are 0 as they are covered by the subscription.
39+
*/
40+
export const claudeCodeModels = {
41+
"claude-fable-5": {
42+
maxTokens: 64_000,
43+
contextWindow: 1_000_000,
44+
supportsImages: true,
45+
supportsPromptCache: true,
46+
// List price: $10/$50 per million in/out, $12.50 cache write, $1.00 cache read.
47+
inputPrice: 0,
48+
outputPrice: 0,
49+
cacheWritesPrice: 0,
50+
cacheReadsPrice: 0,
51+
supportsReasoningBudget: true,
52+
supportsReasoningBinary: true,
53+
supportsTemperature: false,
54+
description: "Claude Fable 5 via Claude Code subscription (Pro/Max/Team/Enterprise).",
55+
},
56+
"claude-opus-4-8": {
57+
maxTokens: 64_000,
58+
contextWindow: 1_000_000,
59+
supportsImages: true,
60+
supportsPromptCache: true,
61+
// List price: $5/$25 per million in/out, $6.25 cache write, $0.50 cache read.
62+
inputPrice: 0,
63+
outputPrice: 0,
64+
cacheWritesPrice: 0,
65+
cacheReadsPrice: 0,
66+
supportsReasoningBudget: true,
67+
supportsReasoningBinary: true,
68+
supportsTemperature: false,
69+
description: "Claude Opus 4.8 via Claude Code subscription (Pro/Max/Team/Enterprise).",
70+
},
71+
"claude-opus-4-7": {
72+
maxTokens: 64_000,
73+
contextWindow: 1_000_000,
74+
supportsImages: true,
75+
supportsPromptCache: true,
76+
// List price: $5/$25 per million in/out, $6.25 cache write, $0.50 cache read.
77+
inputPrice: 0,
78+
outputPrice: 0,
79+
cacheWritesPrice: 0,
80+
cacheReadsPrice: 0,
81+
supportsReasoningBudget: true,
82+
supportsReasoningBinary: true,
83+
supportsTemperature: false,
84+
description: "Claude Opus 4.7 via Claude Code subscription (Pro/Max/Team/Enterprise).",
85+
},
86+
"claude-opus-4-6": {
87+
maxTokens: 64_000,
88+
contextWindow: 200_000,
89+
supportsImages: true,
90+
supportsPromptCache: true,
91+
// List price: $5/$25 per million in/out, $6.25 cache write, $0.50 cache read.
92+
inputPrice: 0,
93+
outputPrice: 0,
94+
cacheWritesPrice: 0,
95+
cacheReadsPrice: 0,
96+
supportsReasoningBudget: true,
97+
description: "Claude Opus 4.6 via Claude Code subscription (Pro/Max/Team/Enterprise).",
98+
},
99+
"claude-sonnet-5": {
100+
maxTokens: 64_000,
101+
contextWindow: 1_000_000,
102+
supportsImages: true,
103+
supportsPromptCache: true,
104+
// List price: $2/$10 per million in/out (introductory, through Aug 31, 2026),
105+
// $2.50 cache write, $0.20 cache read.
106+
inputPrice: 0,
107+
outputPrice: 0,
108+
cacheWritesPrice: 0,
109+
cacheReadsPrice: 0,
110+
supportsReasoningBudget: true,
111+
supportsReasoningBinary: true,
112+
supportsTemperature: false,
113+
description: "Claude Sonnet 5 via Claude Code subscription (Pro/Max/Team/Enterprise).",
114+
},
115+
"claude-sonnet-4-6": {
116+
maxTokens: 32_000,
117+
contextWindow: 200_000,
118+
supportsImages: true,
119+
supportsPromptCache: true,
120+
// List price: $3/$15 per million in/out, $3.75 cache write, $0.30 cache read.
121+
inputPrice: 0,
122+
outputPrice: 0,
123+
cacheWritesPrice: 0,
124+
cacheReadsPrice: 0,
125+
supportsReasoningBudget: true,
126+
description: "Claude Sonnet 4.6 via Claude Code subscription (Pro/Max/Team/Enterprise).",
127+
},
128+
"claude-sonnet-4-6[1m]": {
129+
maxTokens: 32_000,
130+
contextWindow: 1_000_000,
131+
supportsImages: true,
132+
supportsPromptCache: true,
133+
// Same list price as claude-sonnet-4-6; [1m] only changes context window.
134+
// Tiered pricing above 200K would apply on the direct Anthropic API path
135+
// but subscription usage is $0 either way.
136+
inputPrice: 0,
137+
outputPrice: 0,
138+
cacheWritesPrice: 0,
139+
cacheReadsPrice: 0,
140+
supportsReasoningBudget: true,
141+
description: "Claude Sonnet 4.6 (1M context) via Claude Code subscription (Pro/Max/Team/Enterprise).",
142+
},
143+
"claude-haiku-4-5-20251001": {
144+
maxTokens: 32_000,
145+
contextWindow: 200_000,
146+
supportsImages: true,
147+
supportsPromptCache: true,
148+
// List price: $1/$5 per million in/out, $1.25 cache write, $0.10 cache read.
149+
inputPrice: 0,
150+
outputPrice: 0,
151+
cacheWritesPrice: 0,
152+
cacheReadsPrice: 0,
153+
supportsReasoningBudget: true,
154+
description: "Claude Haiku 4.5 via Claude Code subscription (Pro/Max/Team/Enterprise).",
155+
},
156+
} as const satisfies Record<string, ModelInfo>

packages/types/src/providers/index.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
export * from "./anthropic.js"
22
export * from "./baseten.js"
33
export * from "./bedrock.js"
4+
export * from "./claude-code.js"
45
export * from "./deepseek.js"
56
export * from "./fireworks.js"
67
export * from "./gemini.js"
@@ -31,6 +32,7 @@ export * from "./zoo-gateway.js"
3132
import { anthropicDefaultModelId } from "./anthropic.js"
3233
import { basetenDefaultModelId } from "./baseten.js"
3334
import { bedrockDefaultModelId } from "./bedrock.js"
35+
import { claudeCodeDefaultModelId } from "./claude-code.js"
3436
import { deepSeekDefaultModelId } from "./deepseek.js"
3537
import { fireworksDefaultModelId } from "./fireworks.js"
3638
import { geminiDefaultModelId } from "./gemini.js"
@@ -77,6 +79,8 @@ export function getProviderDefaultModelId(
7779
return xaiDefaultModelId
7880
case "baseten":
7981
return basetenDefaultModelId
82+
case "claude-code":
83+
return claudeCodeDefaultModelId
8084
case "bedrock":
8185
return bedrockDefaultModelId
8286
case "vertex":

src/core/config/__tests__/importExport.spec.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -548,7 +548,7 @@ describe("importExport", () => {
548548

549549
describe("lenient import with invalid providers", () => {
550550
it("should sanitize profiles with invalid apiProvider and return warnings", async () => {
551-
// Test importing a profile with a removed/invalid provider like "claude-code"
551+
// Test importing a profile with a removed/invalid provider like "some-removed-provider"
552552
;(vscode.window.showOpenDialog as Mock).mockResolvedValue([{ fsPath: "/mock/path/settings.json" }])
553553

554554
const mockFileContent = JSON.stringify({
@@ -561,7 +561,7 @@ describe("importExport", () => {
561561
id: "valid-id",
562562
},
563563
"invalid-profile": {
564-
apiProvider: "claude-code", // Invalid/removed provider
564+
apiProvider: "some-removed-provider", // Invalid/removed provider
565565
apiKey: "some-key",
566566
id: "invalid-id",
567567
},
@@ -595,7 +595,7 @@ describe("importExport", () => {
595595
expect((result as { warnings?: string[] }).warnings).toBeDefined()
596596
expect((result as { warnings?: string[] }).warnings!.length).toBeGreaterThan(0)
597597
expect((result as { warnings?: string[] }).warnings![0]).toContain("invalid-profile")
598-
expect((result as { warnings?: string[] }).warnings![0]).toContain("claude-code")
598+
expect((result as { warnings?: string[] }).warnings![0]).toContain("some-removed-provider")
599599

600600
// The valid profile should be imported
601601
expect(mockProviderSettingsManager.import).toHaveBeenCalled()
@@ -853,8 +853,8 @@ describe("importExport", () => {
853853
apiKey: "key-2",
854854
id: "openai-id",
855855
},
856-
"old-claude-profile": {
857-
apiProvider: "claude-code", // Removed provider
856+
"old-removed-profile": {
857+
apiProvider: "some-old-removed-provider", // Removed provider
858858
apiKey: "key-3",
859859
id: "claude-id",
860860
},
@@ -891,7 +891,7 @@ describe("importExport", () => {
891891
// Should have multiple warnings
892892
const warnings = (result as { warnings?: string[] }).warnings!
893893
expect(warnings.length).toBe(2) // Two profiles had invalid providers
894-
expect(warnings.some((w) => w.includes("old-claude-profile"))).toBe(true)
894+
expect(warnings.some((w) => w.includes("old-removed-profile"))).toBe(true)
895895
expect(warnings.some((w) => w.includes("another-invalid"))).toBe(true)
896896

897897
// Valid profiles should be imported correctly
@@ -900,7 +900,7 @@ describe("importExport", () => {
900900
expect(importedProfiles.apiConfigs["openai-profile"].apiProvider).toBe("openai")
901901

902902
// Invalid provider profiles should have apiProvider removed
903-
expect(importedProfiles.apiConfigs["old-claude-profile"].apiProvider).toBeUndefined()
903+
expect(importedProfiles.apiConfigs["old-removed-profile"].apiProvider).toBeUndefined()
904904
expect(importedProfiles.apiConfigs["another-invalid"].apiProvider).toBeUndefined()
905905
})
906906

0 commit comments

Comments
 (0)