diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index df68e337..6de7030f 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -177,6 +177,7 @@ function makeHarness(config?: { readonly nativeEventLogger?: ClaudeAdapterLiveOptions["nativeEventLogger"]; readonly cwd?: string; readonly baseDir?: string; + readonly claudeVersion?: string | null; }) { const query = new FakeClaudeQuery(); let createInput: @@ -201,6 +202,11 @@ function makeHarness(config?: { nativeEventLogPath: config.nativeEventLogPath, } : {}), + ...(config && Object.hasOwn(config, "claudeVersion") + ? { + resolveClaudeVersion: () => Effect.succeed(config.claudeVersion ?? null), + } + : {}), }; return { @@ -310,6 +316,11 @@ function effortLevelFromOptions( return settings && typeof settings === "object" ? settings.effortLevel : undefined; } +function fastModeFromOptions(options: ClaudeQueryOptions | undefined): boolean | undefined { + const settings = options?.settings; + return settings && typeof settings === "object" ? settings.fastMode : undefined; +} + const THREAD_ID = ThreadId.makeUnsafe("thread-claude-1"); const RESUME_THREAD_ID = ThreadId.makeUnsafe("thread-claude-resume"); @@ -447,6 +458,102 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("scopes pre-session agent discovery and caching to the Claude executable", () => { + const queries: FakeClaudeQuery[] = []; + const discoveryContexts: Array<{ + executable: string | undefined; + cwd: string | undefined; + }> = []; + const layer = makeClaudeAdapterLive({ + createQuery: (input) => { + const query = new FakeClaudeQuery(); + const executable = input.options.pathToClaudeCodeExecutable; + const cwd = input.options.cwd; + discoveryContexts.push({ executable, cwd }); + Object.assign(query, { + supportedAgents: async () => [ + { + name: + executable === "/managed/claude-a" && cwd === "/tmp/project" + ? "agent-a" + : executable === "/managed/claude-b" + ? "agent-b" + : "agent-other-cwd", + description: `from ${executable} in ${cwd}`, + model: "inherit", + }, + ], + }); + queries.push(query); + return query; + }, + }).pipe( + Layer.provideMerge(ServerConfig.layerTest("/tmp/claude-agent-discovery", "/tmp")), + Layer.provideMerge(NodeServices.layer), + ); + + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + if (!adapter.listAgents) { + return assert.fail("Claude adapter should support agent discovery."); + } + + const first = yield* adapter.listAgents({ + provider: "claudeAgent", + cwd: "/tmp/project", + binaryPath: "/managed/claude-a", + }); + const second = yield* adapter.listAgents({ + provider: "claudeAgent", + cwd: "/tmp/project", + binaryPath: "/managed/claude-b", + }); + const otherCwd = yield* adapter.listAgents({ + provider: "claudeAgent", + cwd: "/tmp/other-project", + binaryPath: "/managed/claude-a", + }); + const firstCached = yield* adapter.listAgents({ + provider: "claudeAgent", + cwd: "/tmp/project", + binaryPath: "/managed/claude-a", + }); + + assert.deepEqual(discoveryContexts, [ + { executable: "/managed/claude-a", cwd: "/tmp/project" }, + { executable: "/managed/claude-b", cwd: "/tmp/project" }, + { executable: "/managed/claude-a", cwd: "/tmp/other-project" }, + ]); + assert.deepEqual( + first.agents.map((agent) => agent.name), + ["agent-a"], + ); + assert.deepEqual( + second.agents.map((agent) => agent.name), + ["agent-b"], + ); + assert.deepEqual( + otherCwd.agents.map((agent) => agent.name), + ["agent-other-cwd"], + ); + assert.equal(first.cached, false); + assert.equal(second.cached, false); + assert.equal(otherCwd.cached, false); + assert.equal(firstCached.cached, true); + assert.deepEqual( + firstCached.agents.map((agent) => agent.name), + ["agent-a"], + ); + assert.deepEqual( + queries.map((query) => query.closeCalls), + [1, 1, 1], + ); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(layer), + ); + }); + it.effect("disables Claude self-updates only for a Scient-managed executable", () => { const harness = makeHarness(); return Effect.gen(function* () { @@ -721,6 +828,83 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("starts an Opus alias with its native context suffix and supported options", () => { + const harness = makeHarness({ claudeVersion: "2.1.219" }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "opus[1m]", + options: { + effort: "xhigh", + autoCompactWindow: "1m", + fastMode: true, + }, + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.model, "claude-opus-5[1m]"); + assert.equal(autoCompactWindowFromOptions(createInput?.options), 1_000_000); + assert.equal(effortLevelFromOptions(createInput?.options), "xhigh"); + assert.equal(fastModeFromOptions(createInput?.options), true); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("falls back persisted Opus aliases when the Claude runtime is too old", () => { + const harness = makeHarness({ claudeVersion: "2.1.218" }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "opus[1m]", + options: { effort: "ultracode", fastMode: true }, + }, + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.model, "claude-opus-4-8[1m]"); + assert.equal(session.model, "claude-opus-4-8[1m]"); + assert.equal(effortLevelFromOptions(createInput?.options), "xhigh"); + assert.equal(fastModeFromOptions(createInput?.options), true); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + + it.effect("fails closed to Opus 4.8 when the Claude runtime version is unknown", () => { + const harness = makeHarness({ claudeVersion: null }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + modelSelection: { + provider: "claudeAgent", + model: "claude-opus-5", + }, + runtimeMode: "full-access", + }); + + assert.equal(harness.getLastCreateQueryInput()?.options.model, "claude-opus-4-8"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("uses live settings for non-max Sonnet 5 effort and spawn options for max", () => Effect.gen(function* () { for (const effort of ["low", "medium", "high", "xhigh", "max"] as const) { @@ -4287,6 +4471,33 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves an Opus alias context suffix when changing models live", () => { + const harness = makeHarness({ claudeVersion: "2.1.219" }); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + + const session = yield* adapter.startSession({ + threadId: THREAD_ID, + provider: "claudeAgent", + runtimeMode: "full-access", + }); + yield* adapter.sendTurn({ + threadId: session.threadId, + input: "hello", + modelSelection: { + provider: "claudeAgent", + model: "opus[1m]", + }, + attachments: [], + }); + + assert.deepEqual(harness.query.setModelCalls, ["claude-opus-5[1m]"]); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("updates the auto-compact budget live without changing the Claude model id", () => { 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 34388ea1..59f9cf97 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.ts @@ -65,6 +65,8 @@ import { getModelCapabilities, hasAutoCompactWindowOption, hasEffortLevel, + normalizeClaudeModelSelectionForRuntime, + normalizeModelSlug, resolveApiModelId, trimOrNull, } from "@synara/shared/model"; @@ -85,6 +87,7 @@ import { Semaphore, Stream, } from "effect"; +import { ChildProcessSpawner } from "effect/unstable/process"; import { resolveAttachmentPath } from "../../attachmentStore.ts"; import { ServerConfig } from "../../config.ts"; @@ -92,6 +95,7 @@ import { buildIsolatedClaudeDiscoveryOptions } from "../claudeDiscoveryIsolation import { buildFileAttachmentsPromptBlock } from "../attachmentProjection.ts"; import { readProviderPromptImage } from "../promptAttachments.ts"; import { buildClaudeProcessEnv } from "../claudeProcessEnv.ts"; +import { resolveClaudeCliVersion } from "../claudeCliVersion.ts"; import { applyClaudeTaskToolResult, claudeTrackedTasksPayload, @@ -231,6 +235,8 @@ interface ClaudeSessionContext { firstTurnSpawnModeAuthoritative: boolean; lastInteractionMode: "default" | "plan" | undefined; currentApiModelId: string | undefined; + readonly claudeExecutable: string; + claudeVersion: string | null | undefined; resumeSessionId: string | undefined; readonly pendingApprovals: Map; readonly pendingUserInputs: Map; @@ -288,6 +294,10 @@ export interface ClaudeAdapterLiveOptions { }) => ClaudeQueryRuntime; readonly nativeEventLogPath?: string; readonly nativeEventLogger?: EventNdjsonLogger; + readonly resolveClaudeVersion?: (input: { + readonly executable: string; + readonly env: NodeJS.ProcessEnv; + }) => Effect.Effect; } function mapSupportedCommands(commands: SlashCommand[]): ProviderListCommandsResult { @@ -1423,6 +1433,7 @@ function sdkNativeItemId(message: SDKMessage): string | undefined { function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { return Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; + const childProcessSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const serverConfig = yield* ServerConfig; const nativeEventLogger = options?.nativeEventLogger ?? @@ -1443,7 +1454,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const sessionLifecycleLocks = new Map(); const modelsCache = new Map(); const pendingModelDiscoveries = new Map>(); - let cachedAgents: ProviderListAgentsResult | null = null; + const agentsCache = new Map(); + const pendingAgentDiscoveries = new Map>(); const runtimeEventQueue = yield* Queue.unbounded(); const nowIso = Effect.map(DateTime.now, DateTime.formatIso); @@ -1467,6 +1479,20 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { isScientManagedProviderExecutable(binaryPath, serverConfig.stateDir) ? { ...env, DISABLE_AUTOUPDATER: "1" } : env; + const resolveClaudeVersion = + options?.resolveClaudeVersion ?? + ((input: { readonly executable: string; readonly env: NodeJS.ProcessEnv }) => + resolveClaudeCliVersion(input).pipe( + Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, childProcessSpawner), + )); + const resolveClaudeVersionForSelection = ( + modelSelection: Extract, + executable: string, + env: NodeJS.ProcessEnv, + ) => + normalizeModelSlug(modelSelection.model, "claudeAgent") === "claude-opus-5" + ? resolveClaudeVersion({ executable, env }) + : Effect.succeed(undefined); const offerRuntimeEvent = (event: ProviderRuntimeEvent): Effect.Effect => Queue.offer(runtimeEventQueue, event).pipe(Effect.asVoid); @@ -3647,8 +3673,23 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ); const providerOptions = input.providerOptions?.claudeAgent; - const modelSelection = + const requestedModelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + const claudeExecutable = providerOptions?.binaryPath ?? "claude"; + const claudeSdkEnv = claudeSdkEnvForExecutable( + yield* resolveClaudeSdkEnv, + claudeExecutable, + ); + const claudeVersion = requestedModelSelection + ? yield* resolveClaudeVersionForSelection( + requestedModelSelection, + claudeExecutable, + claudeSdkEnv, + ) + : undefined; + const modelSelection = requestedModelSelection + ? normalizeClaudeModelSelectionForRuntime(requestedModelSelection, claudeVersion) + : undefined; const requestedEffort = trimOrNull(modelSelection?.options?.effort ?? null); const requestedAutoCompactWindow = trimOrNull( modelSelection?.options?.autoCompactWindow ?? @@ -3704,11 +3745,6 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { ...(ultracode ? { ultracode: true } : {}), }; const claudeSubagents = buildClaudeSdkSubagents(); - const claudeExecutable = providerOptions?.binaryPath ?? "claude"; - const claudeSdkEnv = claudeSdkEnvForExecutable( - yield* resolveClaudeSdkEnv, - claudeExecutable, - ); const queryOptions: ClaudeQueryOptions = { ...(input.cwd ? { cwd: input.cwd } : {}), @@ -3779,12 +3815,12 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }); } - // Populate agent cache in background from first session - if (!cachedAgents) { + // Populate the same executable/cwd-scoped agent cache used by discovery. + if (!agentsCache.has(modelCacheKey)) { queryRuntime .supportedAgents() .then((agents) => { - cachedAgents = { + agentsCache.set(modelCacheKey, { agents: agents.map((a) => ({ name: a.name, displayName: a.name, @@ -3793,7 +3829,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { })), source: "sdk", cached: false, - }; + }); }) .catch(() => { /* ignore discovery failures */ @@ -3838,6 +3874,8 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { firstTurnSpawnModeAuthoritative: true, lastInteractionMode: undefined, currentApiModelId: apiModelId, + claudeExecutable, + claudeVersion, resumeSessionId: sessionId, pendingApprovals, pendingUserInputs, @@ -3982,8 +4020,23 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { const sendTurn: ClaudeAdapterShape["sendTurn"] = (input) => Effect.gen(function* () { const context = yield* requireSession(input.threadId); - const modelSelection = + const requestedModelSelection = input.modelSelection?.provider === "claudeAgent" ? input.modelSelection : undefined; + if ( + requestedModelSelection && + context.claudeVersion === undefined && + normalizeModelSlug(requestedModelSelection.model, "claudeAgent") === "claude-opus-5" + ) { + context.claudeVersion = yield* resolveClaudeVersionForSelection( + requestedModelSelection, + context.claudeExecutable, + claudeSdkEnvForExecutable(yield* resolveClaudeSdkEnv, context.claudeExecutable), + ); + } + const modelSelection = requestedModelSelection + ? normalizeClaudeModelSelectionForRuntime(requestedModelSelection, context.claudeVersion) + : undefined; + const dispatchInput = requestedModelSelection ? { ...input, modelSelection } : input; const requestedAutoCompactWindow = resolveSelectedClaudeAutoCompactWindow( modelSelection?.model, modelSelection?.options?.autoCompactWindow ?? modelSelection?.options?.contextWindow, @@ -4173,7 +4226,7 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { }); } - const message = yield* buildUserMessageEffect(input, { + const message = yield* buildUserMessageEffect(dispatchInput, { fileSystem, attachmentsDir: serverConfig.attachmentsDir, }); @@ -4353,6 +4406,43 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { } } + async function discoverAgentsViaTemporaryProcess( + cwd: string, + env: NodeJS.ProcessEnv, + binaryPath: string, + ): Promise { + const tempQuery = createQuery({ + prompt: neverResolvingUserMessageStream(), + options: buildIsolatedClaudeDiscoveryOptions({ + cwd, + pathToClaudeCodeExecutable: binaryPath, + permissionMode: "plan" as PermissionMode, + env: claudeSdkEnvForExecutable(env, binaryPath), + }), + }); + + try { + void (async () => { + for await (const message of tempQuery) { + void message; + } + })().catch(() => undefined); + const agents = await tempQuery.supportedAgents(); + return { + agents: agents.map((agent) => ({ + name: agent.name, + displayName: agent.name, + ...(agent.description ? { description: agent.description } : {}), + ...(agent.model ? { model: agent.model } : {}), + })), + source: "sdk", + cached: false, + }; + } finally { + tempQuery.close(); + } + } + const listCommands: NonNullable = ( input: ProviderListCommandsInput, ) => @@ -4495,32 +4585,39 @@ function makeClaudeAdapter(options?: ClaudeAdapterLiveOptions) { return result; }); - const listAgents: NonNullable = (_input) => - Effect.sync(() => { - if (cachedAgents) { - return { ...cachedAgents, cached: true }; - } - for (const [, context] of sessions) { - if (!context.stopped && context.query) { - context.query - .supportedAgents() - .then((agents) => { - cachedAgents = { - agents: agents.map((a) => ({ - name: a.name, - displayName: a.name, - ...(a.description ? { description: a.description } : {}), - ...(a.model ? { model: a.model } : {}), - })), - source: "sdk", - cached: false, - }; - }) - .catch(() => {}); - break; - } - } - return { agents: [], source: "pending", cached: false }; + const listAgents: NonNullable = (input) => + Effect.gen(function* () { + const cwd = input.cwd ?? serverConfig.cwd; + const binaryPath = input.binaryPath ?? "claude"; + const cacheKey = JSON.stringify({ cwd, binaryPath }); + const cached = agentsCache.get(cacheKey); + if (cached) return { ...cached, cached: true }; + + const claudeSdkEnv = yield* resolveClaudeSdkEnv; + const existing = pendingAgentDiscoveries.get(cacheKey); + const discovery = + existing ?? discoverAgentsViaTemporaryProcess(cwd, claudeSdkEnv, binaryPath); + if (!existing) pendingAgentDiscoveries.set(cacheKey, discovery); + const result = yield* Effect.tryPromise({ + try: () => discovery, + catch: (cause) => + new ProviderAdapterProcessError({ + provider: PROVIDER, + threadId: ThreadId.makeUnsafe("discovery"), + detail: toMessage(cause, "Failed to discover Claude agents."), + cause, + }), + }).pipe( + Effect.ensuring( + Effect.sync(() => { + if (pendingAgentDiscoveries.get(cacheKey) === discovery) { + pendingAgentDiscoveries.delete(cacheKey); + } + }), + ), + ); + agentsCache.set(cacheKey, result); + return result; }); return { diff --git a/apps/server/src/provider/claudeCliVersion.ts b/apps/server/src/provider/claudeCliVersion.ts new file mode 100644 index 00000000..32258731 --- /dev/null +++ b/apps/server/src/provider/claudeCliVersion.ts @@ -0,0 +1,52 @@ +// FILE: claudeCliVersion.ts +// Purpose: Resolve the exact Claude CLI version used for a provider session. +// Layer: Provider runtime helper + +import { prepareWindowsSafeProcess } from "@synara/shared/windowsProcess"; +import { Effect, Option, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process"; + +import { parseGenericCliVersion } from "./providerMaintenance"; + +const CLAUDE_VERSION_PROBE_TIMEOUT_MS = 4_000; + +const collectStreamAsString = (stream: Stream.Stream): Effect.Effect => + Stream.runFold( + stream, + () => "", + (acc, chunk) => acc + new TextDecoder().decode(chunk), + ); + +export function resolveClaudeCliVersion(input: { + readonly executable: string; + readonly env: NodeJS.ProcessEnv; +}): Effect.Effect { + return Effect.gen(function* () { + const spawner = yield* ChildProcessSpawner.ChildProcessSpawner; + const prepared = prepareWindowsSafeProcess(input.executable, ["--version"], { + env: input.env, + }); + const command = ChildProcess.make(prepared.command, prepared.args, { + shell: prepared.shell, + ...(prepared.windowsVerbatimArguments ? { windowsVerbatimArguments: true } : {}), + env: input.env, + stdin: "ignore", + }); + const child = yield* spawner.spawn(command); + const [stdout, stderr, exitCode] = yield* Effect.all( + [ + collectStreamAsString(child.stdout), + collectStreamAsString(child.stderr), + child.exitCode.pipe(Effect.map(Number)), + ], + { concurrency: "unbounded" }, + ); + if (exitCode !== 0) return null; + return parseGenericCliVersion(`${stdout}\n${stderr}`); + }).pipe( + Effect.scoped, + Effect.timeoutOption(CLAUDE_VERSION_PROBE_TIMEOUT_MS), + Effect.map(Option.getOrElse((): string | null => null)), + Effect.catch(() => Effect.succeed(null)), + ); +} diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 2d9820e6..27f84404 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -573,6 +573,7 @@ import { import { buildModelSelection, buildNextProviderOptions, + filterProviderModelOptionsForRuntime, mergeDynamicModelOptions, type ProviderModelOption, } from "../providerModelOptions"; @@ -2125,6 +2126,9 @@ export default function ChatView({ const featureFlags = useFeatureFlags(); const showDebugTaskBanner = import.meta.env.DEV && featureFlags["show-debug-task-banner"]; const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const claudeProviderVersion = + serverConfigQuery.data?.providers.find((provider) => provider.provider === "claudeAgent") + ?.version ?? null; const composerModelHintByProvider = useMemo>(() => { const threadModelSelection = activeThread?.modelSelection ?? null; const projectModelSelection = activeProject?.defaultModelSelection ?? null; @@ -2157,7 +2161,10 @@ export default function ChatView({ serverCwd: serverConfigQuery.data?.cwd ?? null, }); const claudeDynamicModelsQuery = useQuery( - providerModelsQueryOptions({ provider: "claudeAgent" }), + providerModelsQueryOptions({ + provider: "claudeAgent", + binaryPath: settings.claudeBinaryPath || null, + }), ); const codexDynamicModelsQuery = useQuery(providerModelsQueryOptions({ provider: "codex" })); const openCodeModelDiscoveryEnabled = @@ -2248,7 +2255,10 @@ export default function ChatView({ }), ); const claudeDynamicAgentsQuery = useQuery( - providerAgentsQueryOptions({ provider: "claudeAgent" }), + providerAgentsQueryOptions({ + provider: "claudeAgent", + binaryPath: settings.claudeBinaryPath || null, + }), ); const codexDynamicAgentsQuery = useQuery(providerAgentsQueryOptions({ provider: "codex" })); const openCodeDynamicAgentsQuery = useQuery( @@ -2338,17 +2348,24 @@ export default function ChatView({ ], ); const modelOptionsByProvider = useMemo(() => { - const staticOptions: Record> = { + const staticOptions: Record< + ProviderKind, + ReadonlyArray + > = { codex: getAppModelOptions( "codex", customModelsByProvider.codex, composerModelHintByProvider.codex, ), - claudeAgent: getAppModelOptions( - "claudeAgent", - customModelsByProvider.claudeAgent, - composerModelHintByProvider.claudeAgent, - ), + claudeAgent: filterProviderModelOptionsForRuntime({ + provider: "claudeAgent", + providerVersion: claudeProviderVersion, + options: getAppModelOptions( + "claudeAgent", + customModelsByProvider.claudeAgent, + composerModelHintByProvider.claudeAgent, + ), + }), cursor: getAppModelOptions( "cursor", customModelsByProvider.cursor, @@ -2416,6 +2433,7 @@ export default function ChatView({ if (dynamicModels && dynamicModels.length > 0) { result[provider] = mergeDynamicModelOptions({ provider, + ...(provider === "claudeAgent" ? { providerVersion: claudeProviderVersion } : {}), staticOptions: staticOptions[provider], dynamicModels, }); @@ -2425,6 +2443,7 @@ export default function ChatView({ return result; }, [ claudeDynamicModelsQuery.data, + claudeProviderVersion, composerModelHintByProvider, codexDynamicModelsQuery.data, cursorDynamicModelsQuery.data, diff --git a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx index 7abce5cf..c30496de 100644 --- a/apps/web/src/components/chat/ProviderModelPicker.browser.tsx +++ b/apps/web/src/components/chat/ProviderModelPicker.browser.tsx @@ -9,6 +9,7 @@ import { useProviderConnectionDialogStore } from "../../providerConnectionDialog const MODEL_OPTIONS_BY_PROVIDER = { claudeAgent: [ + { slug: "claude-opus-5", name: "Claude Opus 5" }, { slug: "claude-opus-4-6", name: "Claude Opus 4.6" }, { slug: "claude-sonnet-4-6", name: "Claude Sonnet 4.6" }, { slug: "claude-haiku-4-5", name: "Claude Haiku 4.5" }, @@ -267,6 +268,23 @@ describe("ProviderModelPicker", () => { } }); + it("shows and selects the supported Claude Opus 5 row", async () => { + const mounted = await mountPicker({ + provider: "claudeAgent", + model: "claude-opus-4-6", + lockedProvider: "claudeAgent", + }); + + try { + await page.getByRole("button").click(); + await page.getByRole("menuitemradio", { name: "Claude Opus 5" }).click(); + + expect(mounted.onProviderModelChange).toHaveBeenCalledWith("claudeAgent", "claude-opus-5"); + } finally { + await mounted.cleanup(); + } + }); + it("shows live Droid cost multipliers without adding one to BYOK models", async () => { const mounted = await mountPicker({ provider: "droid", diff --git a/apps/web/src/components/chat/TraitsPicker.browser.tsx b/apps/web/src/components/chat/TraitsPicker.browser.tsx index 3d276a43..8e7f0bf3 100644 --- a/apps/web/src/components/chat/TraitsPicker.browser.tsx +++ b/apps/web/src/components/chat/TraitsPicker.browser.tsx @@ -226,6 +226,26 @@ describe("TraitsPicker (Claude)", () => { }); }); + it("shows the Opus 5 reasoning, speed, and auto-compact controls", async () => { + await using _ = await mountClaudePicker({ + model: "claude-opus-5", + }); + + await page.getByRole("button").click(); + + await vi.waitFor(() => { + const text = document.body.textContent ?? ""; + expect(text).toContain("Extra High"); + expect(text).toContain("Max"); + expect(text).toContain("Ultracode"); + expect(text).not.toContain("Ultrathink"); + expect(text).toContain("Speed"); + expect(text).toContain("Fast"); + expect(text).toContain("Auto-compact"); + expect(text).toContain("1M"); + }); + }); + it("shows a th inking on/off dropdown for Haiku", async () => { await using _ = await mountClaudePicker({ model: "claude-haiku-4-5", diff --git a/apps/web/src/components/chat/composerProviderRegistry.test.tsx b/apps/web/src/components/chat/composerProviderRegistry.test.tsx index 82ce1a07..5a2367fe 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.test.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.test.tsx @@ -450,6 +450,33 @@ describe("getComposerProviderState", () => { }); }); + it("drops stored Claude controls that runtime discovery proves unsupported", () => { + const state = getComposerProviderState({ + provider: "claudeAgent", + model: "claude-opus-4-8", + runtimeModel: { + slug: "opus[1m]", + name: "Opus", + resolvedModel: "claude-opus-4-8[1m]", + supportedReasoningEfforts: [{ value: "low" }, { value: "high" }], + supportsFastMode: false, + }, + prompt: "", + modelOptions: { + claudeAgent: { + effort: "xhigh", + fastMode: true, + }, + }, + }); + + expect(state).toEqual({ + provider: "claudeAgent", + promptEffort: "high", + modelOptionsForDispatch: undefined, + }); + }); + it("tracks Claude ultrathink from the prompt without changing dispatch effort", () => { const state = getComposerProviderState({ provider: "claudeAgent", diff --git a/apps/web/src/components/chat/composerProviderRegistry.tsx b/apps/web/src/components/chat/composerProviderRegistry.tsx index a85be2fc..a1a568cd 100644 --- a/apps/web/src/components/chat/composerProviderRegistry.tsx +++ b/apps/web/src/components/chat/composerProviderRegistry.tsx @@ -150,7 +150,7 @@ function getProviderStateFromCapabilities( case "claudeAgent": { const providerOptions = modelOptions?.claudeAgent; rawEffort = trimOrNull(providerOptions?.effort); - normalizedOptions = normalizeClaudeModelOptions(model, providerOptions); + normalizedOptions = normalizeClaudeModelOptions(model, providerOptions, caps); break; } case "cursor": { diff --git a/apps/web/src/components/chat/runtimeModelCapabilities.test.ts b/apps/web/src/components/chat/runtimeModelCapabilities.test.ts new file mode 100644 index 00000000..e6fe37b2 --- /dev/null +++ b/apps/web/src/components/chat/runtimeModelCapabilities.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from "vitest"; + +import { + getRuntimeAwareModelCapabilities, + resolveRuntimeModelDescriptor, +} from "./runtimeModelCapabilities"; + +describe("Claude runtime model capabilities", () => { + it("matches an older moving alias by the SDK-resolved model identity", () => { + const runtimeModels = [ + { + slug: "opus[1m]", + name: "Opus", + resolvedModel: "claude-opus-4-8[1m]", + supportedReasoningEfforts: [{ value: "low" }, { value: "high" }], + supportsFastMode: false, + }, + ]; + + const runtimeModel = resolveRuntimeModelDescriptor({ + provider: "claudeAgent", + model: "claude-opus-4-8", + runtimeModels, + }); + + expect(runtimeModel).toBe(runtimeModels[0]); + const capabilities = getRuntimeAwareModelCapabilities({ + provider: "claudeAgent", + model: "claude-opus-4-8", + runtimeModel, + }); + expect(capabilities.reasoningEffortLevels.map((effort) => effort.value)).toEqual([ + "low", + "high", + "ultrathink", + "ultracode", + ]); + expect(capabilities.supportsFastMode).toBe(false); + }); + + it("matches Opus 5 after the same moving alias advances", () => { + const runtimeModel = { + slug: "opus[1m]", + name: "Opus", + resolvedModel: "claude-opus-5[1m]", + supportedReasoningEfforts: [{ value: "xhigh" }], + supportsFastMode: true, + }; + + const resolvedRuntimeModel = resolveRuntimeModelDescriptor({ + provider: "claudeAgent", + model: "claude-opus-5", + runtimeModels: [runtimeModel], + }); + + expect(resolvedRuntimeModel).toBe(runtimeModel); + const capabilities = getRuntimeAwareModelCapabilities({ + provider: "claudeAgent", + model: "claude-opus-5", + runtimeModel: resolvedRuntimeModel, + }); + expect(capabilities.reasoningEffortLevels.map((effort) => effort.value)).toEqual([ + "xhigh", + "ultracode", + ]); + expect(capabilities.supportsFastMode).toBe(true); + }); +}); diff --git a/apps/web/src/components/chat/runtimeModelCapabilities.ts b/apps/web/src/components/chat/runtimeModelCapabilities.ts index c7a2f617..2a844fb9 100644 --- a/apps/web/src/components/chat/runtimeModelCapabilities.ts +++ b/apps/web/src/components/chat/runtimeModelCapabilities.ts @@ -57,7 +57,13 @@ export function resolveRuntimeModelDescriptor(input: { } return runtimeModels.find((candidate) => { - const normalizedCandidate = normalizeModelSlug(candidate.slug, provider) ?? candidate.slug; + const resolvedCandidateSlug = + provider === "claudeAgent" ? trimOrNull(candidate.resolvedModel) : null; + const candidateSlug = resolvedCandidateSlug ?? candidate.slug; + const candidateSlugWithoutContext = + provider === "claudeAgent" ? candidateSlug.replace(/\[[^\]]+\]$/u, "") : candidateSlug; + const normalizedCandidate = + normalizeModelSlug(candidateSlugWithoutContext, provider) ?? candidateSlugWithoutContext; if (normalizedCandidate === normalizedModel) { return true; } @@ -78,7 +84,10 @@ export function getRuntimeAwareModelCapabilities(input: { const staticCapabilities = getModelCapabilities(input.provider, input.model); // Runtime discovery is authoritative when available; the static table is only a startup fallback. const supportsFastMode = - (input.provider === "codex" || input.provider === "cursor") && input.runtimeModel + (input.provider === "claudeAgent" || + input.provider === "codex" || + input.provider === "cursor") && + input.runtimeModel ? input.runtimeModel.supportsFastMode === true : staticCapabilities.supportsFastMode; const supportsThinkingToggle = @@ -94,7 +103,8 @@ export function getRuntimeAwareModelCapabilities(input: { const runtimeEfforts = input.runtimeModel?.supportedReasoningEfforts; // Providers with dynamic catalogs, including Droid, expose model-specific effort ladders here. if ( - (input.provider !== "codex" && + (input.provider !== "claudeAgent" && + input.provider !== "codex" && input.provider !== "cursor" && input.provider !== "antigravity" && input.provider !== "grok" && @@ -131,6 +141,21 @@ export function getRuntimeAwareModelCapabilities(input: { }; }); + // Claude discovery owns the API-effort rows, but it cannot describe Scient's + // provider-setting or prompt-prefix controls. Preserve those static controls + // alongside the live API ladder without duplicating a value should discovery + // eventually learn about it. + const runtimeOptionValues = new Set(runtimeOptions.map((option) => option.value)); + const staticClaudeControls = + input.provider === "claudeAgent" + ? staticCapabilities.reasoningEffortLevels.filter( + (option) => + (option.controlSource === "provider-setting" || + option.controlSource === "prompt-prefix") && + !runtimeOptionValues.has(option.value), + ) + : []; + if (input.provider === "kilo" || input.provider === "opencode") { return { ...staticCapabilities, @@ -147,6 +172,6 @@ export function getRuntimeAwareModelCapabilities(input: { supportsFastMode, supportsThinkingToggle, contextWindowOptions, - reasoningEffortLevels: runtimeOptions, + reasoningEffortLevels: [...runtimeOptions, ...staticClaudeControls], }; } diff --git a/apps/web/src/composerDraftStore.test.ts b/apps/web/src/composerDraftStore.test.ts index 90e2b81e..ead5da1b 100644 --- a/apps/web/src/composerDraftStore.test.ts +++ b/apps/web/src/composerDraftStore.test.ts @@ -2690,6 +2690,37 @@ describe("composerDraftStore modelSelection", () => { }); }); + it.each(["opus", "claude-opus-5"])( + "does not restore unavailable persisted Claude model %s after runtime gating", + (model) => { + const state = deriveEffectiveComposerModelState({ + draft: { modelSelectionByProvider: {}, activeProvider: "claudeAgent" }, + selectedProvider: "claudeAgent", + threadModelSelection: modelSelection("claudeAgent", model), + projectModelSelection: null, + customModelsByProvider: { + codex: [], + claudeAgent: [], + cursor: [], + antigravity: [], + grok: [], + droid: [], + kilo: [], + opencode: [], + pi: [], + }, + availableModelOptionsByProvider: { + claudeAgent: [ + { slug: "claude-opus-4-8", name: "Claude Opus 4.8" }, + { slug: "claude-sonnet-5", name: "Claude Sonnet 5" }, + ], + }, + }); + + expect(state.selectedModel).toBe("claude-opus-4-8"); + }, + ); + it("selects Droid Auto without adding a reasoning override", () => { const state = deriveEffectiveComposerModelState({ draft: { modelSelectionByProvider: {}, activeProvider: "droid" }, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index b807a689..533790d3 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -1859,6 +1859,16 @@ export function deriveEffectiveComposerModelState(input: { } return resolveSelectableModel(input.selectedProvider, candidate, availableOptions); }; + const allowUnlistedModelFallback = (candidate: string | null | undefined): boolean => { + if ( + input.selectedProvider !== "claudeAgent" || + normalizeModelSlug(candidate, "claudeAgent") !== "claude-opus-5" + ) { + return true; + } + const availableOptions = input.availableModelOptionsByProvider?.claudeAgent; + return !availableOptions || availableOptions.length === 0; + }; const baseModel = resolveModelSlugForProvider( input.selectedProvider, (input.threadModelSelection?.provider === input.selectedProvider @@ -1912,13 +1922,13 @@ export function deriveEffectiveComposerModelState(input: { : null, ) ?? resolveAvailableModel(selectedDraftModel) ?? - persistedThreadModel ?? - persistedProjectModel ?? - unlistedDraftModel ?? - offlineDraftModel ?? + (allowUnlistedModelFallback(persistedThreadModel) ? persistedThreadModel : null) ?? + (allowUnlistedModelFallback(persistedProjectModel) ? persistedProjectModel : null) ?? + (allowUnlistedModelFallback(unlistedDraftModel) ? unlistedDraftModel : null) ?? + (allowUnlistedModelFallback(offlineDraftModel) ? offlineDraftModel : null) ?? policyModelSelection?.model ?? - selectedDraftModel ?? - baseModel ?? + (allowUnlistedModelFallback(selectedDraftModel) ? selectedDraftModel : null) ?? + (allowUnlistedModelFallback(baseModel) ? baseModel : null) ?? getDefaultModel("codex"); const modelOptions = deriveEffectiveComposerModelOptions({ ...input, diff --git a/apps/web/src/hooks/useProviderModelCatalog.ts b/apps/web/src/hooks/useProviderModelCatalog.ts index 179ec6b7..3924c46b 100644 --- a/apps/web/src/hooks/useProviderModelCatalog.ts +++ b/apps/web/src/hooks/useProviderModelCatalog.ts @@ -20,7 +20,12 @@ import { providerAgentsQueryOptions, providerModelsQueryOptions, } from "../lib/providerDiscoveryReactQuery"; -import { mergeDynamicModelOptions, type ProviderModelOption } from "../providerModelOptions"; +import { serverConfigQueryOptions } from "../lib/serverReactQuery"; +import { + filterProviderModelOptionsForRuntime, + mergeDynamicModelOptions, + type ProviderModelOption, +} from "../providerModelOptions"; export interface ProviderModelCatalog { modelOptionsByProvider: Record< @@ -61,9 +66,16 @@ export function useProviderModelCatalog(input: { const discoveryCwd = input.cwd ?? null; const { settings } = useAppSettings(); const customModelsByProvider = useMemo(() => getCustomModelsByProvider(settings), [settings]); + const serverConfigQuery = useQuery(serverConfigQueryOptions()); + const claudeProviderVersion = + serverConfigQuery.data?.providers.find((provider) => provider.provider === "claudeAgent") + ?.version ?? null; const claudeDynamicModelsQuery = useQuery( - providerModelsQueryOptions({ provider: "claudeAgent" }), + providerModelsQueryOptions({ + provider: "claudeAgent", + binaryPath: settings.claudeBinaryPath || null, + }), ); const codexDynamicModelsQuery = useQuery(providerModelsQueryOptions({ provider: "codex" })); const cursorDynamicModelsQuery = useQuery( @@ -129,6 +141,7 @@ export function useProviderModelCatalog(input: { const claudeDynamicAgentsQuery = useQuery( providerAgentsQueryOptions({ provider: "claudeAgent", + binaryPath: settings.claudeBinaryPath || null, enabled: selectedProvider === "claudeAgent", }), ); @@ -207,13 +220,20 @@ export function useProviderModelCatalog(input: { ) && isInitialModelDiscoveryPending(antigravityModelsQuery); const modelOptionsByProvider = useMemo(() => { - const staticOptions: Record> = { + const staticOptions: Record< + ProviderKind, + ReadonlyArray + > = { codex: getAppModelOptions("codex", customModelsByProvider.codex, modelHintByProvider?.codex), - claudeAgent: getAppModelOptions( - "claudeAgent", - customModelsByProvider.claudeAgent, - modelHintByProvider?.claudeAgent, - ), + claudeAgent: filterProviderModelOptionsForRuntime({ + provider: "claudeAgent", + providerVersion: claudeProviderVersion, + options: getAppModelOptions( + "claudeAgent", + customModelsByProvider.claudeAgent, + modelHintByProvider?.claudeAgent, + ), + }), cursor: getAppModelOptions( "cursor", customModelsByProvider.cursor, @@ -269,6 +289,7 @@ export function useProviderModelCatalog(input: { if (dynamicModels && dynamicModels.length > 0) { result[provider] = mergeDynamicModelOptions({ provider, + ...(provider === "claudeAgent" ? { providerVersion: claudeProviderVersion } : {}), staticOptions: staticOptions[provider], dynamicModels, }); @@ -278,6 +299,7 @@ export function useProviderModelCatalog(input: { return result; }, [ claudeDynamicModelsQuery.data, + claudeProviderVersion, antigravityModelsQuery.data, codexDynamicModelsQuery.data, cursorDynamicModelsQuery.data, diff --git a/apps/web/src/lib/providerDiscoveryReactQuery.test.ts b/apps/web/src/lib/providerDiscoveryReactQuery.test.ts index db62e897..39485555 100644 --- a/apps/web/src/lib/providerDiscoveryReactQuery.test.ts +++ b/apps/web/src/lib/providerDiscoveryReactQuery.test.ts @@ -27,6 +27,13 @@ function mockListModels(listModels: ReturnType) { return listModels; } +function mockListAgents(listAgents: ReturnType) { + vi.spyOn(nativeApi, "ensureNativeApi").mockReturnValue({ + provider: { listAgents }, + } as unknown as NativeApi); + return listAgents; +} + afterEach(() => { vi.restoreAllMocks(); }); @@ -66,6 +73,24 @@ describe("isInitialModelDiscoveryPending", () => { }); describe("providerModelsQueryOptions", () => { + it("scopes Claude discovery and its cache identity to the configured executable", async () => { + const listModels = mockListModels(vi.fn().mockResolvedValue({ models: [] })); + const configuredOptions = providerModelsQueryOptions({ + provider: "claudeAgent", + binaryPath: "/opt/claude-custom", + }); + const pathOptions = providerModelsQueryOptions({ provider: "claudeAgent" }); + + expect(configuredOptions.queryKey).not.toEqual(pathOptions.queryKey); + + const queryClient = new QueryClient(); + await queryClient.fetchQuery(configuredOptions); + expect(listModels).toHaveBeenCalledWith({ + provider: "claudeAgent", + binaryPath: "/opt/claude-custom", + }); + }); + it("fails fast for Cursor so a missing CLI settles instead of spinning (#103)", async () => { const listModels = mockListModels( vi.fn().mockRejectedValue(new Error("Cursor CLI is not installed or not on PATH")), @@ -161,3 +186,23 @@ describe("providerModelsQueryOptions", () => { await expect(queryClient.fetchQuery(options)).resolves.toEqual(catalog); }); }); + +describe("providerAgentsQueryOptions", () => { + it("scopes Claude subagents and their cache identity to the configured executable", async () => { + const listAgents = mockListAgents(vi.fn().mockResolvedValue({ agents: [] })); + const configuredOptions = providerAgentsQueryOptions({ + provider: "claudeAgent", + binaryPath: "/opt/claude-custom", + }); + const pathOptions = providerAgentsQueryOptions({ provider: "claudeAgent" }); + + expect(configuredOptions.queryKey).not.toEqual(pathOptions.queryKey); + + const queryClient = new QueryClient(); + await queryClient.fetchQuery(configuredOptions); + expect(listAgents).toHaveBeenCalledWith({ + provider: "claudeAgent", + binaryPath: "/opt/claude-custom", + }); + }); +}); diff --git a/apps/web/src/lib/providerModelPrefetch.test.ts b/apps/web/src/lib/providerModelPrefetch.test.ts index 7ad835cf..bc0b41a4 100644 --- a/apps/web/src/lib/providerModelPrefetch.test.ts +++ b/apps/web/src/lib/providerModelPrefetch.test.ts @@ -25,6 +25,7 @@ function makeSettings( ): ProviderModelPrefetchSettings { return { defaultProvider: "codex", + claudeBinaryPath: "", cursorBinaryPath: "", cursorApiEndpoint: "", antigravityBinaryPath: "", @@ -105,6 +106,7 @@ describe("resolveNewThreadModelPrefetchCwd", () => { describe("providerModelsPrefetchQueryOptions", () => { it("matches ChatView cache keys for cwd-scoped and binary-scoped providers", () => { const settings = makeSettings({ + claudeBinaryPath: "/bin/claude-custom", cursorBinaryPath: "/bin/agent", cursorApiEndpoint: "https://api.example", antigravityBinaryPath: "/bin/antigravity", @@ -113,6 +115,14 @@ describe("providerModelsPrefetchQueryOptions", () => { piAgentDir: "/tmp/pi-agent", }); + const claudeOptions = providerModelsPrefetchQueryOptions({ + provider: "claudeAgent", + settings, + }); + expect(claudeOptions.queryKey).toEqual( + providerDiscoveryQueryKeys.models("claudeAgent", "/bin/claude-custom", null, null, null), + ); + const cursorOptions = providerModelsPrefetchQueryOptions({ provider: "cursor", settings, @@ -165,6 +175,24 @@ describe("providerModelsPrefetchQueryOptions", () => { }); describe("prefetchProviderModelsForNewThread", () => { + it("prefetches Claude models and subagents from the same configured executable", async () => { + const queryClient = new QueryClient(); + const prefetchQuery = vi.spyOn(queryClient, "prefetchQuery").mockResolvedValue(undefined); + + prefetchProviderModelsForNewThread(queryClient, { + provider: "claudeAgent", + settings: makeSettings({ claudeBinaryPath: "/bin/claude-custom" }), + }); + + expect(prefetchQuery).toHaveBeenCalledTimes(2); + expect(prefetchQuery.mock.calls[0]?.[0].queryKey).toEqual( + providerDiscoveryQueryKeys.models("claudeAgent", "/bin/claude-custom", null, null, null), + ); + expect(prefetchQuery.mock.calls[1]?.[0].queryKey).toEqual( + providerDiscoveryQueryKeys.agents("claudeAgent", "/bin/claude-custom", null), + ); + }); + it("prefetches models and agents for the resolved provider", async () => { const queryClient = new QueryClient(); const prefetchQuery = vi.spyOn(queryClient, "prefetchQuery").mockResolvedValue(undefined); diff --git a/apps/web/src/lib/providerModelPrefetch.ts b/apps/web/src/lib/providerModelPrefetch.ts index db7075f1..e31c2b5c 100644 --- a/apps/web/src/lib/providerModelPrefetch.ts +++ b/apps/web/src/lib/providerModelPrefetch.ts @@ -18,6 +18,7 @@ import { export type ProviderModelPrefetchSettings = Pick< AppSettings, | "defaultProvider" + | "claudeBinaryPath" | "cursorBinaryPath" | "cursorApiEndpoint" | "antigravityBinaryPath" @@ -70,7 +71,10 @@ export function providerModelsPrefetchQueryOptions(input: { switch (provider) { case "claudeAgent": - return providerModelsQueryOptions({ provider: "claudeAgent" }); + return providerModelsQueryOptions({ + provider: "claudeAgent", + binaryPath: settings.claudeBinaryPath || null, + }); case "codex": return providerModelsQueryOptions({ provider: "codex" }); case "cursor": @@ -128,7 +132,10 @@ function providerAgentsPrefetchQueryOptions(input: { switch (provider) { case "claudeAgent": - return providerAgentsQueryOptions({ provider: "claudeAgent" }); + return providerAgentsQueryOptions({ + provider: "claudeAgent", + binaryPath: settings.claudeBinaryPath || null, + }); case "codex": return providerAgentsQueryOptions({ provider: "codex" }); case "kilo": diff --git a/apps/web/src/providerModelOptions.test.ts b/apps/web/src/providerModelOptions.test.ts index 18b74427..13d9627c 100644 --- a/apps/web/src/providerModelOptions.test.ts +++ b/apps/web/src/providerModelOptions.test.ts @@ -157,6 +157,7 @@ describe("mergeDynamicModelOptions", () => { expect( mergeDynamicModelOptions({ provider: "claudeAgent", + providerVersion: "2.1.219", staticOptions: [], dynamicModels: [ { @@ -193,6 +194,80 @@ describe("mergeDynamicModelOptions", () => { }, ]); }); + + it("uses Claude SDK resolution before a moving provider alias", () => { + expect( + mergeDynamicModelOptions({ + provider: "claudeAgent", + providerVersion: "2.1.219", + staticOptions: [], + dynamicModels: [ + { + slug: "opus[1m]", + name: "Opus", + resolvedModel: "claude-opus-5[1m]", + }, + { + slug: "opus", + name: "Opus fallback", + }, + ], + }), + ).toEqual([ + { + slug: "claude-opus-5", + name: "Opus", + resolvedModel: "claude-opus-5[1m]", + }, + ]); + }); + + it.each([undefined, "2.1.218"])( + "hides Opus 5 when Claude Code %s cannot support it", + (providerVersion) => { + expect( + mergeDynamicModelOptions({ + provider: "claudeAgent", + providerVersion, + staticOptions: [ + { slug: "claude-opus-5", name: "Claude Opus 5" }, + { slug: "claude-opus-4-8", name: "Claude Opus 4.8" }, + ], + dynamicModels: [ + { + slug: "opus[1m]", + name: "Opus", + resolvedModel: "claude-opus-4-8[1m]", + }, + ], + }), + ).toEqual([ + { + slug: "claude-opus-4-8", + name: "Claude Opus 4.8", + resolvedModel: "claude-opus-4-8[1m]", + }, + ]); + }, + ); + + it("includes the Opus 5 fallback at the minimum Claude Code version", () => { + expect( + mergeDynamicModelOptions({ + provider: "claudeAgent", + providerVersion: "2.1.219", + staticOptions: [{ slug: "claude-opus-5", name: "Claude Opus 5" }], + dynamicModels: [{ slug: "sonnet", name: "Sonnet", resolvedModel: "claude-sonnet-5" }], + }), + ).toEqual([ + { + slug: "claude-sonnet-5", + name: "Claude Sonnet 5", + resolvedModel: "claude-sonnet-5", + }, + { slug: "claude-opus-5", name: "Claude Opus 5" }, + ]); + }); }); describe("providerModelCostMultiplierLabel", () => { diff --git a/apps/web/src/providerModelOptions.ts b/apps/web/src/providerModelOptions.ts index d771cf76..102bf459 100644 --- a/apps/web/src/providerModelOptions.ts +++ b/apps/web/src/providerModelOptions.ts @@ -3,6 +3,7 @@ import { humanizeModelSlug, normalizeModelSlug, } from "@synara/shared/model"; +import { isClaudeOpus5RuntimeSupported } from "@synara/shared/providerVersions"; import type { AntigravityModelOptions, AntigravityModelSelection, @@ -50,6 +51,34 @@ export interface ProviderModelOptionGroup { options: ProviderModelOption[]; } +function providerModelIsSupportedByRuntime(input: { + provider: ProviderKind; + slug: string; + providerVersion?: string | null | undefined; +}): boolean { + if ( + input.provider !== "claudeAgent" || + normalizeDynamicModelSlug(input.provider, input.slug) !== "claude-opus-5" + ) { + return true; + } + return isClaudeOpus5RuntimeSupported(input.providerVersion); +} + +export function filterProviderModelOptionsForRuntime(input: { + provider: ProviderKind; + providerVersion?: string | null | undefined; + options: ReadonlyArray; +}): ReadonlyArray { + return input.options.filter((option) => + providerModelIsSupportedByRuntime({ + provider: input.provider, + slug: option.slug, + providerVersion: input.providerVersion, + }), + ); +} + function modelOptionKey(option: Pick): string { return option.slug.trim().toLowerCase(); } @@ -115,6 +144,7 @@ function normalizeDynamicModelSlug(provider: ProviderKind, slug: string): string */ export function mergeDynamicModelOptions(input: { provider: ProviderKind; + providerVersion?: string | null | undefined; staticOptions: ReadonlyArray; dynamicModels: ReadonlyArray<{ slug: string; @@ -133,7 +163,12 @@ export function mergeDynamicModelOptions(input: { | undefined; }>; }): ReadonlyArray { - const staticNameBySlug = new Map(input.staticOptions.map((model) => [model.slug, model.name])); + const eligibleStaticOptions = filterProviderModelOptionsForRuntime({ + provider: input.provider, + providerVersion: input.providerVersion, + options: input.staticOptions, + }); + const staticNameBySlug = new Map(eligibleStaticOptions.map((model) => [model.slug, model.name])); const claudeResolvedDefaultSlug = input.provider === "claudeAgent" ? input.dynamicModels @@ -159,7 +194,23 @@ export function mergeDynamicModelOptions(input: { continue; } - const normalizedSlug = normalizeDynamicModelSlug(input.provider, dynamicModel.slug); + // Claude's moving aliases (for example, `opus`) are resolved by the SDK for + // the installed CLI. Prefer that exact model identity so an older catalog row + // cannot be relabeled when Scient advances the fallback alias. + const slugToNormalize = + input.provider === "claudeAgent" && dynamicModel.resolvedModel?.trim() + ? dynamicModel.resolvedModel + : dynamicModel.slug; + const normalizedSlug = normalizeDynamicModelSlug(input.provider, slugToNormalize); + if ( + !providerModelIsSupportedByRuntime({ + provider: input.provider, + slug: normalizedSlug, + providerVersion: input.providerVersion, + }) + ) { + continue; + } const isDefault = dynamicModel.isDefault === true || normalizedSlug === normalizedClaudeResolvedDefaultSlug; const rawSlug = dynamicModel.slug.trim().toLowerCase(); @@ -202,13 +253,13 @@ export function mergeDynamicModelOptions(input: { const customOnlyModels = input.provider === "droid" ? [] - : input.staticOptions.filter( + : eligibleStaticOptions.filter( (model) => "isCustom" in model && model.isCustom && !dynamicNormalizedSlugs.has(normalizeDynamicModelSlug(input.provider, model.slug)), ); - const staticBuiltInModels = input.staticOptions.filter( + const staticBuiltInModels = eligibleStaticOptions.filter( (model) => !("isCustom" in model) || model.isCustom !== true, ); const missingStaticBuiltIns = diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 18fb3e50..0cea3d68 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -402,6 +402,13 @@ const CLAUDE_NO_FAST_XHIGH_CAPABILITIES: ModelCapabilities = { const CLAUDE_FABLE_CAPABILITIES: ModelCapabilities = CLAUDE_NO_FAST_XHIGH_CAPABILITIES; +// Opus 5 keeps the Claude 5 ladder (thinking is adaptive, so no ultrathink prompt +// mode) but stays on the Opus fast-mode lane that Fable and Sonnet lack. +const CLAUDE_OPUS_5_CAPABILITIES: ModelCapabilities = { + ...CLAUDE_NO_FAST_XHIGH_CAPABILITIES, + supportsFastMode: true, +}; + // Full reasoning ladder: xhigh + ultracode + ultrathink (Opus 4.7/4.8). const CLAUDE_FLAGSHIP_CAPABILITIES: ModelCapabilities = { reasoningEffortLevels: [ @@ -505,6 +512,11 @@ export const MODEL_OPTIONS_BY_PROVIDER = { name: "Claude Fable 5", capabilities: CLAUDE_FABLE_CAPABILITIES, }, + { + slug: "claude-opus-5", + name: "Claude Opus 5", + capabilities: CLAUDE_OPUS_5_CAPABILITIES, + }, { slug: "claude-opus-4-8", name: "Claude Opus 4.8", @@ -916,7 +928,11 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Record { it("uses provider-specific aliases", () => { expect(normalizeModelSlug("sonnet", "claudeAgent")).toBe("claude-sonnet-5"); + expect(normalizeModelSlug("opus", "claudeAgent")).toBe("claude-opus-5"); + expect(normalizeModelSlug("opus-5", "claudeAgent")).toBe("claude-opus-5"); + expect(normalizeModelSlug("claude-opus-5", "claudeAgent")).toBe("claude-opus-5"); + expect(normalizeModelSlug("claude-opus-5.0", "claudeAgent")).toBe("claude-opus-5"); + expect(normalizeModelSlug("claude-opus-5-0", "claudeAgent")).toBe("claude-opus-5"); + expect(normalizeModelSlug("opus-4.8", "claudeAgent")).toBe("claude-opus-4-8"); expect(normalizeModelSlug("sonnet-4.6", "claudeAgent")).toBe("claude-sonnet-4-6"); expect(normalizeModelSlug("opus-4.6", "claudeAgent")).toBe("claude-opus-4-6"); expect(normalizeModelSlug("claude-haiku-4-5-20251001", "claudeAgent")).toBe("claude-haiku-4-5"); @@ -364,6 +371,17 @@ describe("getModelCapabilities reasoningEffortLevels", () => { ]); }); + it("returns claude effort options for Opus 5", () => { + expect(values("claudeAgent", "claude-opus-5")).toEqual([ + "low", + "medium", + "high", + "xhigh", + "max", + "ultracode", + ]); + }); + it("returns claude effort options for Opus 4.7", () => { expect(values("claudeAgent", "claude-opus-4-7")).toEqual([ "low", @@ -593,6 +611,9 @@ describe("context window helpers", () => { expect(getModelCapabilities("claudeAgent", "claude-opus-4-5").contextWindowTokens).toBe( 200_000, ); + const opus5Caps = getModelCapabilities("claudeAgent", "claude-opus-5"); + expect(opus5Caps.contextWindowTokens).toBe(1_000_000); + expect(getDefaultAutoCompactWindow(opus5Caps)).toBe("200k"); expect(getDefaultContextWindow(getModelCapabilities("codex", "gpt-5.4"))).toBeNull(); }); @@ -624,6 +645,7 @@ describe("formatModelDisplayName", () => { it("returns built-in display names for known models", () => { expect(formatModelDisplayName("gpt-5.3-codex")).toBe("GPT-5.3 Codex"); expect(formatModelDisplayName("claude-sonnet-5")).toBe("Claude Sonnet 5"); + expect(formatModelDisplayName("claude-opus-5")).toBe("Claude Opus 5"); }); it("humanizes unknown GPT model slugs", () => { @@ -713,6 +735,30 @@ describe("normalizeClaudeModelOptions", () => { }); }); + it("keeps Opus 5 xhigh, ultracode, and fast mode while dropping ultrathink", () => { + expect( + normalizeClaudeModelOptions("claude-opus-5", { + effort: "xhigh", + fastMode: true, + }), + ).toEqual({ + effort: "xhigh", + fastMode: true, + }); + expect( + normalizeClaudeModelOptions("claude-opus-5", { + effort: "ultracode", + }), + ).toEqual({ + effort: "ultracode", + }); + expect( + normalizeClaudeModelOptions("claude-opus-5", { + effort: "ultrathink", + }), + ).toBeUndefined(); + }); + it("drops unsupported fast mode for Sonnet while preserving max effort", () => { expect( normalizeClaudeModelOptions("claude-sonnet-4-6", { @@ -736,6 +782,54 @@ describe("normalizeClaudeModelOptions", () => { }); }); +describe("normalizeClaudeModelSelectionForRuntime", () => { + it.each([ + ["opus", "claude-opus-4-8"], + ["claude-opus-5", "claude-opus-4-8"], + ["opus[1m]", "claude-opus-4-8[1m]"], + ])( + "falls back persisted %s selections when the Claude runtime is too old", + (model, expectedModel) => { + expect( + normalizeClaudeModelSelectionForRuntime( + { + provider: "claudeAgent", + model, + options: { effort: "ultracode", fastMode: true }, + }, + "2.1.218", + ), + ).toEqual({ + provider: "claudeAgent", + model: expectedModel, + options: { effort: "ultracode", fastMode: true }, + }); + }, + ); + + it("fails closed to Opus 4.8 when the Claude runtime version is unknown", () => { + expect( + normalizeClaudeModelSelectionForRuntime( + { provider: "claudeAgent", model: "claude-opus-5" }, + null, + ), + ).toEqual({ provider: "claudeAgent", model: "claude-opus-4-8" }); + }); + + it("preserves Opus 5 and its native context suffix at the minimum supported runtime", () => { + expect( + normalizeClaudeModelSelectionForRuntime( + { provider: "claudeAgent", model: "opus[1m]", options: { fastMode: true } }, + "2.1.219", + ), + ).toEqual({ + provider: "claudeAgent", + model: "claude-opus-5[1m]", + options: { fastMode: true }, + }); + }); +}); + describe("resolveApiModelId", () => { it("keeps native-1M Claude model ids unchanged", () => { expect( @@ -1002,6 +1096,7 @@ describe("getModelCapabilities Claude capability flags", () => { it("enables adaptive reasoning for supported Claude models", () => { const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).reasoningEffortLevels.length > 0; + expect(has("claude-opus-5")).toBe(true); expect(has("claude-opus-4-8")).toBe(true); expect(has("claude-opus-4-7")).toBe(true); expect(has("claude-opus-4-6")).toBe(true); @@ -1014,6 +1109,7 @@ describe("getModelCapabilities Claude capability flags", () => { it("enables max effort for supported Claude models", () => { const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).reasoningEffortLevels.some((l) => l.value === "max"); + expect(has("claude-opus-5")).toBe(true); expect(has("claude-opus-4-8")).toBe(true); expect(has("claude-opus-4-7")).toBe(true); expect(has("claude-opus-4-6")).toBe(true); @@ -1023,8 +1119,9 @@ describe("getModelCapabilities Claude capability flags", () => { expect(has(undefined)).toBe(false); }); - it("only enables Claude fast mode for Opus 4.6", () => { + it("enables Claude fast mode only for supported Opus models", () => { const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).supportsFastMode; + expect(has("claude-opus-5")).toBe(true); expect(has("claude-opus-4-8")).toBe(true); expect(has("claude-opus-4-7")).toBe(true); expect(has("claude-opus-4-6")).toBe(true); @@ -1035,10 +1132,11 @@ describe("getModelCapabilities Claude capability flags", () => { expect(has(undefined)).toBe(false); }); - it("only enables ultrathink keyword handling for Opus 4.6 and Sonnet 4.6", () => { + it("enables ultrathink keyword handling only for supported legacy models", () => { const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).promptInjectedEffortLevels.includes("ultrathink"); expect(has("claude-fable-5")).toBe(false); + expect(has("claude-opus-5")).toBe(false); expect(has("claude-opus-4-8")).toBe(true); expect(has("claude-opus-4-7")).toBe(true); expect(has("claude-opus-4-6")).toBe(true); @@ -1050,6 +1148,7 @@ describe("getModelCapabilities Claude capability flags", () => { it("only enables the Claude thinking toggle for Haiku 4.5", () => { const has = (m: string | undefined) => getModelCapabilities("claudeAgent", m).supportsThinkingToggle; + expect(has("claude-opus-5")).toBe(false); expect(has("claude-opus-4-6")).toBe(false); expect(has("claude-sonnet-5")).toBe(false); expect(has("claude-sonnet-4-6")).toBe(false); diff --git a/packages/shared/src/model.ts b/packages/shared/src/model.ts index 3862c982..a5a6e256 100644 --- a/packages/shared/src/model.ts +++ b/packages/shared/src/model.ts @@ -25,6 +25,7 @@ import { type ProviderWithDefaultModel, CodexReasoningEffort, } from "@synara/contracts"; +import { isClaudeOpus5RuntimeSupported } from "./providerVersions"; const MODEL_SLUG_SET_BY_PROVIDER: Record> = { claudeAgent: new Set(MODEL_OPTIONS_BY_PROVIDER.claudeAgent.map((option) => option.slug)), @@ -707,8 +708,9 @@ export function normalizeCodexModelOptions( export function normalizeClaudeModelOptions( model: string | null | undefined, modelOptions: ClaudeModelOptions | null | undefined, + runtimeCapabilities?: ModelCapabilities | undefined, ): ClaudeModelOptions | undefined { - const caps = getModelCapabilities("claudeAgent", model); + const caps = runtimeCapabilities ?? getModelCapabilities("claudeAgent", model); const defaultReasoningEffort = getDefaultEffort(caps); const defaultAutoCompactWindow = getDefaultAutoCompactWindow(caps); const resolvedEffort = trimOrNull(modelOptions?.effort); @@ -740,6 +742,25 @@ export function normalizeClaudeModelOptions( return Object.keys(nextOptions).length > 0 ? nextOptions : undefined; } +export function normalizeClaudeModelSelectionForRuntime( + modelSelection: Extract, + providerVersion: string | null | undefined, +): Extract { + const contextWindowSuffix = modelSelection.model.trim().match(/\[[^\]]+\]$/u)?.[0] ?? ""; + const normalizedModel = + normalizeModelSlug(modelSelection.model, "claudeAgent") ?? getDefaultModel("claudeAgent"); + const runtimeModel = + normalizedModel === "claude-opus-5" && !isClaudeOpus5RuntimeSupported(providerVersion) + ? "claude-opus-4-8" + : normalizedModel; + const model = `${runtimeModel}${contextWindowSuffix}`; + return { + provider: "claudeAgent", + model, + ...(modelSelection.options ? { options: modelSelection.options } : {}), + }; +} + export function resolveApiModelId(modelSelection: ModelSelection): string { return modelSelection.model; } diff --git a/packages/shared/src/providerVersions.ts b/packages/shared/src/providerVersions.ts index c102ca76..af0afb20 100644 --- a/packages/shared/src/providerVersions.ts +++ b/packages/shared/src/providerVersions.ts @@ -5,6 +5,8 @@ const SEMVER_NUMBER_SEGMENT = /^\d+$/u; const STABLE_SEMVER = /^\d+\.\d+\.\d+$/u; +export const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; + interface ParsedSemver { readonly major: number; readonly minor: number; @@ -90,3 +92,8 @@ export function compareSemverVersions(left: string, right: string): number { } return 0; } + +export function isClaudeOpus5RuntimeSupported(version: string | null | undefined): boolean { + if (typeof version !== "string" || parseSemver(version) === null) return false; + return compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0; +}