From 34c0cfef31bf0b5e701ab6f9a1d1ccb9c5f805bf Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 12:33:31 -0700 Subject: [PATCH 1/3] Discover Claude models from SDK initialization --- .../src/provider/Drivers/ClaudeDriver.ts | 7 +- .../src/provider/Layers/ClaudeAdapter.test.ts | 26 ++++ .../src/provider/Layers/ClaudeAdapter.ts | 28 ++-- .../Layers/ClaudeCapabilitiesProbe.test.ts | 41 +++++- .../src/provider/Layers/ClaudeProvider.ts | 128 ++++++++++++++---- .../provider/Layers/ProviderRegistry.test.ts | 114 +++++++++++++++- .../ClaudeTextGeneration.test.ts | 35 +++++ .../textGeneration/ClaudeTextGeneration.ts | 20 ++- 8 files changed, 357 insertions(+), 42 deletions(-) diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index c2fc11311aa..dd326342a59 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -6,9 +6,10 @@ * closures captured over the per-instance `ClaudeSettings`. * * Unlike Codex, the Claude snapshot probe may invoke a secondary probe - * (`probeClaudeCapabilities`) to read Anthropic account + slash-command - * metadata. That probe is per-instance and keyed by binary + resolved HOME so - * two concurrent Claude instances don't cross-contaminate account metadata. + * (`probeClaudeCapabilities`) to read Anthropic account, model, and + * slash-command metadata. That probe is per-instance and keyed by binary + + * resolved HOME so two concurrent Claude instances don't cross-contaminate + * account metadata. * * @module provider/Drivers/ClaudeDriver */ diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index 760f0e7fbab..1327eab8955 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -553,6 +553,32 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("forwards capabilities selected for an SDK-discovered Claude model", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection(ProviderInstanceId.make("claudeAgent"), "opus[1m]", [ + { id: "effort", value: "xhigh" }, + { id: "fastMode", value: true }, + ]), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.model, "opus[1m]"); + assert.equal(createInput?.options.effort, "xhigh"); + assert.deepEqual(createInput?.options.settings, { + fastMode: true, + }); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("falls back to default effort when unsupported max is requested for Sonnet 4.6", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.ts b/apps/server/src/provider/Layers/ClaudeAdapter.ts index 285d9dac608..6d2a5ab8298 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -339,6 +339,10 @@ function maxClaudeContextWindowFromModelUsage( function selectedClaudeContextWindow( modelSelection: ModelSelection | undefined, ): number | undefined { + if (modelSelection?.model.endsWith("[1m]")) { + return 1_000_000; + } + switch (modelSelection?.model) { case "claude-opus-4-8": case "claude-opus-4-7": @@ -899,7 +903,9 @@ function buildPromptText( input.modelSelection?.instanceId === boundInstanceId ? input.modelSelection.model : undefined; const caps = getClaudeModelCapabilities(claudeModel); - const promptEffort = resolvePromptInjectedEffort(caps, rawEffort); + const promptEffort = + resolvePromptInjectedEffort(caps, rawEffort) ?? + (caps.optionDescriptors?.length === 0 && rawEffort === "ultrathink" ? rawEffort : null); return applyClaudePromptEffortPrefix(input.input?.trim() ?? "", promptEffort); } @@ -3492,13 +3498,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* ( const apiModelId = modelSelection ? resolveClaudeApiModelId(modelSelection) : undefined; const initialContextWindow = selectedClaudeContextWindow(modelSelection); const rawEffort = getModelSelectionStringOptionValue(modelSelection, "effort"); - const effort = resolveClaudeEffort(caps, rawEffort) ?? null; - const fastModeSupported = descriptors.some( - (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", - ); - const thinkingSupported = descriptors.some( - (descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking", - ); + const hasCatalogCapabilities = descriptors.length > 0; + const effort = + (hasCatalogCapabilities ? resolveClaudeEffort(caps, rawEffort) : rawEffort) ?? null; + const fastModeSupported = + !hasCatalogCapabilities || + descriptors.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "fastMode", + ); + const thinkingSupported = + !hasCatalogCapabilities || + descriptors.some( + (descriptor) => descriptor.type === "boolean" && descriptor.id === "thinking", + ); const fastMode = getModelSelectionBooleanOptionValue(modelSelection, "fastMode") === true && fastModeSupported; diff --git a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts index ab6e5992990..8adef98b163 100644 --- a/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts +++ b/apps/server/src/provider/Layers/ClaudeCapabilitiesProbe.test.ts @@ -83,7 +83,16 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { " agents: [],", ' output_style: "default",', ' available_output_styles: ["default"],', - " models: [],", + " models: [{", + ' value: "opus[1m]",', + ' displayName: "Opus (1M context)",', + ' description: "Opus 5 with 1M context",', + " supportsEffort: true,", + ' supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"],', + " supportsAdaptiveThinking: true,", + " supportsFastMode: true,", + " supportsAutoMode: true,", + " }],", ' account: { email: "dev@example.com", subscriptionType: "pro", tokenSource: "oauth" },', " },", " },", @@ -110,6 +119,36 @@ it.layer(NodeServices.layer)("Claude capability probe SDK boundary", (it) => { subscriptionType: "pro", tokenSource: "oauth", apiProvider: undefined, + models: [ + { + slug: "opus[1m]", + name: "Opus (1M context)", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }, + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + }, + ], + }, + }, + ], slashCommands: [ { name: "review", diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 96202ecd952..8676844ad0c 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -22,6 +22,7 @@ import { resolveSpawnCommand } from "@t3tools/shared/shell"; import { compareSemverVersions } from "@t3tools/shared/semver"; import { query as claudeQuery, + type ModelInfo as ClaudeModelInfo, type Options as ClaudeQueryOptions, type SlashCommand as ClaudeSlashCommand, type SDKUserMessage, @@ -56,7 +57,7 @@ const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; -const BUILT_IN_MODELS: ReadonlyArray = [ +const FALLBACK_CLAUDE_MODELS: ReadonlyArray = [ { slug: "claude-fable-5", name: "Claude Fable 5", @@ -325,10 +326,10 @@ function supportsClaudeOpus47(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_4_7_VERSION) >= 0 : false; } -function getBuiltInClaudeModelsForVersion( +function getFallbackClaudeModelsForVersion( version: string | null | undefined, ): ReadonlyArray { - return BUILT_IN_MODELS.filter((model) => { + return FALLBACK_CLAUDE_MODELS.filter((model) => { if (model.slug === "claude-opus-5") { return supportsClaudeOpus5(version); } @@ -368,7 +369,7 @@ function formatClaudeOpus47UpgradeMessage(version: string | null): string { export function getClaudeModelCapabilities(model: string | null | undefined): ModelCapabilities { const slug = model?.trim(); return ( - BUILT_IN_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? + FALLBACK_CLAUDE_MODELS.find((candidate) => candidate.slug === slug)?.capabilities ?? DEFAULT_CLAUDE_MODEL_CAPABILITIES ); } @@ -408,10 +409,11 @@ export function normalizeClaudeCliEffort( } if ( effort === "xhigh" && - model !== "claude-fable-5" && - model !== "claude-opus-5" && - model !== "claude-opus-4-8" && - model !== "claude-sonnet-5" + (model === "claude-opus-4-7" || + model === "claude-opus-4-6" || + model === "claude-opus-4-5" || + model === "claude-sonnet-4-6" || + model === "claude-haiku-4-5") ) { return "max"; } @@ -615,9 +617,83 @@ type ClaudeCapabilitiesProbe = { * the subscription/token fields are absent and auth is external AWS creds. */ readonly apiProvider: string | undefined; + readonly models: ReadonlyArray; readonly slashCommands: ReadonlyArray; }; +type ClaudeEffortLevel = NonNullable[number]; + +function claudeEffortLabel(effort: ClaudeEffortLevel): string { + switch (effort) { + case "low": + return "Low"; + case "medium": + return "Medium"; + case "high": + return "High"; + case "xhigh": + return "Extra High"; + case "max": + return "Max"; + } +} + +function claudeCapabilitiesFromModelInfo(model: ClaudeModelInfo): ModelCapabilities { + const effortLevels = [...new Set(model.supportedEffortLevels ?? [])]; + return createModelCapabilities({ + optionDescriptors: [ + ...(model.supportsEffort && effortLevels.length > 0 + ? [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + ...effortLevels.map((effort) => ({ + value: effort, + label: claudeEffortLabel(effort), + })), + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + ] + : []), + ...(model.supportsFastMode + ? [ + buildBooleanOptionDescriptor({ + id: "fastMode", + label: "Fast Mode", + }), + ] + : []), + ], + }); +} + +export function parseClaudeInitializationModels( + models: ReadonlyArray | undefined, +): ReadonlyArray { + const seen = new Set(); + return (models ?? []).flatMap((model) => { + const slug = nonEmptyProbeString(model.value); + if (!slug || seen.has(slug)) { + return []; + } + seen.add(slug); + + const name = nonEmptyProbeString(model.displayName) ?? slug; + return [ + { + slug, + name, + isCustom: false, + ...(slug === "default" ? { isDefault: true } : {}), + capabilities: claudeCapabilitiesFromModelInfo(model), + } satisfies ServerProviderModel, + ]; + }); +} + function parseClaudeInitializationCommands( commands: ReadonlyArray | undefined, ): ReadonlyArray { @@ -697,7 +773,7 @@ function waitForAbortSignal(signal: AbortSignal): Promise { * We pass a never-yielding AsyncIterable as the prompt so that no user * message is ever written to the subprocess stdin. This means the Claude * Code subprocess completes its local initialization IPC (returning - * account info and slash commands) but never starts an API request to + * account info, models, and slash commands) but never starts an API request to * Anthropic. We read the init data and then abort the subprocess. * * This is used as a fallback when `claude auth status` does not include @@ -744,6 +820,7 @@ const probeClaudeCapabilities = ( subscriptionType: account?.subscriptionType, tokenSource: account?.tokenSource, apiProvider: account?.apiProvider, + models: parseClaudeInitializationModels(init.models), slashCommands: parseClaudeInitializationCommands(init.commands), } satisfies ClaudeCapabilitiesProbe; }); @@ -793,7 +870,7 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( const resolvedEnvironment = environment ?? process.env; const checkedAt = DateTime.formatIso(yield* DateTime.now); const allModels = providerModelsFromSettings( - BUILT_IN_MODELS, + FALLBACK_CLAUDE_MODELS, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); @@ -882,24 +959,27 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( }); } + const capabilities = resolveCapabilities + ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) + : undefined; + const discoveredModels = capabilities?.models ?? []; + const hasDiscoveredModels = discoveredModels.length > 0; const models = providerModelsFromSettings( - getBuiltInClaudeModelsForVersion(parsedVersion), + hasDiscoveredModels ? discoveredModels : getFallbackClaudeModelsForVersion(parsedVersion), claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) + const versionUpgradeMessage = hasDiscoveredModels ? undefined - : supportsClaudeFable5(parsedVersion) - ? formatClaudeOpus5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); - - const capabilities = resolveCapabilities - ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) - : undefined; + : supportsClaudeOpus5(parsedVersion) + ? undefined + : supportsClaudeFable5(parsedVersion) + ? formatClaudeOpus5UpgradeMessage(parsedVersion) + : supportsClaudeOpus48(parsedVersion) + ? formatClaudeFable5UpgradeMessage(parsedVersion) + : supportsClaudeOpus47(parsedVersion) + ? formatClaudeOpus48UpgradeMessage(parsedVersion) + : formatClaudeOpus47UpgradeMessage(parsedVersion); const skills = yield* discoverClaudeSkills(claudeSettings, cwd, resolvedEnvironment); const slashCommands = capabilities?.slashCommands ?? []; const dedupedSlashCommands = dedupeSlashCommands(slashCommands); @@ -956,7 +1036,7 @@ export const makePendingClaudeProvider = ( Effect.gen(function* () { const checkedAt = yield* nowIso; const models = providerModelsFromSettings( - BUILT_IN_MODELS, + FALLBACK_CLAUDE_MODELS, claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 3f00d3cc662..9633bd9d8f6 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -31,7 +31,7 @@ import { createModelCapabilities } from "@t3tools/shared/model"; import { applyServerSettingsPatch } from "@t3tools/shared/serverSettings"; import { checkCodexProviderStatus, type CodexAppServerProviderSnapshot } from "./CodexProvider.ts"; -import { checkClaudeProviderStatus } from "./ClaudeProvider.ts"; +import { checkClaudeProviderStatus, parseClaudeInitializationModels } from "./ClaudeProvider.ts"; import * as OpenCodeRuntime from "../opencodeRuntime.ts"; import * as ProviderEventLoggers from "./ProviderEventLoggers.ts"; import { ProviderInstanceRegistryHydrationLive } from "./ProviderInstanceRegistryHydration.ts"; @@ -102,6 +102,7 @@ type TestClaudeCapabilities = { readonly subscriptionType: string | undefined; readonly tokenSource: string | undefined; readonly apiProvider: string | undefined; + readonly models: ServerProvider["models"]; readonly slashCommands: ReadonlyArray; }; @@ -112,6 +113,7 @@ function claudeCapabilities(overrides: Partial = {}) { subscriptionType: undefined, tokenSource: undefined, apiProvider: undefined, + models: [], slashCommands: [], ...overrides, }); @@ -1792,6 +1794,116 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te // ── checkClaudeProviderStatus tests ────────────────────────── describe("checkClaudeProviderStatus", () => { + it("maps Claude initialization models and their native capabilities", () => { + const models = parseClaudeInitializationModels([ + { + value: "default", + displayName: "Default (recommended)", + description: "Opus 5 with 1M context", + supportsEffort: true, + supportedEffortLevels: ["low", "high", "xhigh", "max"], + supportsAdaptiveThinking: true, + supportsFastMode: true, + supportsAutoMode: true, + }, + { + value: "sonnet", + displayName: "Sonnet", + description: "Sonnet 5", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high"], + }, + ]); + + assert.deepStrictEqual(models, [ + { + slug: "default", + name: "Default (recommended)", + isCustom: false, + isDefault: true, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "high", label: "High" }, + { id: "xhigh", label: "Extra High" }, + { id: "max", label: "Max" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }, + { + id: "fastMode", + label: "Fast Mode", + type: "boolean", + }, + ], + }, + }, + { + slug: "sonnet", + name: "Sonnet", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Reasoning", + type: "select", + options: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + { id: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }, + ], + }, + }, + ]); + }); + + it.effect("uses Claude initialization models as the authoritative inventory", () => + Effect.gen(function* () { + const discoveredModels = parseClaudeInitializationModels([ + { + value: "opus[1m]", + displayName: "Opus (1M context)", + description: "Opus 5 with 1M context", + supportsEffort: true, + supportedEffortLevels: ["low", "medium", "high", "xhigh", "max"], + supportsAdaptiveThinking: true, + supportsFastMode: true, + supportsAutoMode: true, + }, + ]); + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities({ models: discoveredModels }), + ); + + assert.deepStrictEqual( + status.models.map((model) => model.slug), + ["opus[1m]"], + ); + assert.strictEqual(status.models[0]?.name, "Opus (1M context)"); + assert.strictEqual(status.message, undefined); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.219\n", stderr: "", code: 0 }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("returns ready when claude is installed and authenticated", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts index 0f3905a0cb1..f9ea75d263a 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.test.ts @@ -255,6 +255,41 @@ it.layer(ClaudeTextGenerationTestLayer)("ClaudeTextGeneration", (it) => { ), ); + it.effect("forwards options for an SDK-discovered Claude model", () => + withFakeClaudeEnv( + { + output: JSON.stringify({ + structured_output: { + title: "Use live Claude models", + body: "Body", + }, + }), + argsMustContain: '--model opus[1m] --effort xhigh --settings {"fastMode":true}', + }, + (textGeneration) => + Effect.gen(function* () { + const generated = yield* textGeneration.generatePrContent({ + cwd: process.cwd(), + baseBranch: "main", + headBranch: "fix/claude-model-discovery", + commitSummary: "Use live Claude models", + diffSummary: "1 file changed", + diffPatch: "diff --git a/provider.ts b/provider.ts", + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "opus[1m]", + [ + { id: "effort", value: "xhigh" }, + { id: "fastMode", value: true }, + ], + ), + }); + + expect(generated.title).toBe("Use live Claude models"); + }), + ), + ); + it.effect("generates thread titles through the Claude provider", () => withFakeClaudeEnv( { diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 453bb62b728..75abd3e043e 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -33,6 +33,7 @@ import { toJsonSchemaObject, } from "./TextGenerationUtils.ts"; import { + getModelSelectionBooleanOptionValue, getModelSelectionStringOptionValue, getProviderOptionDescriptors, } from "@t3tools/shared/model"; @@ -133,15 +134,24 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }); const findDescriptor = (id: string) => descriptors.find((descriptor) => descriptor.id === id); const rawEffortSelection = getModelSelectionStringOptionValue(modelSelection, "effort"); - const resolvedEffort = resolveClaudeEffort(caps, rawEffortSelection); + const hasCatalogCapabilities = descriptors.length > 0; + const resolvedEffort = hasCatalogCapabilities + ? resolveClaudeEffort(caps, rawEffortSelection) + : rawEffortSelection; const cliEffort = normalizeClaudeCliEffort(resolvedEffort, modelSelection.model); const ultracode = isClaudeUltracodeEffort(resolvedEffort); const thinkingDescriptor = findDescriptor("thinking"); const fastModeDescriptor = findDescriptor("fastMode"); - const thinking = - thinkingDescriptor?.type === "boolean" ? thinkingDescriptor.currentValue : undefined; - const fastMode = - fastModeDescriptor?.type === "boolean" ? fastModeDescriptor.currentValue : undefined; + const thinking = hasCatalogCapabilities + ? thinkingDescriptor?.type === "boolean" + ? thinkingDescriptor.currentValue + : undefined + : getModelSelectionBooleanOptionValue(modelSelection, "thinking"); + const fastMode = hasCatalogCapabilities + ? fastModeDescriptor?.type === "boolean" + ? fastModeDescriptor.currentValue + : undefined + : getModelSelectionBooleanOptionValue(modelSelection, "fastMode"); const settings = { ...(typeof thinking === "boolean" ? { alwaysThinkingEnabled: thinking } : {}), ...(fastMode ? { fastMode: true } : {}), From 80db6b332c84144ddc47dee4326cd2fd641edb27 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 13:57:16 -0700 Subject: [PATCH 2/3] fix(provider): replace Claude fallback inventory after discovery --- .../provider/Layers/ProviderRegistry.test.ts | 45 +++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 19 +++++--- 2 files changed, 59 insertions(+), 5 deletions(-) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 9633bd9d8f6..fb674fae23c 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -613,6 +613,51 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("drops fallback Claude models missing from successful SDK discovery", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "warning", + enabled: true, + installed: false, + auth: { status: "unknown" }, + checkedAt: "2026-07-24T00:00:00.000Z", + version: null, + models: [ + { + slug: "claude-opus-4-8", + name: "Claude Opus 4.8", + isCustom: false, + capabilities: null, + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const refreshedProvider = { + ...previousProvider, + status: "ready", + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-24T00:01:00.000Z", + version: "2.1.219", + models: [ + { + slug: "opus[1m]", + name: "Opus (1M context)", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [booleanDescriptor("fastMode", "Fast Mode")], + }), + }, + ], + } as const satisfies ServerProvider; + + assert.deepStrictEqual(mergeProviderSnapshot(previousProvider, refreshedProvider).models, [ + ...refreshedProvider.models, + ]); + }); + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 760c8e1c59e..74b4d1077fb 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -79,19 +79,28 @@ const hasModelCapabilities = (model: ServerProvider["models"][number]): boolean (model.capabilities?.optionDescriptors?.length ?? 0) > 0; const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => { - if (provider.driver !== ProviderDriverKind.make("opencode")) { + const ownsAuthoritativeInventory = + provider.driver === ProviderDriverKind.make("opencode") || + provider.driver === ProviderDriverKind.make("claudeAgent"); + if (!ownsAuthoritativeInventory) { return true; } - // OpenCode's initial snapshot is deliberately non-authoritative while its - // first probe is still running. A probe error from an installed CLI/server - // is likewise partial: it could not establish the current inventory. + // OpenCode and Claude initial snapshots are deliberately non-authoritative + // while their first probes are still running. A probe error from an + // installed CLI/server is likewise partial: it could not establish the + // current inventory. Claude reports an SDK initialization failure as a + // warning because its static fallback remains usable. // Conversely, disabled and missing-CLI snapshots are authoritative removals, // as are successful ready/warning inventories (including an empty one after // logout or plugin removal). const isPendingInitialProbe = provider.enabled && !provider.installed && provider.status === "warning"; - const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; + const didInstalledProviderProbeFail = + provider.installed && + (provider.status === "error" || + (provider.driver === ProviderDriverKind.make("claudeAgent") && + provider.status === "warning")); return isPendingInitialProbe || didInstalledProviderProbeFail; }; From fc3bf5d6c120d1bb86f160fa3e18ec6452565cb3 Mon Sep 17 00:00:00 2001 From: Ivan Malison Date: Fri, 24 Jul 2026 14:12:27 -0700 Subject: [PATCH 3/3] fix(provider): keep Claude fallback and discovered catalogs from mixing A Claude capabilities probe failure degrades the snapshot to `warning` while still shipping the complete static fallback inventory, so treating it as a partial probe failure unioned the fallback catalog with the previously discovered SDK slugs and showed duplicate picker entries. Only a hard `error` (CLI missing or unrunnable) now retains stale models. Also map the SDK's `supportsAdaptiveThinking` to the `thinking` toggle for discovered models without effort levels, which otherwise lost the control the static catalog gave them. Co-Authored-By: Claude --- .../src/provider/Layers/ClaudeProvider.ts | 14 +++- .../provider/Layers/ProviderRegistry.test.ts | 65 +++++++++++++++++++ .../src/provider/Layers/ProviderRegistry.ts | 14 ++-- 3 files changed, 84 insertions(+), 9 deletions(-) diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index 8676844ad0c..3b96843cb4e 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -640,9 +640,10 @@ function claudeEffortLabel(effort: ClaudeEffortLevel): string { function claudeCapabilitiesFromModelInfo(model: ClaudeModelInfo): ModelCapabilities { const effortLevels = [...new Set(model.supportedEffortLevels ?? [])]; + const hasEffortSelect = Boolean(model.supportsEffort) && effortLevels.length > 0; return createModelCapabilities({ optionDescriptors: [ - ...(model.supportsEffort && effortLevels.length > 0 + ...(hasEffortSelect ? [ buildSelectOptionDescriptor({ id: "effort", @@ -658,6 +659,17 @@ function claudeCapabilitiesFromModelInfo(model: ClaudeModelInfo): ModelCapabilit }), ] : []), + // Effort already governs how much a model thinks, so the standalone + // always-thinking toggle is only offered for models without effort + // levels — matching how the static fallback catalog exposes it. + ...(!hasEffortSelect && model.supportsAdaptiveThinking + ? [ + buildBooleanOptionDescriptor({ + id: "thinking", + label: "Thinking", + }), + ] + : []), ...(model.supportsFastMode ? [ buildBooleanOptionDescriptor({ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index fb674fae23c..11f2345b6e6 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -658,6 +658,52 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("replaces discovered Claude models with the fallback inventory when the probe fails", () => { + const previousProvider = { + instanceId: ProviderInstanceId.make("claudeAgent"), + driver: ProviderDriverKind.make("claudeAgent"), + status: "ready", + enabled: true, + installed: true, + auth: { status: "authenticated" }, + checkedAt: "2026-07-24T00:00:00.000Z", + version: "2.1.219", + models: [ + { + slug: "opus[1m]", + name: "Opus (1M context)", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [booleanDescriptor("fastMode", "Fast Mode")], + }), + }, + ], + slashCommands: [], + skills: [], + } as const satisfies ServerProvider; + const degradedProvider = { + ...previousProvider, + status: "warning", + auth: { status: "unknown" }, + checkedAt: "2026-07-24T00:01:00.000Z", + models: [ + { + slug: "claude-opus-5", + name: "Claude Opus 5", + isCustom: false, + capabilities: null, + }, + ], + } as const satisfies ServerProvider; + + assert.deepStrictEqual( + mergeProviderSnapshot(previousProvider, degradedProvider).models.map( + (model) => model.slug, + ), + ["claude-opus-5"], + ); + }); + it("retains stale OpenCode models when a refresh fails", () => { const previousProvider = { instanceId: ProviderInstanceId.make("opencode"), @@ -1913,6 +1959,25 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ]); }); + it("offers a thinking toggle for discovered models without effort levels", () => { + const models = parseClaudeInitializationModels([ + { + value: "haiku", + displayName: "Haiku", + description: "Haiku 4.5", + supportsAdaptiveThinking: true, + }, + ]); + + assert.deepStrictEqual(models[0]?.capabilities?.optionDescriptors, [ + { + id: "thinking", + label: "Thinking", + type: "boolean", + }, + ]); + }); + it.effect("uses Claude initialization models as the authoritative inventory", () => Effect.gen(function* () { const discoveredModels = parseClaudeInitializationModels([ diff --git a/apps/server/src/provider/Layers/ProviderRegistry.ts b/apps/server/src/provider/Layers/ProviderRegistry.ts index 74b4d1077fb..9ef3707aad7 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.ts @@ -89,18 +89,16 @@ const shouldRetainMissingProviderModels = (provider: ServerProvider): boolean => // OpenCode and Claude initial snapshots are deliberately non-authoritative // while their first probes are still running. A probe error from an // installed CLI/server is likewise partial: it could not establish the - // current inventory. Claude reports an SDK initialization failure as a - // warning because its static fallback remains usable. + // current inventory. // Conversely, disabled and missing-CLI snapshots are authoritative removals, // as are successful ready/warning inventories (including an empty one after - // logout or plugin removal). + // logout or plugin removal). Claude degrades an SDK initialization failure to + // a warning while shipping its complete static fallback inventory, so that + // snapshot is authoritative too: retaining previously discovered slugs would + // union two catalogs and show duplicate entries in the picker. const isPendingInitialProbe = provider.enabled && !provider.installed && provider.status === "warning"; - const didInstalledProviderProbeFail = - provider.installed && - (provider.status === "error" || - (provider.driver === ProviderDriverKind.make("claudeAgent") && - provider.status === "warning")); + const didInstalledProviderProbeFail = provider.installed && provider.status === "error"; return isPendingInitialProbe || didInstalledProviderProbeFail; };