diff --git a/apps/vscode/src/sdk/model-catalog/store.test.ts b/apps/vscode/src/sdk/model-catalog/store.test.ts index 668406d294..8809d96625 100644 --- a/apps/vscode/src/sdk/model-catalog/store.test.ts +++ b/apps/vscode/src/sdk/model-catalog/store.test.ts @@ -238,6 +238,25 @@ describe("createProviderConfigStore", () => { }) }) + it("persists Claude Code model selections to providers.json", async () => { + const { createProviderConfigStore } = await import("./store") + const store = createProviderConfigStore() + const providerId = parseProviderId("claude-code") + const selection = { providerId, modelId: "haiku", modelInfo: modelInfoA } + + store.commitSelection(providerId, "act", selection) + + expect(mocks.getSavedProviderSettings("claude-code")).toMatchObject({ + provider: "claude-code", + model: "haiku", + contextWindow: 128_000, + maxTokens: 8_192, + }) + expect(mocks.getSaveProviderSettingsMock()).toHaveBeenCalledWith(expect.objectContaining({ model: "haiku" }), { + setLastUsed: false, + }) + }) + it("subscribers fire synchronously and multiple writes emit events in order", async () => { const { createProviderConfigStore } = await import("./store") const store = createProviderConfigStore() diff --git a/apps/vscode/src/sdk/provider-usability.test.ts b/apps/vscode/src/sdk/provider-usability.test.ts index 1842f78227..5cf43034d2 100644 --- a/apps/vscode/src/sdk/provider-usability.test.ts +++ b/apps/vscode/src/sdk/provider-usability.test.ts @@ -134,6 +134,26 @@ describe("hasUsableProvider", () => { expect(hasUsableProvider(config, "act")).toBe(false) }) + it("treats Claude Code as usable through local Claude CLI auth when a model is configured", () => { + mocks.providerSettingsManager.getProviderSettings.mockReturnValue(undefined) + const config: ApiConfiguration = { + actModeApiProvider: "claude-code", + actModeApiModelId: "sonnet", + } + expect(hasUsableProvider(config, "act")).toBe(true) + }) + + it("treats Claude Code as usable when its model is stored in providers.json", () => { + mocks.providerSettingsManager.getProviderSettings.mockReturnValue({ + provider: "claude-code", + model: "haiku", + }) + const config: ApiConfiguration = { + actModeApiProvider: "claude-code", + } + expect(hasUsableProvider(config, "act")).toBe(true) + }) + it("treats ollama (keyless local) as usable when a model is configured, without a key", () => { const config: ApiConfiguration = { actModeApiProvider: "ollama", diff --git a/apps/vscode/src/sdk/provider-usability.ts b/apps/vscode/src/sdk/provider-usability.ts index 703d254db9..3cfcca4b44 100644 --- a/apps/vscode/src/sdk/provider-usability.ts +++ b/apps/vscode/src/sdk/provider-usability.ts @@ -20,15 +20,15 @@ import { toSdkProviderId } from "./model-catalog/sdk-provider-id" import { getProviderSettingsManager } from "./provider-migration" /** - * Local-inference providers that the legacy ApiConfiguration path treats as - * keyless: they run against a local/self-hosted endpoint and need no API key, - * only a selected model (and optionally a base URL). The SDK catalog models - * ollama/lmstudio as "api-key" providers (they accept an optional key), so the - * catalog's authMethod alone does not classify them as keyless — they are - * listed here explicitly. `vscode-lm` is a VSCode-host provider that is not in - * the SDK builtin catalog at all. + * Local/external-auth providers that the legacy ApiConfiguration path treats as + * keyless: they need no API key in Cline, only a selected model (and optionally + * a base URL). The SDK catalog models ollama/lmstudio as "api-key" providers + * because they accept an optional key; Claude Code authenticates through the + * local Claude CLI; and `vscode-lm` is a VSCode-host provider that is not in + * the SDK builtin catalog at all. List them here when the catalog auth method + * cannot classify them as keyless. */ -const KEYLESS_LOCAL_PROVIDERS = new Set(["ollama", "lmstudio", "vscode-lm"]) +const KEYLESS_LOCAL_PROVIDERS = new Set(["ollama", "lmstudio", "vscode-lm", "claude-code"]) /** * Whether a provider can be usable WITHOUT an API key, on the strength of a @@ -66,18 +66,31 @@ function isKeylessViaModelProvider(providerId: string): boolean { return getProviderAuthMethod(providerId) === "local" } -function hasProviderSettingsAuthCredential(providerId: string): boolean { +function readProviderSettings(providerId: string): unknown { try { - const settings = getProviderSettingsManager().getProviderSettings(providerId) - const auth = - settings && typeof settings === "object" ? (settings as { auth?: { accessToken?: unknown } }).auth : undefined - return typeof auth?.accessToken === "string" && auth.accessToken.trim().length > 0 + return getProviderSettingsManager().getProviderSettings(providerId) } catch { - Logger.warn(`[ProviderUsability] Failed to read provider settings auth credentials for provider ${providerId}`) - return false + Logger.warn(`[ProviderUsability] Failed to read provider settings for provider ${providerId}`) + return undefined } } +function hasProviderSettingsAuthCredential(providerId: string): boolean { + const settings = readProviderSettings(providerId) + const auth = settings && typeof settings === "object" ? (settings as { auth?: { accessToken?: unknown } }).auth : undefined + return typeof auth?.accessToken === "string" && auth.accessToken.trim().length > 0 +} + +function readProviderSettingsModelId(providerId: string): string | undefined { + const settings = readProviderSettings(providerId) + const model = settings && typeof settings === "object" ? (settings as { model?: unknown }).model : undefined + return typeof model === "string" && model.trim().length > 0 ? model.trim() : undefined +} + +function resolveConfiguredModelId(providerId: string, mode: Mode, apiConfig: ApiConfiguration): string | undefined { + return resolveModelId(providerId, mode, apiConfig) ?? readProviderSettingsModelId(providerId) +} + /** * Whether the Bedrock provider has usable auth. resolveApiKey() only sees the * Bedrock API key; profile / SigV4 / default-chain auth also build a working @@ -140,7 +153,7 @@ export function hasUsableProvider(apiConfig: ApiConfiguration, mode: Mode): bool // Keyless local/local-auth providers are usable once a model is // configured for the mode (no API key required). if (isKeylessViaModelProvider(providerId)) { - const modelId = resolveModelId(providerId, mode, apiConfig) + const modelId = resolveConfiguredModelId(providerId, mode, apiConfig) return typeof modelId === "string" && modelId.trim().length > 0 } diff --git a/apps/vscode/webview-ui/src/components/settings/providers/ClaudeCodeProvider.tsx b/apps/vscode/webview-ui/src/components/settings/providers/ClaudeCodeProvider.tsx index 3f137ad112..1860624ff1 100644 --- a/apps/vscode/webview-ui/src/components/settings/providers/ClaudeCodeProvider.tsx +++ b/apps/vscode/webview-ui/src/components/settings/providers/ClaudeCodeProvider.tsx @@ -1,6 +1,9 @@ +import { openAiModelInfoSafeDefaults } from "@shared/api" import { Mode } from "@shared/storage/types" import { useExtensionState } from "@/context/ExtensionStateContext" -import { useStaticProviderSelection } from "@/hooks/useStaticProviderSelection" +import { useProviderConfig } from "@/hooks/useProviderConfig" +import { useProviderModelSelection } from "@/hooks/useProviderModelSelection" +import { useProviderModels } from "@/hooks/useProviderModels" import { DebouncedTextField } from "../common/DebouncedTextField" import { ModelInfoView } from "../common/ModelInfoView" import { ModelSelector } from "../common/ModelSelector" @@ -36,14 +39,31 @@ interface ClaudeCodeProviderProps { */ export const ClaudeCodeProvider = ({ showModelOptions, isPopup, currentMode }: ClaudeCodeProviderProps) => { const { apiConfiguration } = useExtensionState() - const { handleFieldChange, handleModeFieldChange } = useApiConfigurationHandlers() + const { handleFieldChange } = useApiConfigurationHandlers() + const providerId = "claude-code" + const { models, defaultModelId } = useProviderModels(providerId) + const { config, commitSelection } = useProviderConfig(providerId) + const { selectedModelId, selectedModelInfo, commitModelSelection } = useProviderModelSelection(providerId, currentMode, { + models, + defaultModelId, + config, + commitSelection, + }) - // Get the normalized configuration - const { models, selectedModelId, selectedModelInfo, hideUsageCost } = useStaticProviderSelection( - "claude-code", - apiConfiguration, - currentMode, - ) + const handleModelSelect = (event: { + target?: { value?: unknown } + currentTarget?: { value?: unknown } + detail?: { value?: unknown } + }) => { + const modelId = event.target?.value ?? event.currentTarget?.value ?? event.detail?.value + if (typeof modelId !== "string" || modelId.length === 0) { + return + } + void commitModelSelection({ + modelId, + modelInfo: models[modelId] ?? selectedModelInfo ?? openAiModelInfoSafeDefaults, + }).catch((err) => console.error("Failed to commit Claude Code model selection:", err)) + } return (
@@ -67,18 +87,7 @@ export const ClaudeCodeProvider = ({ showModelOptions, isPopup, currentMode }: C {showModelOptions && ( <> - - handleModeFieldChange( - { plan: "planModeApiModelId", act: "actModeApiModelId" }, - e.target.value, - currentMode, - ) - } - selectedModelId={selectedModelId} - /> + {(selectedModelId === "sonnet" || selectedModelId === "opus") && (

)} - + )}