From 21d618f7e0aa330afb7266ff8d998dd67e1b4c89 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 17:27:05 -0500 Subject: [PATCH 01/13] fix(server): Claude on Bedrock runs the model picked in the composer Claude Code on Bedrock, Vertex and similar backends does not run the first-party slug T3 sends. With an availableModels allowlist it silently substitutes the default model, so picking Haiku ran Opus. The capability probe now keeps the model list Claude Code reports. When a row resolves to a backend id for a catalog model, such as global.anthropic.claude-haiku-5-5 for claude-haiku-5-5, turns and text generation send that row's id (here the `haiku` alias) instead of the slug. First-party ids are unchanged. Fixes #24214 Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 44 +++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 24 ++++++- .../src/provider/ClaudeModelCatalog.test.ts | 64 ++++++++++++++++++- .../server/src/provider/ClaudeModelCatalog.ts | 53 ++++++++++++++- apps/server/src/provider/ClaudeProvider.ts | 3 + .../src/provider/Drivers/ClaudeDriver.ts | 23 +++++-- 6 files changed, 202 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 0b6232479c40a..3d9a49c2bb9ee 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -62,6 +62,7 @@ import * as Option from "effect/Option"; import * as Path from "effect/Path"; import * as PubSub from "effect/PubSub"; import * as Queue from "effect/Queue"; +import * as Ref from "effect/Ref"; import * as Schema from "effect/Schema"; import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; @@ -85,6 +86,7 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; +import type { ClaudeCodeReportedModel } from "../../provider/ClaudeModelCatalog.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -2154,6 +2156,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly freshQueueOnReopen?: boolean; readonly forkSession?: ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerShape["forkSession"]; readonly allocateSessionId?: Effect.Effect; + readonly claudeCodeModels?: Ref.Ref>; }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -2212,6 +2215,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { path: yield* Path.Path, crypto: yield* Crypto.Crypto, idAllocator, + ...(options?.claudeCodeModels ? { claudeCodeModels: options.claudeCodeModels } : {}), continuationRequests: { offer: (request) => Effect.sync(() => { @@ -3237,6 +3241,46 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("sends the model id Claude Code reported for the selected catalog model", () => + Effect.scoped( + Effect.gen(function* () { + const claudeCodeModels = yield* Ref.make>([]); + const harness = yield* makeWakeHarnessWithOptions({ claudeCodeModels }); + const now = yield* DateTime.now; + const turn = (ordinal: number, model: string) => + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now, + attemptId: RunAttemptId.make(`attempt-reported-model:${ordinal}`), + text: `Request ${ordinal}`, + attachments: [], + providerTurnOrdinal: ordinal, + modelSelection: { instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, model }, + }); + // Bedrock with ANTHROPIC_DEFAULT_*_MODEL set, as Claude Code 2.1.310 reports it. + yield* Ref.set(claudeCodeModels, [ + { value: "default", resolvedModel: "global.anthropic.claude-opus-5-5" }, + { value: "sonnet", resolvedModel: "global.anthropic.claude-sonnet-5-5[1m]" }, + { value: "opus", resolvedModel: "global.anthropic.claude-opus-5-5" }, + { value: "haiku", resolvedModel: "global.anthropic.claude-haiku-5-5" }, + ]); + + yield* harness.runtime.startTurn(turn(1, "claude-haiku-5-5")); + assert.equal(harness.getOpenedOptions()?.model, "haiku"); + yield* harness.offerAndWait(turnOneResult); + yield* Queue.take(harness.terminalReceipts); + + yield* harness.runtime.startTurn(turn(2, "claude-sonnet-5-5")); + assert.deepEqual(harness.liveSelectionChanges[0], { model: "sonnet" }); + }).pipe( + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ), + ); + it.effect( "applies model, effort and Fast changes to a process running a background shell, but not thinking or a cleared effort", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 792195ebfa80e..b40cac1941613 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -148,10 +148,12 @@ import { import { BUNDLED_CLAUDE_MODEL_CATALOG, resolveClaudeCatalogContextWindowTokens, + type ClaudeCodeReportedModel, type ClaudeModelCatalog, getClaudeCatalogModelCapabilities, resolveClaudeModelSlug, scopeClaudeModelCatalog, + withClaudeCodeModelRoutes, } from "../../provider/ClaudeModelCatalog.ts"; import { boundProviderEventForLogging, @@ -1298,6 +1300,7 @@ export function claudeQueryEnvironment( export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; + readonly modelCatalog?: ClaudeModelCatalog; readonly nativeThreadId: string; readonly resume: boolean; readonly resumeSessionAt?: string; @@ -1317,7 +1320,7 @@ export function makeClaudeQueryOptions(input: { readonly allowDangerouslySkipPermissions?: boolean; readonly appendSystemPromptFileText?: string; }): ClaudeAgentSdkQueryOptions { - const compiledSelection = compileClaudeModelSelection(input.modelSelection); + const compiledSelection = compileClaudeModelSelection(input.modelSelection, input.modelCatalog); const { "permission-mode": launchArgPermissionMode, "dangerously-skip-permissions": launchArgSkipPermissions, @@ -3912,6 +3915,8 @@ export interface ClaudeAdapterV2Options { readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; readonly scopedLimitNames?: Ref.Ref; + /** Claude Code's model list from the provider probe, so turns send ids it accepts. */ + readonly claudeCodeModels?: Ref.Ref>; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; readonly continuationRequests?: { readonly offer: ( @@ -9222,7 +9227,16 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : { allowedTools: queryPolicy.allowedTools }), }); const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); - const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection); + const modelCatalog = adapterOptions.claudeCodeModels + ? withClaudeCodeModelRoutes( + BUNDLED_CLAUDE_MODEL_CATALOG, + yield* Ref.get(adapterOptions.claudeCodeModels), + ) + : BUNDLED_CLAUDE_MODEL_CATALOG; + const compiledSelection = compileClaudeModelSelection( + turnInput.modelSelection, + modelCatalog, + ); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); // A continuation prompts nothing: it drains output the live process @@ -9323,6 +9337,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, + modelCatalog, nativeThreadId, resume: shouldResume, ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), @@ -10520,7 +10535,10 @@ export type ClaudeAdapterV2DriverEnv = export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( function* ( input: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick< + ClaudeAdapterV2Options, + "scopedLimitNames" | "claudeCodeModels" | "onUsageLimits" + > = {}, ) { const { instanceId, environment, enabled, config } = input; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index 2092429f6d556..b439d529b611a 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -1,8 +1,10 @@ import { assert, describe, it } from "@effect/vitest"; -import { ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import { type ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts"; import { + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, formatClaudeVersionUpgradeMessage, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, @@ -13,6 +15,7 @@ import { resolveClaudeModelSlug, resolveClaudeUpdateRequiredModels, scopeClaudeModelCatalog, + withClaudeCodeModelRoutes, } from "./ClaudeModelCatalog.ts"; const manifest = (): ModelManifestData => ({ @@ -222,4 +225,63 @@ describe("Claude model catalog", () => { ["claude-synthetic-next", "claude-custom-tuned"], ); }); + + // Rows Claude Code 2.1.310 reports from initialization for each configuration. + it("sends the model id Claude Code reports when it serves a catalog model under another id", () => { + const apiModel = ( + catalog: ClaudeModelCatalog, + model: string, + options?: ModelSelection["options"], + ) => + resolveClaudeCatalogApiModelId(catalog, { + instanceId: ProviderInstanceId.make("claudeAgent"), + model, + ...(options ? { options } : {}), + }); + + // Bedrock with ANTHROPIC_DEFAULT_*_MODEL and an availableModels allowlist of their ids. + const configuredBedrock = withClaudeCodeModelRoutes(BUNDLED_CLAUDE_MODEL_CATALOG, [ + { value: "default", resolvedModel: "global.anthropic.claude-opus-5-5" }, + { value: "sonnet", resolvedModel: "global.anthropic.claude-sonnet-5-5[1m]" }, + { value: "opus", resolvedModel: "global.anthropic.claude-opus-5-5" }, + { value: "haiku", resolvedModel: "global.anthropic.claude-haiku-5-5" }, + ]); + assert.strictEqual(apiModel(configuredBedrock, "claude-haiku-5-5"), "haiku"); + assert.strictEqual(apiModel(configuredBedrock, "haiku-5.5"), "haiku"); + assert.strictEqual(apiModel(configuredBedrock, "claude-sonnet-5-5"), "sonnet"); + assert.strictEqual(apiModel(configuredBedrock, "claude-opus-5-5"), "opus"); + assert.strictEqual(apiModel(configuredBedrock, "claude-opus-5"), "claude-opus-5"); + + // Bedrock with no model settings: Claude Code's `haiku` is Haiku 4.5 there. + const defaultBedrock = withClaudeCodeModelRoutes(BUNDLED_CLAUDE_MODEL_CATALOG, [ + { + value: "us.anthropic.claude-opus-4-6-v1", + resolvedModel: "us.anthropic.claude-opus-4-6-v1", + }, + { + value: "us.anthropic.claude-opus-4-6-v1[1m]", + resolvedModel: "us.anthropic.claude-opus-4-6-v1[1m]", + }, + { value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }, + ]); + assert.strictEqual( + apiModel(defaultBedrock, "claude-opus-4-6", [{ id: "contextWindow", value: "1m" }]), + "us.anthropic.claude-opus-4-6-v1[1m]", + ); + assert.strictEqual( + apiModel(defaultBedrock, "claude-opus-4-6", [{ id: "contextWindow", value: "200k" }]), + "us.anthropic.claude-opus-4-6-v1", + ); + assert.strictEqual(apiModel(defaultBedrock, "claude-haiku-4-5"), "haiku"); + assert.strictEqual(apiModel(defaultBedrock, "claude-haiku-5-5"), "claude-haiku-5-5"); + + // First-party ids already are what Claude Code runs, even when an alias points elsewhere. + const firstParty = withClaudeCodeModelRoutes(BUNDLED_CLAUDE_MODEL_CATALOG, [ + { value: "default", resolvedModel: "claude-opus-5-5" }, + { value: "opus", resolvedModel: "claude-opus-5-5" }, + { value: "claude-fable-5-1[1m]", resolvedModel: "claude-fable-5-1[1m]" }, + { value: "haiku", resolvedModel: "claude-haiku-4-5-20251001" }, + ]); + assert.strictEqual(firstParty, BUNDLED_CLAUDE_MODEL_CATALOG); + }); }); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index db409077756a3..75479f14749ec 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -31,6 +31,14 @@ export interface ClaudeCatalogModel { readonly model: ServerProviderModel; readonly runtime: ClaudeCodeProfile; readonly compatibility: ClaudeCodeCompatibility; + /** What to send Claude Code instead of the slug, from its own model list. */ + readonly apiModel?: string; +} + +/** A row of Claude Code's own model list, as initialization reports it. */ +export interface ClaudeCodeReportedModel { + readonly value: string; + readonly resolvedModel?: string | undefined; } export interface ClaudeModelCatalog { @@ -130,6 +138,49 @@ function resolveClaudeCatalogModel( ); } +const CLAUDE_CODE_MODEL_SUFFIX = /\[[^\]]*\]$/; + +// Cloud backends decorate first-party ids: Bedrock adds a region prefix and a +// version ("us.anthropic.claude-opus-4-6-v1"), Vertex adds "@". +function stripClaudeBackendDecoration(wireModel: string): string { + return wireModel + .replace(/^(?:[a-z-]+\.)?anthropic\./, "") + .replace(/-v\d+(?::\d+)?$/, "") + .replace(/@[^@]*$/, ""); +} + +/** + * On Bedrock, Vertex and similar backends Claude Code does not accept the first-party slug: it + * either sends it verbatim or, when `availableModels` excludes it, silently runs the default + * model instead. Its model list names the id that does work, often a family alias that + * `ANTHROPIC_DEFAULT_*_MODEL` points at a backend id. + */ +export function withClaudeCodeModelRoutes( + catalog: ClaudeModelCatalog, + reportedModels: ReadonlyArray, +): ClaudeModelCatalog { + const apiModels = new Map(); + for (const reported of reportedModels) { + if (reported.value === "default") continue; + const wireModel = (reported.resolvedModel ?? reported.value).replace( + CLAUDE_CODE_MODEL_SUFFIX, + "", + ); + const firstPartyModel = stripClaudeBackendDecoration(wireModel); + if (firstPartyModel === wireModel) continue; + const entry = resolveClaudeCatalogModel(catalog, firstPartyModel); + if (!entry || apiModels.has(entry.model.slug)) continue; + apiModels.set(entry.model.slug, reported.value.replace(CLAUDE_CODE_MODEL_SUFFIX, "")); + } + if (apiModels.size === 0) return catalog; + return { + models: catalog.models.map((entry) => { + const apiModel = apiModels.get(entry.model.slug); + return apiModel ? { ...entry, apiModel } : entry; + }), + }; +} + export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; } @@ -261,7 +312,7 @@ export function resolveClaudeCatalogApiModelId( modelSelection: ModelSelection, ): string { const entry = resolveClaudeCatalogModel(catalog, modelSelection.model); - const slug = entry?.model.slug ?? modelSelection.model; + const slug = entry?.apiModel ?? entry?.model.slug ?? modelSelection.model; const descriptors = getProviderOptionDescriptors({ caps: entry?.model.capabilities ?? EMPTY_CAPABILITIES, selections: modelSelection.options, diff --git a/apps/server/src/provider/ClaudeProvider.ts b/apps/server/src/provider/ClaudeProvider.ts index 18f3b8508a04a..ecc6969ee0dce 100644 --- a/apps/server/src/provider/ClaudeProvider.ts +++ b/apps/server/src/provider/ClaudeProvider.ts @@ -47,6 +47,7 @@ import { } from "./claudeUsageLimits.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeCodeReportedModel, type ClaudeModelCatalog, formatClaudeVersionUpgradeMessage, resolveClaudeModelsForVersion, @@ -259,6 +260,7 @@ export type ClaudeCapabilitiesProbe = { */ readonly apiProvider: string | undefined; readonly slashCommands: ReadonlyArray; + readonly models?: ReadonlyArray; /** * Subscription windows from the SDK's `get_usage` control request, or * `undefined` when the request itself failed. Absent windows on an @@ -415,6 +417,7 @@ const probeClaudeCapabilities = ( apiKeySource: account?.apiKeySource, apiProvider: account?.apiProvider, slashCommands: parseClaudeInitializationCommands(init.commands), + models: (init.models ?? []).map(({ value, resolvedModel }) => ({ value, resolvedModel })), ...(usage ? { usage, usageCheckedAt } : {}), } satisfies ClaudeCapabilitiesProbe; }), diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 86832d53d3119..aebbd4aa130cd 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -35,7 +35,11 @@ import { } from "../ClaudeProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; -import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; +import { + type ClaudeCodeReportedModel, + resolveClaudeModelCatalog, + withClaudeCodeModelRoutes, +} from "../ClaudeModelCatalog.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -170,6 +174,9 @@ export const ClaudeDriver: ProviderDriver>([]); const orchestrationAdapter = yield* createClaudeAdapterV2( { instanceId, @@ -179,7 +186,11 @@ export const ClaudeDriver: ProviderDriver snapshot.applyUsageLimits(update) }, + { + scopedLimitNames, + claudeCodeModels, + onUsageLimits: (update) => snapshot.applyUsageLimits(update), + }, ).pipe( Effect.mapError( (cause) => @@ -194,7 +205,7 @@ export const ClaudeDriver: ProviderDriver Ref.set(lastGoodCapabilities, capabilities)), + Effect.tap(({ capabilities }) => + Ref.set(lastGoodCapabilities, capabilities).pipe( + Effect.andThen(Ref.set(claudeCodeModels, capabilities.models ?? [])), + ), + ), Effect.catch((error) => Ref.getAndSet(lastGoodCapabilities, undefined).pipe( Effect.flatMap((capabilities) => From 75b14f7185729cc2a3305a3d21f9ede72c697347 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sat, 24 Oct 2026 20:18:38 -0500 Subject: [PATCH 02/13] fix(server): Claude turns route models from the live catalog Turns built their model routes from the bundled catalog while the picker and text generation used the instance's live catalog. A model the remote manifest adds after a release showed in the picker but was still sent as its slug, so Claude Code ran the default model instead. The driver now builds the routed catalog once and hands the same effect to the adapter and to text generation. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 55 ++++++++++++++----- .../Adapters/ClaudeAdapterV2.ts | 21 +++---- .../src/provider/Drivers/ClaudeDriver.ts | 9 ++- 3 files changed, 56 insertions(+), 29 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 3d9a49c2bb9ee..1d69b9558601e 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -86,7 +86,13 @@ import { WorktreeToolkit } from "../../mcp/toolkits/worktree/tools.ts"; import { ThreadToolkit } from "../../mcp/toolkits/thread/tools.ts"; import { OrchestratorToolkit } from "../../mcp/toolkits/orchestrator/tools.ts"; import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutable.ts"; -import type { ClaudeCodeReportedModel } from "../../provider/ClaudeModelCatalog.ts"; +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + type ClaudeModelCatalog, + resolveClaudeModelCatalog, + withClaudeCodeModelRoutes, +} from "../../provider/ClaudeModelCatalog.ts"; +import { resolveProviderCatalog } from "../../provider/ModelManifest.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; import type * as ProviderContinuationRequests from "@t3tools/provider-core/server/ProviderContinuationRequests"; @@ -2156,7 +2162,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly freshQueueOnReopen?: boolean; readonly forkSession?: ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerShape["forkSession"]; readonly allocateSessionId?: Effect.Effect; - readonly claudeCodeModels?: Ref.Ref>; + readonly modelCatalog?: Effect.Effect; }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -2215,7 +2221,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { path: yield* Path.Path, crypto: yield* Crypto.Crypto, idAllocator, - ...(options?.claudeCodeModels ? { claudeCodeModels: options.claudeCodeModels } : {}), + ...(options?.modelCatalog ? { modelCatalog: options.modelCatalog } : {}), continuationRequests: { offer: (request) => Effect.sync(() => { @@ -3244,8 +3250,8 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("sends the model id Claude Code reported for the selected catalog model", () => Effect.scoped( Effect.gen(function* () { - const claudeCodeModels = yield* Ref.make>([]); - const harness = yield* makeWakeHarnessWithOptions({ claudeCodeModels }); + const modelCatalog = yield* Ref.make(BUNDLED_CLAUDE_MODEL_CATALOG); + const harness = yield* makeWakeHarnessWithOptions({ modelCatalog: Ref.get(modelCatalog) }); const now = yield* DateTime.now; const turn = (ordinal: number, model: string) => makeClaudeTestTurnInput({ @@ -3258,20 +3264,41 @@ describe("ClaudeAdapterV2 background wake turns", () => { providerTurnOrdinal: ordinal, modelSelection: { instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, model }, }); - // Bedrock with ANTHROPIC_DEFAULT_*_MODEL set, as Claude Code 2.1.310 reports it. - yield* Ref.set(claudeCodeModels, [ - { value: "default", resolvedModel: "global.anthropic.claude-opus-5-5" }, - { value: "sonnet", resolvedModel: "global.anthropic.claude-sonnet-5-5[1m]" }, - { value: "opus", resolvedModel: "global.anthropic.claude-opus-5-5" }, - { value: "haiku", resolvedModel: "global.anthropic.claude-haiku-5-5" }, - ]); + // Models the live manifest delivers after a release, so the bundled catalog lacks them. + const manifestCatalog = resolveClaudeModelCatalog( + resolveProviderCatalog( + { + version: 1, + currentModels: {}, + providers: { + claudeAgent: { + profiles: {}, + models: [ + { slug: "claude-haiku-6", name: "Claude Haiku 6", status: "current" }, + { slug: "claude-sonnet-6", name: "Claude Sonnet 6", status: "current" }, + ], + }, + }, + }, + ProviderDriverKind.make("claudeAgent"), + ) ?? undefined, + ); + // The probe reports Bedrock with ANTHROPIC_DEFAULT_*_MODEL set after the adapter starts. + yield* Ref.set( + modelCatalog, + withClaudeCodeModelRoutes(manifestCatalog, [ + { value: "default", resolvedModel: "global.anthropic.claude-sonnet-6" }, + { value: "sonnet", resolvedModel: "global.anthropic.claude-sonnet-6" }, + { value: "haiku", resolvedModel: "global.anthropic.claude-haiku-6" }, + ]), + ); - yield* harness.runtime.startTurn(turn(1, "claude-haiku-5-5")); + yield* harness.runtime.startTurn(turn(1, "claude-haiku-6")); assert.equal(harness.getOpenedOptions()?.model, "haiku"); yield* harness.offerAndWait(turnOneResult); yield* Queue.take(harness.terminalReceipts); - yield* harness.runtime.startTurn(turn(2, "claude-sonnet-5-5")); + yield* harness.runtime.startTurn(turn(2, "claude-sonnet-6")); assert.deepEqual(harness.liveSelectionChanges[0], { model: "sonnet" }); }).pipe( Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b40cac1941613..bdec333549e62 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -148,12 +148,10 @@ import { import { BUNDLED_CLAUDE_MODEL_CATALOG, resolveClaudeCatalogContextWindowTokens, - type ClaudeCodeReportedModel, type ClaudeModelCatalog, getClaudeCatalogModelCapabilities, resolveClaudeModelSlug, scopeClaudeModelCatalog, - withClaudeCodeModelRoutes, } from "../../provider/ClaudeModelCatalog.ts"; import { boundProviderEventForLogging, @@ -3915,8 +3913,11 @@ export interface ClaudeAdapterV2Options { readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; readonly scopedLimitNames?: Ref.Ref; - /** Claude Code's model list from the provider probe, so turns send ids it accepts. */ - readonly claudeCodeModels?: Ref.Ref>; + /** + * The instance's model catalog with Claude Code's reported model ids, read per turn so turns + * send ids its backend accepts. Defaults to the bundled catalog. + */ + readonly modelCatalog?: Effect.Effect; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; readonly continuationRequests?: { readonly offer: ( @@ -9227,11 +9228,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : { allowedTools: queryPolicy.allowedTools }), }); const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); - const modelCatalog = adapterOptions.claudeCodeModels - ? withClaudeCodeModelRoutes( - BUNDLED_CLAUDE_MODEL_CATALOG, - yield* Ref.get(adapterOptions.claudeCodeModels), - ) + const modelCatalog = adapterOptions.modelCatalog + ? yield* adapterOptions.modelCatalog : BUNDLED_CLAUDE_MODEL_CATALOG; const compiledSelection = compileClaudeModelSelection( turnInput.modelSelection, @@ -10535,10 +10533,7 @@ export type ClaudeAdapterV2DriverEnv = export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( function* ( input: ProviderAdapterDriverCreateInput, - hooks: Pick< - ClaudeAdapterV2Options, - "scopedLimitNames" | "claudeCodeModels" | "onUsageLimits" - > = {}, + hooks: Pick = {}, ) { const { instanceId, environment, enabled, config } = input; const fileSystem = yield* FileSystem.FileSystem; diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index aebbd4aa130cd..25b78b7c388ca 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -177,6 +177,11 @@ export const ClaudeDriver: ProviderDriver>([]); + const routedModelCatalog = Effect.zipWith( + modelCatalog, + Ref.get(claudeCodeModels), + withClaudeCodeModelRoutes, + ); const orchestrationAdapter = yield* createClaudeAdapterV2( { instanceId, @@ -188,7 +193,7 @@ export const ClaudeDriver: ProviderDriver snapshot.applyUsageLimits(update), }, ).pipe( @@ -205,7 +210,7 @@ export const ClaudeDriver: ProviderDriver Date: Sat, 24 Oct 2026 20:22:10 -0500 Subject: [PATCH 03/13] refactor(server): read the Claude model catalog once per adapter Resolve the optional catalog option to one effect when the adapter is built, and read it per turn, the same shape open #13913 uses for its model catalog option, so the two changes share one option. Co-Authored-By: Claude Opus 5.5 --- .../orchestration-v2/Adapters/ClaudeAdapterV2.ts | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index bdec333549e62..7bf2261eba9b3 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3913,12 +3913,12 @@ export interface ClaudeAdapterV2Options { readonly idAllocator: IdAllocator.IdAllocatorV2["Service"]; readonly queryRunner: ClaudeAgentSdkQueryRunnerShape; readonly scopedLimitNames?: Ref.Ref; + readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; /** * The instance's model catalog with Claude Code's reported model ids, read per turn so turns * send ids its backend accepts. Defaults to the bundled catalog. */ readonly modelCatalog?: Effect.Effect; - readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -3977,6 +3977,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }; }, ); + const modelCatalog = adapterOptions.modelCatalog ?? Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG); // Claude Code's own subagent TTL setting, in its precedence order. Unset or unreadable means // Anthropic's default applies. @@ -9228,13 +9229,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : { allowedTools: queryPolicy.allowedTools }), }); const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); - const modelCatalog = adapterOptions.modelCatalog - ? yield* adapterOptions.modelCatalog - : BUNDLED_CLAUDE_MODEL_CATALOG; - const compiledSelection = compileClaudeModelSelection( - turnInput.modelSelection, - modelCatalog, - ); + const catalog = yield* modelCatalog; + const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); // A continuation prompts nothing: it drains output the live process @@ -9335,7 +9331,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( resumeSessionAt !== undefined || openedWithResume || hasPersistedProviderTurn; const queryOptions = makeClaudeQueryOptions({ modelSelection: turnInput.modelSelection, - modelCatalog, + modelCatalog: catalog, nativeThreadId, resume: shouldResume, ...(resumeSessionAt === undefined ? {} : { resumeSessionAt }), @@ -10533,7 +10529,7 @@ export type ClaudeAdapterV2DriverEnv = export const createClaudeAdapterV2 = Effect.fn("ClaudeAdapterV2Driver.create")( function* ( input: ProviderAdapterDriverCreateInput, - hooks: Pick = {}, + hooks: Pick = {}, ) { const { instanceId, environment, enabled, config } = input; const fileSystem = yield* FileSystem.FileSystem; From 6e7af0c6369f9c296de0260de9d00f93b285185a Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 12:11:28 +0000 Subject: [PATCH 04/13] fix(claude): reported model ids apply only where they mean the same model, and wait for the first probe The capability probe runs in the server's state directory, so the ids Claude Code reports there (such as `haiku`) can resolve to a different model in a project whose own .claude settings change ANTHROPIC_DEFAULT_*_MODEL, the backend or availableModels. Turns and text generation now apply those ids only when their cwd resolves the same model settings as the probe (compared through the SDK's resolveSettings); anywhere else they send the plain catalog id, as before this PR. Turns sent while the first probe is still running (queued or scheduled turns at startup) used to go out with the slug. They now wait until the first provider check settles, by any path; later turns never wait. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 79 +++++++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 11 +-- .../src/provider/ClaudeModelCatalog.test.ts | 41 +++++++++ .../server/src/provider/ClaudeModelCatalog.ts | 83 +++++++++++++++++++ .../src/provider/Drivers/ClaudeDriver.ts | 29 ++++--- .../ClaudeTextGeneration.test.ts | 4 +- .../textGeneration/ClaudeTextGeneration.ts | 41 +++++---- 7 files changed, 250 insertions(+), 38 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4fa301a6dcb07..8d09d13cf56c0 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -90,7 +90,9 @@ import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutab import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, + makeClaudeCodeModelRoutes, resolveClaudeModelCatalog, + resolveClaudeModelSettingsKey, withClaudeCodeModelRoutes, } from "../../provider/ClaudeModelCatalog.ts"; import { resolveProviderCatalog } from "../../provider/ModelManifest.ts"; @@ -2195,7 +2197,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly freshQueueOnReopen?: boolean; readonly forkSession?: ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerShape["forkSession"]; readonly allocateSessionId?: Effect.Effect; - readonly modelCatalog?: Effect.Effect; + readonly modelCatalog?: (cwd: string | null) => Effect.Effect; }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -3382,7 +3384,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { Effect.scoped( Effect.gen(function* () { const modelCatalog = yield* Ref.make(BUNDLED_CLAUDE_MODEL_CATALOG); - const harness = yield* makeWakeHarnessWithOptions({ modelCatalog: Ref.get(modelCatalog) }); + const harness = yield* makeWakeHarnessWithOptions({ + modelCatalog: () => Ref.get(modelCatalog), + }); const now = yield* DateTime.now; const turn = (ordinal: number, model: string) => makeClaudeTestTurnInput({ @@ -3439,6 +3443,77 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect( + "sends Claude Code's reported model id only where the cwd resolves it as the probe did", + () => + Effect.gen(function* () { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + const sdk = yield* Effect.promise(() => + vi.importActual("@anthropic-ai/claude-agent-sdk"), + ); + vi.mocked(ClaudeSdk.resolveSettings).mockImplementation(sdk.resolveSettings); + const probeCwd = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-probe-" }); + const project = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-project-" }); + const overridden = yield* fileSystem.makeTempDirectoryScoped({ + prefix: "t3-claude-local-", + }); + yield* fileSystem.makeDirectory(path.join(overridden, ".claude")); + yield* fileSystem.writeFileString( + path.join(overridden, ".claude", "settings.local.json"), + JSON.stringify({ + env: { ANTHROPIC_DEFAULT_HAIKU_MODEL: "global.anthropic.claude-sonnet-4-6" }, + }), + ); + const routes = yield* makeClaudeCodeModelRoutes( + Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), + (cwd) => resolveClaudeModelSettingsKey(["project", "local"], cwd), + ); + yield* routes.record( + [{ value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }], + probeCwd, + ); + yield* routes.settle; + const openedModel = (cwd: string) => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + modelCatalog: routes.catalogFor, + }); + yield* harness.runtime.startTurn( + makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-reported-model-cwd"), + text: "Request", + attachments: [], + modelSelection: { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-haiku-4-5", + }, + runtimePolicy: ProviderAdapter.ProviderAdapterV2RuntimePolicy.make({ + runtimeMode: "full-access", + interactionMode: "default", + cwd, + }), + }), + ); + return harness.getOpenedOptions()?.model; + }), + ); + + assert.equal(yield* openedModel(project), "haiku"); + // Claude Code resolves `haiku` to Sonnet there, so the slug is the only safe id. + assert.equal(yield* openedModel(overridden), "claude-haiku-4-5"); + }).pipe( + Effect.scoped, + Effect.provide( + Layer.mergeAll(IdAllocator.layer, McpProviderSessions.layer, NodeServices.layer), + ), + ), + ); + it.effect( "applies model, effort and Fast changes to a process running a background shell, but not thinking or a cleared effort", () => diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 2f761e6d6719c..59dcb1431a9e7 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -3912,10 +3912,10 @@ export interface ClaudeAdapterV2Options { readonly scopedLimitNames?: Ref.Ref; readonly onUsageLimits?: ManagedServerProvider["applyUsageLimits"]; /** - * The instance's model catalog with Claude Code's reported model ids, read per turn so turns - * send ids its backend accepts. Defaults to the bundled catalog. + * The instance's model catalog with Claude Code's reported model ids for a turn's cwd, read per + * turn so turns send ids its backend accepts. Defaults to the bundled catalog. */ - readonly modelCatalog?: Effect.Effect; + readonly modelCatalog?: (cwd: string | null) => Effect.Effect; readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -3974,7 +3974,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }; }, ); - const modelCatalog = adapterOptions.modelCatalog ?? Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG); + const modelCatalog = + adapterOptions.modelCatalog ?? (() => Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG)); // Claude Code's own subagent TTL setting, in its precedence order. Unset or unreadable means // Anthropic's default applies. @@ -9247,7 +9248,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : { allowedTools: queryPolicy.allowedTools }), }); const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); - const catalog = yield* modelCatalog; + const catalog = yield* modelCatalog(turnInput.runtimePolicy.cwd); const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index b439d529b611a..7e5165ce3bf2b 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -1,11 +1,14 @@ import { assert, describe, it } from "@effect/vitest"; import { type ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, formatClaudeVersionUpgradeMessage, + makeClaudeCodeModelRoutes, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, resolveClaudeCatalogContextWindowTokens, @@ -284,4 +287,42 @@ describe("Claude model catalog", () => { ]); assert.strictEqual(firstParty, BUNDLED_CLAUDE_MODEL_CATALOG); }); + + it.effect( + "a turn that starts before the first probe settles waits and then gets its routes", + () => + Effect.gen(function* () { + const haiku = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-haiku-4-5", + } satisfies ModelSelection; + const routes = yield* makeClaudeCodeModelRoutes( + Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), + () => Effect.succeed("same settings"), + ); + const early = yield* routes + .catalogFor("/project") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* routes.record( + [{ value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }], + "/state", + ); + yield* routes.settle; + assert.strictEqual( + resolveClaudeCatalogApiModelId(yield* Fiber.join(early), haiku), + "haiku", + ); + + // A first check that never reaches the probe still releases waiting turns. + const unprobed = yield* makeClaudeCodeModelRoutes( + Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), + () => Effect.succeed("same settings"), + ); + const waiting = yield* unprobed + .catalogFor("/project") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* unprobed.settle; + assert.strictEqual(yield* Fiber.join(waiting), BUNDLED_CLAUDE_MODEL_CATALOG); + }), + ); }); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index 75479f14749ec..c2f547699b2c9 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -6,7 +6,15 @@ import { type ServerProviderModel, type ServerProviderUpdateRequiredModel, } from "@t3tools/contracts"; +import { + resolveSettings as resolveClaudeSettings, + type SettingSource, + type Settings as ClaudeSdkSettings, +} from "@anthropic-ai/claude-agent-sdk"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; +import * as Ref from "effect/Ref"; import { getModelSelectionStringOptionValue, getProviderOptionCurrentValue, @@ -181,6 +189,81 @@ export function withClaudeCodeModelRoutes( }; } +// Env names that pick the backend, its region, or the model a family alias resolves to. +const MODEL_SETTINGS_ENV_PREFIXES = [ + "ANTHROPIC_", + "CLAUDE_CODE_USE_", + "AWS_", + "CLOUD_ML_", + "VERTEX_", +]; + +/** Equal keys mean Claude Code resolves model ids the same way under both settings. */ +function claudeModelSettingsKey(settings: ClaudeSdkSettings): string { + const sortedEntries = (record: Readonly> | undefined) => + Object.entries(record ?? {}).toSorted(([a], [b]) => a.localeCompare(b)); + return JSON.stringify([ + settings.availableModels ?? null, + sortedEntries(settings.modelOverrides), + sortedEntries(settings.env).filter(([name]) => + MODEL_SETTINGS_ENV_PREFIXES.some((prefix) => name.startsWith(prefix)), + ), + ]); +} + +export const resolveClaudeModelSettingsKey = Effect.fn("resolveClaudeModelSettingsKey")(function* ( + settingSources: ReadonlyArray, + cwd: string | null, +) { + return yield* Effect.tryPromise(() => + resolveClaudeSettings({ + ...(cwd === null ? {} : { cwd }), + settingSources: [...settingSources], + }), + ).pipe( + Effect.map(({ effective }) => claudeModelSettingsKey(effective)), + Effect.orElseSucceed(() => undefined), + ); +}); + +export interface ClaudeCodeModelRoutes { + /** Keeps a successful probe's model list with the settings key of the cwd it ran in. */ + readonly record: ( + models: ReadonlyArray, + probeCwd: string, + ) => Effect.Effect; + /** Releases turns waiting for the first probe, whatever its outcome. */ + readonly settle: Effect.Effect; + readonly catalogFor: (cwd: string | null) => Effect.Effect; +} + +export const makeClaudeCodeModelRoutes = Effect.fn("makeClaudeCodeModelRoutes")(function* ( + catalog: Effect.Effect, + settingsKey: (cwd: string | null) => Effect.Effect, +) { + const firstProbeSettled = yield* Deferred.make(); + const reported = yield* Ref.make<{ + readonly models: ReadonlyArray; + readonly settingsKey: string | undefined; + }>({ models: [], settingsKey: undefined }); + return { + record: Effect.fn("ClaudeCodeModelRoutes.record")(function* (models, probeCwd) { + yield* Ref.set(reported, { models, settingsKey: yield* settingsKey(probeCwd) }); + }), + settle: Deferred.succeed(firstProbeSettled, undefined).pipe(Effect.asVoid), + catalogFor: Effect.fn("ClaudeCodeModelRoutes.catalogFor")(function* (cwd) { + yield* Deferred.await(firstProbeSettled); + const base = yield* catalog; + const { models, settingsKey: probeKey } = yield* Ref.get(reported); + const routed = withClaudeCodeModelRoutes(base, models); + // Project settings can point an alias at another model, so routes hold only where the + // cwd resolves the same model settings as the probe's. + if (routed === base || probeKey === undefined) return base; + return (yield* settingsKey(cwd)) === probeKey ? routed : base; + }), + } satisfies ClaudeCodeModelRoutes; +}); + export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; } diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 25b78b7c388ca..412d3e8339f0d 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -36,9 +36,9 @@ import { import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; import { - type ClaudeCodeReportedModel, + makeClaudeCodeModelRoutes, resolveClaudeModelCatalog, - withClaudeCodeModelRoutes, + resolveClaudeModelSettingsKey, } from "../ClaudeModelCatalog.ts"; import { defaultProviderContinuationIdentity, @@ -174,13 +174,17 @@ export const ClaudeDriver: ProviderDriver 0 || + processEnv.CLAUDE_CONFIG_DIR !== (yield* HostProcess.Environment).CLAUDE_CONFIG_DIR; + // Only a successful probe records routes, so a failed one never sends turns back to slugs // the backend rejects. - const claudeCodeModels = yield* Ref.make>([]); - const routedModelCatalog = Effect.zipWith( - modelCatalog, - Ref.get(claudeCodeModels), - withClaudeCodeModelRoutes, + const modelRoutes = yield* makeClaudeCodeModelRoutes(modelCatalog, (cwd) => + resolveClaudeModelSettingsKey( + customHome ? ["project", "local"] : ["user", "project", "local"], + cwd, + ), ); const orchestrationAdapter = yield* createClaudeAdapterV2( { @@ -193,7 +197,7 @@ export const ClaudeDriver: ProviderDriver snapshot.applyUsageLimits(update), }, ).pipe( @@ -210,7 +214,7 @@ export const ClaudeDriver: ProviderDriver Ref.set(lastGoodCapabilities, capabilities).pipe( - Effect.andThen(Ref.set(claudeCodeModels, capabilities.models ?? [])), + Effect.andThen(modelRoutes.record(capabilities.models ?? [], stateDir)), ), ), + Effect.ensuring(modelRoutes.settle), Effect.catch((error) => Ref.getAndSet(lastGoodCapabilities, undefined).pipe( Effect.flatMap((capabilities) => @@ -302,6 +307,8 @@ export const ClaudeDriver: ProviderDriver( ); const config = decodeClaudeSettings(input.claudeConfig ?? {}); - const textGeneration = yield* makeClaudeTextGeneration( - config, - undefined, + const textGeneration = yield* makeClaudeTextGeneration(config, undefined, () => Effect.succeed(SYNTHETIC_CLAUDE_MODEL_CATALOG), ); return yield* effectFn(textGeneration); diff --git a/apps/server/src/textGeneration/ClaudeTextGeneration.ts b/apps/server/src/textGeneration/ClaudeTextGeneration.ts index 8fa9ccb1c8094..532c8cfaf4424 100644 --- a/apps/server/src/textGeneration/ClaudeTextGeneration.ts +++ b/apps/server/src/textGeneration/ClaudeTextGeneration.ts @@ -2,6 +2,7 @@ import * as Effect from "effect/Effect"; import * as FileSystem from "effect/FileSystem"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; +import * as Scope from "effect/Scope"; import * as Stream from "effect/Stream"; import { ChildProcess, ChildProcessSpawner } from "effect/process"; @@ -80,14 +81,16 @@ function claudeErrorResult(stdout: string): string | undefined { export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(function* ( claudeSettings: ClaudeSettings, environment?: NodeJS.ProcessEnv, - modelCatalog: Effect.Effect = Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), + modelCatalog: (cwd: string) => Effect.Effect = () => + Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), ) { const commandSpawner = yield* ChildProcessSpawner.ChildProcessSpawner; const fileSystem = yield* FileSystem.FileSystem; const claudeEnvironment = yield* makeClaudeEnvironment(claudeSettings, environment); - const scopedModelCatalog = modelCatalog.pipe( - Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)), - ); + const scopedModelCatalog = (cwd: string) => + modelCatalog(cwd).pipe( + Effect.map((catalog) => scopeClaudeModelCatalog(catalog, claudeSettings.customModels)), + ); const readStreamAsString = ( operation: string, @@ -130,9 +133,19 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu }: TextGenerationOperations.Request): Effect.fn.Return< S["Type"], TextGenerationError, - S["DecodingServices"] + S["DecodingServices"] | Scope.Scope > { - const catalog = yield* scopedModelCatalog; + const workingDirectory = + operation === "generateThreadTitle" + ? yield* fileSystem + .makeTempDirectoryScoped({ prefix: "t3code-claude-title-" }) + .pipe( + Effect.mapError((cause) => + normalizeCliError("claude", operation, cause, "Failed to create title directory"), + ), + ) + : cwd; + const catalog = yield* scopedModelCatalog(workingDirectory); const resolvedModelSelection = { ...modelSelection, model: resolveClaudeModelSlug(catalog, modelSelection.model), @@ -182,16 +195,6 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ); const runClaudeCommand = Effect.fn("runClaudeJson.runClaudeCommand")(function* () { - const workingDirectory = - operation === "generateThreadTitle" - ? yield* fileSystem - .makeTempDirectoryScoped({ prefix: "t3code-claude-title-" }) - .pipe( - Effect.mapError((cause) => - normalizeCliError("claude", operation, cause, "Failed to create title directory"), - ), - ) - : cwd; const managedMcpConfig = yield* hasClaudeManagedMcpConfig(); const spawnCommand = yield* resolveSpawnCommand( claudeSettings.binaryPath || "claude", @@ -318,5 +321,9 @@ export const makeClaudeTextGeneration = Effect.fn("makeClaudeTextGeneration")(fu ); }); - return TextGenerationOperations.fromRunner("ClaudeTextGeneration", runClaudeJson); + return TextGenerationOperations.fromRunner( + "ClaudeTextGeneration", + (request: TextGenerationOperations.Request) => + Effect.scoped(runClaudeJson(request)), + ); }); From e828b634c8e11b5258f4e5ae65d57f1f7837da4f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 12:17:49 +0000 Subject: [PATCH 05/13] refactor(server): Claude Code model routes get their own module The per-cwd settings key and first-probe gate moved out of the pure catalog module, and the probe's cwd is bound once instead of passed on every record. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 12 +-- .../provider/ClaudeCodeModelRoutes.test.ts | 51 +++++++++++ .../src/provider/ClaudeCodeModelRoutes.ts | 85 +++++++++++++++++++ .../src/provider/ClaudeModelCatalog.test.ts | 41 --------- .../server/src/provider/ClaudeModelCatalog.ts | 83 ------------------ .../src/provider/Drivers/ClaudeDriver.ts | 19 +++-- 6 files changed, 154 insertions(+), 137 deletions(-) create mode 100644 apps/server/src/provider/ClaudeCodeModelRoutes.test.ts create mode 100644 apps/server/src/provider/ClaudeCodeModelRoutes.ts diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 8d09d13cf56c0..2101bca7df185 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -90,11 +90,13 @@ import { ClaudeExecutableFileCheck } from "../../provider/Drivers/ClaudeExecutab import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, - makeClaudeCodeModelRoutes, resolveClaudeModelCatalog, - resolveClaudeModelSettingsKey, withClaudeCodeModelRoutes, } from "../../provider/ClaudeModelCatalog.ts"; +import { + makeClaudeCodeModelRoutes, + resolveClaudeModelSettingsKey, +} from "../../provider/ClaudeCodeModelRoutes.ts"; import { resolveProviderCatalog } from "../../provider/ModelManifest.ts"; import * as ProviderEventLoggers from "@t3tools/provider-core/server/ProviderEventLoggers"; import * as ProviderAdapter from "@t3tools/provider-core/server/ProviderAdapter"; @@ -3468,11 +3470,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { const routes = yield* makeClaudeCodeModelRoutes( Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), (cwd) => resolveClaudeModelSettingsKey(["project", "local"], cwd), - ); - yield* routes.record( - [{ value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }], probeCwd, ); + yield* routes.record([ + { value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }, + ]); yield* routes.settle; const openedModel = (cwd: string) => Effect.scoped( diff --git a/apps/server/src/provider/ClaudeCodeModelRoutes.test.ts b/apps/server/src/provider/ClaudeCodeModelRoutes.test.ts new file mode 100644 index 0000000000000..9860c029c92f8 --- /dev/null +++ b/apps/server/src/provider/ClaudeCodeModelRoutes.test.ts @@ -0,0 +1,51 @@ +import { assert, describe, it } from "@effect/vitest"; +import { type ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; +import * as Effect from "effect/Effect"; +import * as Fiber from "effect/Fiber"; + +import { makeClaudeCodeModelRoutes } from "./ClaudeCodeModelRoutes.ts"; +import { + BUNDLED_CLAUDE_MODEL_CATALOG, + resolveClaudeCatalogApiModelId, +} from "./ClaudeModelCatalog.ts"; + +describe("Claude Code model routes", () => { + it.effect( + "a turn that starts before the first probe settles waits and then gets its routes", + () => + Effect.gen(function* () { + const haiku = { + instanceId: ProviderInstanceId.make("claudeAgent"), + model: "claude-haiku-4-5", + } satisfies ModelSelection; + const routes = yield* makeClaudeCodeModelRoutes( + Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), + () => Effect.succeed("same settings"), + "/state", + ); + const early = yield* routes + .catalogFor("/project") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* routes.record([ + { value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }, + ]); + yield* routes.settle; + assert.strictEqual( + resolveClaudeCatalogApiModelId(yield* Fiber.join(early), haiku), + "haiku", + ); + + // A first check that never reaches the probe still releases waiting turns. + const unprobed = yield* makeClaudeCodeModelRoutes( + Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), + () => Effect.succeed("same settings"), + "/state", + ); + const waiting = yield* unprobed + .catalogFor("/project") + .pipe(Effect.forkChild({ startImmediately: true })); + yield* unprobed.settle; + assert.strictEqual(yield* Fiber.join(waiting), BUNDLED_CLAUDE_MODEL_CATALOG); + }), + ); +}); diff --git a/apps/server/src/provider/ClaudeCodeModelRoutes.ts b/apps/server/src/provider/ClaudeCodeModelRoutes.ts new file mode 100644 index 0000000000000..66a156f5d2d6c --- /dev/null +++ b/apps/server/src/provider/ClaudeCodeModelRoutes.ts @@ -0,0 +1,85 @@ +import { + resolveSettings as resolveClaudeSettings, + type SettingSource, + type Settings as ClaudeSdkSettings, +} from "@anthropic-ai/claude-agent-sdk"; +import * as Deferred from "effect/Deferred"; +import * as Effect from "effect/Effect"; +import * as Ref from "effect/Ref"; + +import { + type ClaudeCodeReportedModel, + type ClaudeModelCatalog, + withClaudeCodeModelRoutes, +} from "./ClaudeModelCatalog.ts"; + +// Env names that pick the backend, its region, or the model a family alias resolves to. +const MODEL_SETTINGS_ENV_PREFIXES = [ + "ANTHROPIC_", + "CLAUDE_CODE_USE_", + "AWS_", + "CLOUD_ML_", + "VERTEX_", +]; + +/** Equal keys mean Claude Code resolves model ids the same way under both settings. */ +function claudeModelSettingsKey(settings: ClaudeSdkSettings): string { + const sortedEntries = (record: Readonly> | undefined) => + Object.entries(record ?? {}).toSorted(([a], [b]) => a.localeCompare(b)); + return JSON.stringify([ + settings.availableModels ?? null, + sortedEntries(settings.modelOverrides), + sortedEntries(settings.env).filter(([name]) => + MODEL_SETTINGS_ENV_PREFIXES.some((prefix) => name.startsWith(prefix)), + ), + ]); +} + +export const resolveClaudeModelSettingsKey = Effect.fn("resolveClaudeModelSettingsKey")(function* ( + settingSources: ReadonlyArray, + cwd: string | null, +) { + return yield* Effect.tryPromise(() => + resolveClaudeSettings({ + ...(cwd === null ? {} : { cwd }), + settingSources: [...settingSources], + }), + ).pipe( + Effect.map(({ effective }) => claudeModelSettingsKey(effective)), + Effect.orElseSucceed(() => undefined), + ); +}); + +/** + * Claude Code's reported model ids, applied to a turn only where its cwd resolves model settings + * as the probe's cwd did. `settle` releases turns waiting for the first probe, whatever its outcome. + */ +export const makeClaudeCodeModelRoutes = Effect.fn("makeClaudeCodeModelRoutes")(function* ( + catalog: Effect.Effect, + settingsKey: (cwd: string | null) => Effect.Effect, + probeCwd: string, +) { + const firstProbeSettled = yield* Deferred.make(); + const reported = yield* Ref.make<{ + readonly models: ReadonlyArray; + readonly settingsKey: string | undefined; + }>({ models: [], settingsKey: undefined }); + return { + record: Effect.fn("ClaudeCodeModelRoutes.record")(function* ( + models: ReadonlyArray, + ) { + yield* Ref.set(reported, { models, settingsKey: yield* settingsKey(probeCwd) }); + }), + settle: Deferred.succeed(firstProbeSettled, undefined).pipe(Effect.asVoid), + catalogFor: Effect.fn("ClaudeCodeModelRoutes.catalogFor")(function* (cwd: string | null) { + yield* Deferred.await(firstProbeSettled); + const base = yield* catalog; + const { models, settingsKey: probeKey } = yield* Ref.get(reported); + const routed = withClaudeCodeModelRoutes(base, models); + // Project settings can point an alias at another model, so routes hold only where the + // cwd resolves the same model settings as the probe's. + if (routed === base || probeKey === undefined) return base; + return (yield* settingsKey(cwd)) === probeKey ? routed : base; + }), + }; +}); diff --git a/apps/server/src/provider/ClaudeModelCatalog.test.ts b/apps/server/src/provider/ClaudeModelCatalog.test.ts index 7e5165ce3bf2b..b439d529b611a 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.test.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.test.ts @@ -1,14 +1,11 @@ import { assert, describe, it } from "@effect/vitest"; import { type ModelSelection, ProviderDriverKind, ProviderInstanceId } from "@t3tools/contracts"; -import * as Effect from "effect/Effect"; -import * as Fiber from "effect/Fiber"; import { resolveProviderCatalog, type ModelManifestData } from "./ModelManifest.ts"; import { BUNDLED_CLAUDE_MODEL_CATALOG, type ClaudeModelCatalog, formatClaudeVersionUpgradeMessage, - makeClaudeCodeModelRoutes, normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, resolveClaudeCatalogContextWindowTokens, @@ -287,42 +284,4 @@ describe("Claude model catalog", () => { ]); assert.strictEqual(firstParty, BUNDLED_CLAUDE_MODEL_CATALOG); }); - - it.effect( - "a turn that starts before the first probe settles waits and then gets its routes", - () => - Effect.gen(function* () { - const haiku = { - instanceId: ProviderInstanceId.make("claudeAgent"), - model: "claude-haiku-4-5", - } satisfies ModelSelection; - const routes = yield* makeClaudeCodeModelRoutes( - Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), - () => Effect.succeed("same settings"), - ); - const early = yield* routes - .catalogFor("/project") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* routes.record( - [{ value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }], - "/state", - ); - yield* routes.settle; - assert.strictEqual( - resolveClaudeCatalogApiModelId(yield* Fiber.join(early), haiku), - "haiku", - ); - - // A first check that never reaches the probe still releases waiting turns. - const unprobed = yield* makeClaudeCodeModelRoutes( - Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), - () => Effect.succeed("same settings"), - ); - const waiting = yield* unprobed - .catalogFor("/project") - .pipe(Effect.forkChild({ startImmediately: true })); - yield* unprobed.settle; - assert.strictEqual(yield* Fiber.join(waiting), BUNDLED_CLAUDE_MODEL_CATALOG); - }), - ); }); diff --git a/apps/server/src/provider/ClaudeModelCatalog.ts b/apps/server/src/provider/ClaudeModelCatalog.ts index c2f547699b2c9..75479f14749ec 100644 --- a/apps/server/src/provider/ClaudeModelCatalog.ts +++ b/apps/server/src/provider/ClaudeModelCatalog.ts @@ -6,15 +6,7 @@ import { type ServerProviderModel, type ServerProviderUpdateRequiredModel, } from "@t3tools/contracts"; -import { - resolveSettings as resolveClaudeSettings, - type SettingSource, - type Settings as ClaudeSdkSettings, -} from "@anthropic-ai/claude-agent-sdk"; -import * as Deferred from "effect/Deferred"; -import * as Effect from "effect/Effect"; import * as Option from "effect/Option"; -import * as Ref from "effect/Ref"; import { getModelSelectionStringOptionValue, getProviderOptionCurrentValue, @@ -189,81 +181,6 @@ export function withClaudeCodeModelRoutes( }; } -// Env names that pick the backend, its region, or the model a family alias resolves to. -const MODEL_SETTINGS_ENV_PREFIXES = [ - "ANTHROPIC_", - "CLAUDE_CODE_USE_", - "AWS_", - "CLOUD_ML_", - "VERTEX_", -]; - -/** Equal keys mean Claude Code resolves model ids the same way under both settings. */ -function claudeModelSettingsKey(settings: ClaudeSdkSettings): string { - const sortedEntries = (record: Readonly> | undefined) => - Object.entries(record ?? {}).toSorted(([a], [b]) => a.localeCompare(b)); - return JSON.stringify([ - settings.availableModels ?? null, - sortedEntries(settings.modelOverrides), - sortedEntries(settings.env).filter(([name]) => - MODEL_SETTINGS_ENV_PREFIXES.some((prefix) => name.startsWith(prefix)), - ), - ]); -} - -export const resolveClaudeModelSettingsKey = Effect.fn("resolveClaudeModelSettingsKey")(function* ( - settingSources: ReadonlyArray, - cwd: string | null, -) { - return yield* Effect.tryPromise(() => - resolveClaudeSettings({ - ...(cwd === null ? {} : { cwd }), - settingSources: [...settingSources], - }), - ).pipe( - Effect.map(({ effective }) => claudeModelSettingsKey(effective)), - Effect.orElseSucceed(() => undefined), - ); -}); - -export interface ClaudeCodeModelRoutes { - /** Keeps a successful probe's model list with the settings key of the cwd it ran in. */ - readonly record: ( - models: ReadonlyArray, - probeCwd: string, - ) => Effect.Effect; - /** Releases turns waiting for the first probe, whatever its outcome. */ - readonly settle: Effect.Effect; - readonly catalogFor: (cwd: string | null) => Effect.Effect; -} - -export const makeClaudeCodeModelRoutes = Effect.fn("makeClaudeCodeModelRoutes")(function* ( - catalog: Effect.Effect, - settingsKey: (cwd: string | null) => Effect.Effect, -) { - const firstProbeSettled = yield* Deferred.make(); - const reported = yield* Ref.make<{ - readonly models: ReadonlyArray; - readonly settingsKey: string | undefined; - }>({ models: [], settingsKey: undefined }); - return { - record: Effect.fn("ClaudeCodeModelRoutes.record")(function* (models, probeCwd) { - yield* Ref.set(reported, { models, settingsKey: yield* settingsKey(probeCwd) }); - }), - settle: Deferred.succeed(firstProbeSettled, undefined).pipe(Effect.asVoid), - catalogFor: Effect.fn("ClaudeCodeModelRoutes.catalogFor")(function* (cwd) { - yield* Deferred.await(firstProbeSettled); - const base = yield* catalog; - const { models, settingsKey: probeKey } = yield* Ref.get(reported); - const routed = withClaudeCodeModelRoutes(base, models); - // Project settings can point an alias at another model, so routes hold only where the - // cwd resolves the same model settings as the probe's. - if (routed === base || probeKey === undefined) return base; - return (yield* settingsKey(cwd)) === probeKey ? routed : base; - }), - } satisfies ClaudeCodeModelRoutes; -}); - export function resolveClaudeModelSlug(catalog: ClaudeModelCatalog, slugOrAlias: string): string { return resolveClaudeCatalogModel(catalog, slugOrAlias)?.model.slug ?? slugOrAlias; } diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 412d3e8339f0d..7060fa00b0873 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -35,11 +35,11 @@ import { } from "../ClaudeProvider.ts"; import { makeManagedServerProvider } from "@t3tools/provider-core/server/managedProvider"; import * as ModelCatalog from "@t3tools/provider-core/server/ModelCatalog"; +import { resolveClaudeModelCatalog } from "../ClaudeModelCatalog.ts"; import { makeClaudeCodeModelRoutes, - resolveClaudeModelCatalog, resolveClaudeModelSettingsKey, -} from "../ClaudeModelCatalog.ts"; +} from "../ClaudeCodeModelRoutes.ts"; import { defaultProviderContinuationIdentity, type ProviderDriver, @@ -180,11 +180,14 @@ export const ClaudeDriver: ProviderDriver - resolveClaudeModelSettingsKey( - customHome ? ["project", "local"] : ["user", "project", "local"], - cwd, - ), + const modelRoutes = yield* makeClaudeCodeModelRoutes( + modelCatalog, + (cwd) => + resolveClaudeModelSettingsKey( + customHome ? ["project", "local"] : ["user", "project", "local"], + cwd, + ), + stateDir, ); const orchestrationAdapter = yield* createClaudeAdapterV2( { @@ -235,7 +238,7 @@ export const ClaudeDriver: ProviderDriver Ref.set(lastGoodCapabilities, capabilities).pipe( - Effect.andThen(modelRoutes.record(capabilities.models ?? [], stateDir)), + Effect.andThen(modelRoutes.record(capabilities.models ?? [])), ), ), Effect.ensuring(modelRoutes.settle), From d5c3a89994f9313aaf05777e4d8a38ccd445843a Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 12:29:30 +0000 Subject: [PATCH 06/13] fix(claude): reported model ids skip worktrees whose main checkout's local settings change models The SDK's resolveSettings reads .claude/settings.local.json from cwd, but Claude Code reads it from the repository root, and from the main checkout's root in a linked worktree. A model override there left the settings keys equal, so a turn could send `haiku` while Claude Code resolved it to another model. The settings key now reads that root file and gives no key, so no routes, when it carries model settings. The adapter's local settings directory lookup moves to ClaudeHome so both use it. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 31 ++++++++++++- .../Adapters/ClaudeAdapterV2.ts | 45 ++++--------------- .../src/provider/ClaudeCodeModelRoutes.ts | 17 +++++++ .../src/provider/Drivers/ClaudeDriver.ts | 3 ++ .../server/src/provider/Drivers/ClaudeHome.ts | 42 +++++++++++++++++ 5 files changed, 101 insertions(+), 37 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 2101bca7df185..a981f3d53afe2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -3469,7 +3469,11 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); const routes = yield* makeClaudeCodeModelRoutes( Effect.succeed(BUNDLED_CLAUDE_MODEL_CATALOG), - (cwd) => resolveClaudeModelSettingsKey(["project", "local"], cwd), + (cwd) => + resolveClaudeModelSettingsKey(["project", "local"], cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ), probeCwd, ); yield* routes.record([ @@ -3508,6 +3512,31 @@ describe("ClaudeAdapterV2 background wake turns", () => { assert.equal(yield* openedModel(project), "haiku"); // Claude Code resolves `haiku` to Sonnet there, so the slug is the only safe id. assert.equal(yield* openedModel(overridden), "claude-haiku-4-5"); + + // A linked worktree and a repository subdirectory read the main checkout's local file. + const repo = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-repo-" }); + const worktree = yield* fileSystem.makeTempDirectoryScoped({ prefix: "t3-claude-wt-" }); + const worktreeGitDir = path.join(repo, ".git", "worktrees", "wt"); + yield* fileSystem.makeDirectory(worktreeGitDir, { recursive: true }); + yield* fileSystem.writeFileString(path.join(worktreeGitDir, "commondir"), "../..\n"); + yield* fileSystem.writeFileString( + path.join(worktree, ".git"), + `gitdir: ${worktreeGitDir}\n`, + ); + yield* fileSystem.makeDirectory(path.join(repo, ".claude")); + yield* fileSystem.makeDirectory(path.join(repo, "packages", "app"), { recursive: true }); + const rootLocal = (settings: object) => + fileSystem.writeFileString( + path.join(repo, ".claude", "settings.local.json"), + JSON.stringify(settings), + ); + yield* rootLocal({ + env: { ANTHROPIC_DEFAULT_HAIKU_MODEL: "global.anthropic.claude-sonnet-4-6" }, + }); + assert.equal(yield* openedModel(worktree), "claude-haiku-4-5"); + assert.equal(yield* openedModel(path.join(repo, "packages", "app")), "claude-haiku-4-5"); + yield* rootLocal({ env: { FOO: "1" } }); + assert.equal(yield* openedModel(worktree), "haiku"); }).pipe( Effect.scoped, Effect.provide( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 59dcb1431a9e7..ba0b93cad43e2 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -141,6 +141,7 @@ import { expandHomePath } from "@t3tools/provider-core/server/pathExpansion"; import { spawnAndCollect } from "@t3tools/provider-core/server/snapshotProbe"; import { signalProcessGroup } from "@t3tools/provider-core/server/processGroup"; import { + claudeLocalSettingsDirectory, claudeSignedOutMessage, makeClaudeEnvironment, resolveClaudeHomePath, @@ -4010,46 +4011,18 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( }), Effect.orElseSucceed(() => undefined), ); - // A worktree's .git file points at
/.git/worktrees/, whose commondir leads back to - //
/.git. A bare repository has no main checkout, so its worktree keeps its own file. - const mainCheckoutRoot = (checkout: string) => - fileSystem.readFileString(path.join(checkout, ".git")).pipe( - Effect.flatMap((pointer) => { - const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); - if (gitDir === undefined) return Effect.succeed(checkout); - const worktreeGitDir = path.resolve(checkout, gitDir); - return fileSystem.readFileString(path.join(worktreeGitDir, "commondir")).pipe( - Effect.map((commonDir) => { - const common = path.resolve(worktreeGitDir, commonDir.trim()); - return path.basename(common) === ".git" ? path.dirname(common) : checkout; - }), - ); - }), - Effect.orElseSucceed(() => checkout), - ); - // Claude Code keeps .claude/settings.local.json at the repository root, or in cwd outside a - // repository, on Windows, or when the root is home. Its owner check is skipped: missing it only - // leaves tool search on, Claude Code's default. - const localSettingsDirectory = Effect.fn("ClaudeAdapterV2.localSettingsDirectory")(function* ( - cwd: string, - ) { - if ((yield* HostProcess.Platform) === "win32") return cwd; - for (let directory = cwd; ; directory = path.dirname(directory)) { - const isCheckout = yield* fileSystem - .exists(path.join(directory, ".git")) - .pipe(Effect.orElseSucceed(() => false)); - if (isCheckout) { - const root = yield* mainCheckoutRoot(directory); - return root === (yield* HostProcess.HomeDirectory) ? cwd : root; - } - if (path.dirname(directory) === directory) return cwd; - } - }); // Settings env beats the process env, local over project over user, and a local file an older // Claude Code left in cwd applies under the root one. resolveSettings reads local from cwd only. + // The root file's owner check is skipped: missing it only leaves tool search on, the default. const resolveToolSearchOverride = Effect.fn("ClaudeAdapterV2.resolveToolSearchOverride")( function* (cwd: string | null) { - const localDirectory = cwd === null ? null : yield* localSettingsDirectory(cwd); + const localDirectory = + cwd === null + ? null + : yield* claudeLocalSettingsDirectory(cwd).pipe( + Effect.provideService(FileSystem.FileSystem, fileSystem), + Effect.provideService(Path.Path, path), + ); const settingsFiles = [ ...(cwd === null || localDirectory === null ? [] diff --git a/apps/server/src/provider/ClaudeCodeModelRoutes.ts b/apps/server/src/provider/ClaudeCodeModelRoutes.ts index 66a156f5d2d6c..13a17acb2bd88 100644 --- a/apps/server/src/provider/ClaudeCodeModelRoutes.ts +++ b/apps/server/src/provider/ClaudeCodeModelRoutes.ts @@ -7,6 +7,7 @@ import * as Deferred from "effect/Deferred"; import * as Effect from "effect/Effect"; import * as Ref from "effect/Ref"; +import { claudeLocalSettingsDirectory } from "./Drivers/ClaudeHome.ts"; import { type ClaudeCodeReportedModel, type ClaudeModelCatalog, @@ -35,10 +36,26 @@ function claudeModelSettingsKey(settings: ClaudeSdkSettings): string { ]); } +const NO_MODEL_SETTINGS_KEY = claudeModelSettingsKey({}); + export const resolveClaudeModelSettingsKey = Effect.fn("resolveClaudeModelSettingsKey")(function* ( settingSources: ReadonlyArray, cwd: string | null, ) { + // The SDK reads the local file from cwd, Claude Code from the main checkout's root. No key, so + // no routes, when that root file touches model settings. + if (cwd !== null && settingSources.includes("local")) { + const localDirectory = yield* claudeLocalSettingsDirectory(cwd); + if (localDirectory !== cwd) { + const rootLocal = yield* Effect.tryPromise(() => + resolveClaudeSettings({ cwd: localDirectory, settingSources: ["local"] }), + ).pipe( + Effect.map(({ sources }) => sources.find(({ source }) => source === "local")?.settings), + Effect.orElseSucceed(() => undefined), + ); + if (claudeModelSettingsKey(rootLocal ?? {}) !== NO_MODEL_SETTINGS_KEY) return undefined; + } + } return yield* Effect.tryPromise(() => resolveClaudeSettings({ ...(cwd === null ? {} : { cwd }), diff --git a/apps/server/src/provider/Drivers/ClaudeDriver.ts b/apps/server/src/provider/Drivers/ClaudeDriver.ts index 7060fa00b0873..c2bc1df41993f 100644 --- a/apps/server/src/provider/Drivers/ClaudeDriver.ts +++ b/apps/server/src/provider/Drivers/ClaudeDriver.ts @@ -186,6 +186,9 @@ export const ClaudeDriver: ProviderDriver false)); }); +// A worktree's .git file points at
/.git/worktrees/, whose commondir leads back to +//
/.git. A bare repository has no main checkout, so its worktree keeps its own file. +const mainCheckoutRoot = Effect.fn("mainCheckoutRoot")(function* (checkout: string) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + return yield* fileSystem.readFileString(path.join(checkout, ".git")).pipe( + Effect.flatMap((pointer) => { + const gitDir = /^gitdir:\s*(.+)$/m.exec(pointer)?.[1]?.trim(); + if (gitDir === undefined) return Effect.succeed(checkout); + const worktreeGitDir = path.resolve(checkout, gitDir); + return fileSystem.readFileString(path.join(worktreeGitDir, "commondir")).pipe( + Effect.map((commonDir) => { + const common = path.resolve(worktreeGitDir, commonDir.trim()); + return path.basename(common) === ".git" ? path.dirname(common) : checkout; + }), + ); + }), + Effect.orElseSucceed(() => checkout), + ); +}); + +// Claude Code keeps .claude/settings.local.json at the repository root, or in cwd outside a +// repository, on Windows, or when the root is home. Its owner check is skipped, so callers must +// stay safe when Claude Code ignores the root file. +export const claudeLocalSettingsDirectory = Effect.fn("claudeLocalSettingsDirectory")(function* ( + cwd: string, +) { + const fileSystem = yield* FileSystem.FileSystem; + const path = yield* Path.Path; + if ((yield* HostProcess.Platform) === "win32") return cwd; + for (let directory = cwd; ; directory = path.dirname(directory)) { + const isCheckout = yield* fileSystem + .exists(path.join(directory, ".git")) + .pipe(Effect.orElseSucceed(() => false)); + if (isCheckout) { + const root = yield* mainCheckoutRoot(directory); + return root === (yield* HostProcess.HomeDirectory) ? cwd : root; + } + if (path.dirname(directory) === directory) return cwd; + } +}); + export const makeClaudeContinuationGroupKey = Effect.fn("makeClaudeContinuationGroupKey")( function* ( config: Pick, From 7ed38fb741cbbc9af2941cd2a4512a5e2130dcdb Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Sun, 1 Nov 2026 12:32:19 +0000 Subject: [PATCH 07/13] refactor(server): drop an always-true guard and an extra span from the local settings lookup Co-Authored-By: Claude Opus 5.5 --- .../src/provider/ClaudeCodeModelRoutes.ts | 20 +++++++++---------- .../server/src/provider/Drivers/ClaudeHome.ts | 2 +- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/apps/server/src/provider/ClaudeCodeModelRoutes.ts b/apps/server/src/provider/ClaudeCodeModelRoutes.ts index 13a17acb2bd88..ba4388c98a722 100644 --- a/apps/server/src/provider/ClaudeCodeModelRoutes.ts +++ b/apps/server/src/provider/ClaudeCodeModelRoutes.ts @@ -44,17 +44,15 @@ export const resolveClaudeModelSettingsKey = Effect.fn("resolveClaudeModelSettin ) { // The SDK reads the local file from cwd, Claude Code from the main checkout's root. No key, so // no routes, when that root file touches model settings. - if (cwd !== null && settingSources.includes("local")) { - const localDirectory = yield* claudeLocalSettingsDirectory(cwd); - if (localDirectory !== cwd) { - const rootLocal = yield* Effect.tryPromise(() => - resolveClaudeSettings({ cwd: localDirectory, settingSources: ["local"] }), - ).pipe( - Effect.map(({ sources }) => sources.find(({ source }) => source === "local")?.settings), - Effect.orElseSucceed(() => undefined), - ); - if (claudeModelSettingsKey(rootLocal ?? {}) !== NO_MODEL_SETTINGS_KEY) return undefined; - } + const localDirectory = cwd === null ? null : yield* claudeLocalSettingsDirectory(cwd); + if (localDirectory !== null && localDirectory !== cwd) { + const rootLocal = yield* Effect.tryPromise(() => + resolveClaudeSettings({ cwd: localDirectory, settingSources: ["local"] }), + ).pipe( + Effect.map(({ sources }) => sources.find(({ source }) => source === "local")?.settings), + Effect.orElseSucceed(() => undefined), + ); + if (claudeModelSettingsKey(rootLocal ?? {}) !== NO_MODEL_SETTINGS_KEY) return undefined; } return yield* Effect.tryPromise(() => resolveClaudeSettings({ diff --git a/apps/server/src/provider/Drivers/ClaudeHome.ts b/apps/server/src/provider/Drivers/ClaudeHome.ts index 4f79c0344ccc5..bbbc015dc5365 100644 --- a/apps/server/src/provider/Drivers/ClaudeHome.ts +++ b/apps/server/src/provider/Drivers/ClaudeHome.ts @@ -57,7 +57,7 @@ export const hasClaudeManagedMcpConfig = Effect.fn("hasClaudeManagedMcpConfig")( // A worktree's .git file points at
/.git/worktrees/, whose commondir leads back to //
/.git. A bare repository has no main checkout, so its worktree keeps its own file. -const mainCheckoutRoot = Effect.fn("mainCheckoutRoot")(function* (checkout: string) { +const mainCheckoutRoot = Effect.fnUntraced(function* (checkout: string) { const fileSystem = yield* FileSystem.FileSystem; const path = yield* Path.Path; return yield* fileSystem.readFileString(path.join(checkout, ".git")).pipe( From 9c5624282e8840f07f11bb1f46cb4d9d38facb18 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Sun, 1 Nov 2026 13:22:03 +0000 Subject: [PATCH 08/13] fix(claude): keep launch settings from redirecting model aliases --- .../Adapters/ClaudeAdapterV2.test.ts | 21 +++++++++++++++++-- .../Adapters/ClaudeAdapterV2.ts | 19 ++++++++++------- .../src/provider/ClaudeCodeModelRoutes.ts | 6 +++++- 3 files changed, 35 insertions(+), 11 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index bfac38e7d7683..651f9aadd9a85 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -2961,7 +2961,10 @@ describe("ClaudeAdapterV2 background wake turns", () => { readonly freshQueueOnReopen?: boolean; readonly forkSession?: ClaudeAdapterV2.ClaudeAgentSdkQueryRunnerShape["forkSession"]; readonly allocateSessionId?: Effect.Effect; - readonly modelCatalog?: (cwd: string | null) => Effect.Effect; + readonly modelCatalog?: ( + cwd: string | null, + launchSettings?: ClaudeSdk.Settings, + ) => Effect.Effect; }) => Effect.gen(function* () { const fileSystem = yield* FileSystem.FileSystem; @@ -4846,11 +4849,18 @@ describe("ClaudeAdapterV2 background wake turns", () => { { value: "haiku", resolvedModel: "us.anthropic.claude-haiku-4-5-20251001-v1:0" }, ]); yield* routes.settle; - const openedModel = (cwd: string) => + const openedModel = (cwd: string, launchSettings?: Record) => Effect.scoped( Effect.gen(function* () { const harness = yield* makeWakeHarnessWithOptions({ modelCatalog: routes.catalogFor, + settings: { + ...DEFAULT_CLAUDE_SETTINGS, + launchArgs: + launchSettings === undefined + ? "" + : `--settings '${JSON.stringify(launchSettings)}'`, + }, }); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ @@ -4876,6 +4886,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); assert.equal(yield* openedModel(project), "haiku"); + assert.equal( + yield* openedModel(project, { + env: { ANTHROPIC_DEFAULT_HAIKU_MODEL: "global.anthropic.claude-sonnet-4-6" }, + }), + "claude-haiku-4-5", + ); + assert.equal(yield* openedModel(project, { env: { FOO: "1" } }), "haiku"); // Claude Code resolves `haiku` to Sonnet there, so the slug is the only safe id. assert.equal(yield* openedModel(overridden), "claude-haiku-4-5"); diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 5c7ef0e9df613..2650bcc1573b6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4491,7 +4491,10 @@ export interface ClaudeAdapterV2Options { * The instance's model catalog with Claude Code's reported model ids for a turn's cwd, read per * turn so turns send ids its backend accepts. Defaults to the bundled catalog. */ - readonly modelCatalog?: (cwd: string | null) => Effect.Effect; + readonly modelCatalog?: ( + cwd: string | null, + launchSettings?: ClaudeSdkSettings, + ) => Effect.Effect; readonly continuationRequests?: { readonly offer: ( request: ProviderContinuationRequests.ProviderContinuationRequest, @@ -10099,7 +10102,13 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : { allowedTools: queryPolicy.allowedTools }), }); const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); - const catalog = yield* modelCatalog(turnInput.runtimePolicy.cwd); + const launchArgSettings = yield* readClaudeLaunchArgSettings({ + settings: adapterOptions.settings, + cwd: turnInput.runtimePolicy.cwd, + fileSystem, + path, + }); + const catalog = yield* modelCatalog(turnInput.runtimePolicy.cwd, launchArgSettings); const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); @@ -10186,12 +10195,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( fileSystem, path, }); - const launchArgSettings = yield* readClaudeLaunchArgSettings({ - settings: adapterOptions.settings, - cwd: turnInput.runtimePolicy.cwd, - fileSystem, - path, - }); const closedExistingNativeThreadId = existing !== null ? existing.nativeThreadId : null; if (existing !== null) { yield* existing.query.close.pipe(Effect.ignore); diff --git a/apps/server/src/provider/ClaudeCodeModelRoutes.ts b/apps/server/src/provider/ClaudeCodeModelRoutes.ts index f470d77fd9bca..ddf9578df8473 100644 --- a/apps/server/src/provider/ClaudeCodeModelRoutes.ts +++ b/apps/server/src/provider/ClaudeCodeModelRoutes.ts @@ -86,10 +86,14 @@ export const makeClaudeCodeModelRoutes = Effect.fn("makeClaudeCodeModelRoutes")( yield* Ref.set(reported, { models, settingsKey: yield* settingsKey(probeCwd) }); }), settle: Deferred.succeed(firstProbeSettled, undefined).pipe(Effect.asVoid), - catalogFor: Effect.fn("ClaudeCodeModelRoutes.catalogFor")(function* (cwd: string | null) { + catalogFor: Effect.fn("ClaudeCodeModelRoutes.catalogFor")(function* ( + cwd: string | null, + launchSettings?: ClaudeSdkSettings, + ) { // Leave room for the sequential version and capability probes before falling back. yield* Deferred.await(firstProbeSettled).pipe(Effect.timeoutOption("2 minutes")); const base = yield* catalog; + if (claudeModelSettingsKey(launchSettings ?? {}) !== NO_MODEL_SETTINGS_KEY) return base; const { models, settingsKey: probeKey } = yield* Ref.get(reported); const routed = withClaudeCodeModelRoutes(base, models); // Project settings can point an alias at another model, so routes hold only where the From aabed5d85a42ac3d562415faaf230142c2583961 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 02:23:50 +0000 Subject: [PATCH 09/13] fix(claude): reuse live continuations before reading launch settings --- .../Adapters/ClaudeAdapterV2.test.ts | 9 ++++++++- .../orchestration-v2/Adapters/ClaudeAdapterV2.ts | 16 ++++++++-------- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1ed9e95ca099e..9abf10b8a09f1 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -10996,7 +10996,13 @@ describe("ClaudeAdapterV2 background wake turns", () => { it.effect("leaves buffered wake messages for the continuation queued behind a user turn", () => Effect.scoped( Effect.gen(function* () { - const harness = yield* makeWakeHarness; + const fileSystem = yield* FileSystem.FileSystem; + const directory = yield* fileSystem.makeTempDirectoryScoped(); + const settingsPath = NodePath.join(directory, "settings.json"); + yield* fileSystem.writeFileString(settingsPath, "{}"); + const harness = yield* makeWakeHarnessWithOptions({ + settings: { ...DEFAULT_CLAUDE_SETTINGS, launchArgs: `--settings "${settingsPath}"` }, + }); const now = yield* DateTime.now; yield* harness.runtime.startTurn( @@ -11047,6 +11053,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + yield* fileSystem.remove(settingsPath); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index b8d07a3b0ecd9..42ccb987671a6 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -10183,14 +10183,6 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( : { allowedTools: queryPolicy.allowedTools }), }); const queryPolicyKey = claudeEffectiveQueryPolicyKey(queryPolicy, mcpOverrides); - const launchArgSettings = yield* readClaudeLaunchArgSettings({ - settings: adapterOptions.settings, - cwd: turnInput.runtimePolicy.cwd, - fileSystem, - path, - }); - const catalog = yield* modelCatalog(turnInput.runtimePolicy.cwd, launchArgSettings); - const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); const resumeSessionAt = yield* getNativeConversationHeadId(turnInput.providerThread); const existing = yield* Ref.get(queryContext); // Continuations drain existing output without prompting; retain the live process regardless of selection. @@ -10202,6 +10194,14 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return existing; } + const launchArgSettings = yield* readClaudeLaunchArgSettings({ + settings: adapterOptions.settings, + cwd: turnInput.runtimePolicy.cwd, + fileSystem, + path, + }); + const catalog = yield* modelCatalog(turnInput.runtimePolicy.cwd, launchArgSettings); + const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); if ( existing !== null && existing.nativeThreadId === nativeThreadId && From 3b265896dc5fa6964230e4ef21f2f2ea1f5fcfc0 Mon Sep 17 00:00:00 2001 From: maria-rcks Date: Mon, 2 Nov 2026 02:31:59 +0000 Subject: [PATCH 10/13] fix(claude): retain loaded settings for live query reuse --- .../Adapters/ClaudeAdapterV2.test.ts | 2 +- .../Adapters/ClaudeAdapterV2.ts | 22 ++++++++++++++++--- 2 files changed, 20 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 9abf10b8a09f1..36c668ce77df9 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -11022,6 +11022,7 @@ describe("ClaudeAdapterV2 background wake turns", () => { yield* Queue.offer(harness.sdkMessages, wakeResult); yield* harness.awaitContinuation(1); + yield* fileSystem.remove(settingsPath); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, @@ -11053,7 +11054,6 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); - yield* fileSystem.remove(settingsPath); yield* harness.runtime.startTurn( makeClaudeTestTurnInput({ threadId: harness.threadId, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 42ccb987671a6..d580c5be2c2ed 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -4113,6 +4113,7 @@ interface ClaudeLiveQueryContext { readonly nativeThreadId: string; readonly query: ClaudeAgentSdkQuerySession; readonly queryPolicyKey: string; + readonly launchArgSettings: ClaudeSdkSettings | undefined; selection: CompiledClaudeModelSelection; readonly closed: Deferred.Deferred; lastTurn: ActiveClaudeTurnContext | null; @@ -10194,14 +10195,22 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( ) { return existing; } - const launchArgSettings = yield* readClaudeLaunchArgSettings({ + const reuseLaunchSettings = + existing !== null && + existing.nativeThreadId === nativeThreadId && + existing.queryPolicyKey === queryPolicyKey && + !existing.stopping; + const readLaunchSettings = readClaudeLaunchArgSettings({ settings: adapterOptions.settings, cwd: turnInput.runtimePolicy.cwd, fileSystem, path, }); - const catalog = yield* modelCatalog(turnInput.runtimePolicy.cwd, launchArgSettings); - const compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); + let launchArgSettings = reuseLaunchSettings + ? existing.launchArgSettings + : yield* readLaunchSettings; + let catalog = yield* modelCatalog(turnInput.runtimePolicy.cwd, launchArgSettings); + let compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); if ( existing !== null && existing.nativeThreadId === nativeThreadId && @@ -10269,6 +10278,12 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return yield* new ClaudeBackgroundWorkBlocksQueryReplacementError(); } + if (reuseLaunchSettings) { + launchArgSettings = yield* readLaunchSettings; + catalog = yield* modelCatalog(turnInput.runtimePolicy.cwd, launchArgSettings); + compiledSelection = compileClaudeModelSelection(turnInput.modelSelection, catalog); + } + // Read before closing the live process so a bad path leaves it running. const appendSystemPromptFileText = yield* readClaudeAppendSystemPromptFile({ settings: adapterOptions.settings, @@ -10376,6 +10391,7 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( nativeThreadId, query: querySession, queryPolicyKey, + launchArgSettings, selection: compiledSelection, closed, lastTurn: null, From 00514a7df61c3567cb8f7b919960e77c044b3290 Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Wed, 11 Nov 2026 23:38:28 +0000 Subject: [PATCH 11/13] fix(server): Claude steers and effort changes keep the reported model id A steer or a live effort change compiled the selection against the unrouted catalog. On Bedrock with availableModels, a same-model steer called setModel with the catalog slug, which Claude Code rejects, so the steer failed; an effort change saw a different model id and was dropped. Both now compile against the catalog routed for the live query's directory and launch settings, as the query open does. Co-Authored-By: Claude Opus 5.5 --- .../Adapters/ClaudeAdapterV2.test.ts | 68 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 21 ++++-- 2 files changed, 85 insertions(+), 4 deletions(-) diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 1618a268e3205..dfcd5f0389269 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -6069,6 +6069,74 @@ describe("ClaudeAdapterV2 background wake turns", () => { ), ); + it.effect("keeps the reported model id when a running turn is steered or changes effort", () => + Effect.scoped( + Effect.gen(function* () { + const harness = yield* makeWakeHarnessWithOptions({ + routeModelCatalog: (catalog) => + Effect.succeed( + withClaudeCodeModelRoutes(catalog, [ + { value: "haiku", resolvedModel: "global.anthropic.claude-haiku-5-5" }, + ]), + ), + }); + const idAllocator = yield* IdAllocator.IdAllocatorV2; + const haiku = { + instanceId: ClaudeAdapterV2.CLAUDE_DEFAULT_INSTANCE_ID, + model: "claude-haiku-5-5", + } satisfies ModelSelection; + const input = makeClaudeTestTurnInput({ + threadId: harness.threadId, + providerThread: harness.providerThread, + now: yield* DateTime.now, + attemptId: RunAttemptId.make("attempt-reported-model-live-change"), + text: "Run the slow build.", + attachments: [], + modelSelection: haiku, + }); + yield* harness.runtime.startTurn(input); + assert.equal(harness.getOpenedOptions()?.model, "haiku"); + + yield* harness.runtime.steerTurn({ + threadId: harness.threadId, + runId: input.runId, + providerThread: harness.providerThread, + providerTurnId: idAllocator.derive.providerTurn({ + driver: ClaudeAdapterV2.CLAUDE_PROVIDER, + nativeTurnId: `turn:${input.attemptId}`, + }), + modelSelection: haiku, + message: { + createdBy: "user", + creationSource: "web", + messageId: MessageId.make("steer-reported-model"), + text: "Also check the tests.", + attachments: [], + }, + }); + assert.deepEqual(harness.liveSelectionChanges, []); + assert.lengthOf(harness.offeredMessages, 2); + + yield* harness.runtime.applyModelSelection!({ + providerThread: harness.providerThread, + modelSelection: { ...haiku, options: [{ id: "effort", value: "high" }] }, + }); + assert.deepEqual(harness.liveSelectionChanges, [ + { effortLevel: "high", fastMode: null, ultracode: null }, + ]); + }).pipe( + Effect.provide( + Layer.mergeAll( + IdAllocator.layer, + McpProviderSessions.layer, + NodeServices.layer, + NodeCrypto.layer, + ), + ), + ), + ), + ); + it.effect.each([ { content: { type: "advisor_tool_result_error", error_code: "overloaded" }, diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 16019f970f31e..009a7d27b99ff 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -11172,6 +11172,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( return false; }); + const routedCatalogFor = (live: ClaudeLiveQueryContext) => + routeModelCatalog(modelCatalog, live.settingsCwd, live.launchArgSettings); + const applySelectionLive = Effect.fnUntraced(function* ( live: ClaudeLiveQueryContext, selection: CompiledClaudeModelSelection, @@ -12067,7 +12070,10 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( const steerSelection = turnInput.modelSelection === undefined ? undefined - : compileClaudeModelSelection(turnInput.modelSelection, modelCatalog); + : compileClaudeModelSelection( + turnInput.modelSelection, + yield* routedCatalogFor(existing), + ); const liveSteerSelection = steerSelection !== undefined && existing.selection.queryIdentity !== steerSelection.queryIdentity && @@ -12477,12 +12483,19 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* ( modelSelection, }) { const live = yield* Ref.get(queryContext); - const selection = compileClaudeModelSelection(modelSelection, modelCatalog); - // A model change still waits for the next turn, which checks it against the turn's permission mode. if ( live === null || live.stopping || - live.nativeThreadId !== providerThread.nativeThreadRef?.nativeId || + live.nativeThreadId !== providerThread.nativeThreadRef?.nativeId + ) { + return; + } + const selection = compileClaudeModelSelection( + modelSelection, + yield* routedCatalogFor(live), + ); + // A model change still waits for the next turn, which checks it against the turn's permission mode. + if ( live.selection.queryIdentity === selection.queryIdentity || live.selection.apiModelId !== selection.apiModelId || !canApplyClaudeSelectionLive(live.selection, selection) From ee0ea0cea62a78116e46a777834ce0de3c57b01f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 00:26:56 +0000 Subject: [PATCH 12/13] fix(server): a timed-out first Claude probe wait is not repeated When the first capability probe never settled, each later turn, steer and effort change waited the full two minutes again before falling back. The expired wait now counts as settled, so only the first call waits. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/provider/ClaudeCodeModelRoutes.test.ts | 7 +++++++ apps/server/src/provider/ClaudeCodeModelRoutes.ts | 8 ++++++-- 2 files changed, 13 insertions(+), 2 deletions(-) diff --git a/apps/server/src/provider/ClaudeCodeModelRoutes.test.ts b/apps/server/src/provider/ClaudeCodeModelRoutes.test.ts index db317833f337a..2c06148cac680 100644 --- a/apps/server/src/provider/ClaudeCodeModelRoutes.test.ts +++ b/apps/server/src/provider/ClaudeCodeModelRoutes.test.ts @@ -2,6 +2,7 @@ import { assert, describe, it } from "@effect/vitest"; import { type ModelSelection, ProviderInstanceId } from "@t3tools/contracts"; import * as Effect from "effect/Effect"; import * as Fiber from "effect/Fiber"; +import * as Option from "effect/Option"; import * as TestClock from "effect/testing/TestClock"; import { makeClaudeCodeModelRoutes } from "./ClaudeCodeModelRoutes.ts"; @@ -55,6 +56,12 @@ describe("Claude Code model routes", () => { .pipe(Effect.forkChild({ startImmediately: true })); yield* TestClock.adjust("2 minutes"); assert.strictEqual(yield* Fiber.join(fallback), BUNDLED_CLAUDE_MODEL_CATALOG); + // Later turns, steers and effort changes do not wait for the stuck probe again. + const later = yield* stuck + .route(BUNDLED_CLAUDE_MODEL_CATALOG, "/project") + .pipe(Effect.timeoutOption("1 second"), Effect.forkChild({ startImmediately: true })); + yield* TestClock.adjust("1 second"); + assert.deepStrictEqual(yield* Fiber.join(later), Option.some(BUNDLED_CLAUDE_MODEL_CATALOG)); }), ); }); diff --git a/apps/server/src/provider/ClaudeCodeModelRoutes.ts b/apps/server/src/provider/ClaudeCodeModelRoutes.ts index 4dd4e1607a3c1..6a9e7a48e64c5 100644 --- a/apps/server/src/provider/ClaudeCodeModelRoutes.ts +++ b/apps/server/src/provider/ClaudeCodeModelRoutes.ts @@ -98,8 +98,12 @@ export const makeClaudeCodeModelRoutes = Effect.fn("makeClaudeCodeModelRoutes")( cwd: string | null, launchSettings?: ClaudeSdkSettings, ) { - // Leave room for the sequential version and capability probes before falling back. - yield* Deferred.await(firstProbeSettled).pipe(Effect.timeoutOption("2 minutes")); + // Leave room for the sequential version and capability probes before falling back once; + // an expired wait counts as settled so later calls do not wait again. + yield* Deferred.await(firstProbeSettled).pipe( + Effect.timeoutOption("2 minutes"), + Effect.andThen(Deferred.succeed(firstProbeSettled, undefined)), + ); if (claudeModelSettingsKey(launchSettings ?? {}) !== NO_MODEL_SETTINGS_KEY) return base; const { models, settingsKey: probeKey } = yield* Ref.get(reported); const routed = withClaudeCodeModelRoutes(base, models); From 1a18cb66288cb3e4131e24a24bd16301b8f6be5f Mon Sep 17 00:00:00 2001 From: Victor Vogel Date: Thu, 12 Nov 2026 08:51:46 +0000 Subject: [PATCH 13/13] fix(server): a Claude Code alias the Bedrock route picks is not pinned to itself Main's alias pin for custom models named like an alias also caught the alias this branch routes a Bedrock model to, so Claude Code sent the bare alias instead of the user's ANTHROPIC_DEFAULT_*_MODEL id. The selection now records which alias it pins, only when the picked model itself has the alias's name, and a change of pinned alias reopens Claude Code instead of switching the live process. Co-Authored-By: Claude Opus 5.5 --- apps/server/src/claudeModelOptions.ts | 19 ++++++++++- .../Adapters/ClaudeAdapterV2.test.ts | 34 +++++++++++++++++++ .../Adapters/ClaudeAdapterV2.ts | 18 +++++----- 3 files changed, 61 insertions(+), 10 deletions(-) diff --git a/apps/server/src/claudeModelOptions.ts b/apps/server/src/claudeModelOptions.ts index 7d815f5f9af55..12530db50ec5e 100644 --- a/apps/server/src/claudeModelOptions.ts +++ b/apps/server/src/claudeModelOptions.ts @@ -12,14 +12,19 @@ import { normalizeClaudeCatalogEffort, resolveClaudeCatalogApiModelId, resolveClaudeCatalogEffort, + resolveClaudeModelSlug, type ClaudeModelCatalog, } from "./provider/ClaudeModelCatalog.ts"; +const CLAUDE_CODE_MODEL_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]); + export interface CompiledClaudeModelSelection { readonly apiModelId: string; readonly effort: string | undefined; readonly promptEffort: string | undefined; readonly settings: Readonly>; + /** The alias of a custom model named like one, which Claude Code would otherwise expand. */ + readonly pinnedAlias: string | undefined; readonly queryIdentity: string; } @@ -51,11 +56,23 @@ export function compileClaudeModelSelection( }; const apiModelId = resolveClaudeCatalogApiModelId(catalog, selection); const promptEffort = resolvePromptInjectedEffort(capabilities, rawEffort) ?? undefined; + // An alias the catalog routes a built-in model to is Claude Code's own and is not pinned. + const pinnedAlias = + CLAUDE_CODE_MODEL_ALIASES.has(apiModelId) && + resolveClaudeModelSlug(catalog, selection.model) === apiModelId + ? apiModelId + : undefined; return { apiModelId, effort, promptEffort, settings, - queryIdentity: JSON.stringify({ apiModelId, effort: effort ?? null, settings }), + pinnedAlias, + queryIdentity: JSON.stringify({ + apiModelId, + effort: effort ?? null, + settings, + pinnedAlias: pinnedAlias ?? null, + }), }; } diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts index 4b70707151c86..4b46b28aa2e37 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts @@ -167,6 +167,9 @@ const CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ }, ], }); +const ALIAS_CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ + customModels: ["haiku", "sonnet"], +}); const ULTRACODE_CUSTOM_MODEL_CLAUDE_SETTINGS = Schema.decodeSync(ClaudeSettings)({ customModels: [ { @@ -6170,6 +6173,9 @@ describe("ClaudeAdapterV2 background wake turns", () => { const modelCatalog = yield* Ref.make(BUNDLED_CLAUDE_MODEL_CATALOG); const harness = yield* makeWakeHarnessWithOptions({ modelCatalog: Ref.get(modelCatalog), + settings: ALIAS_CUSTOM_MODEL_CLAUDE_SETTINGS, + close: Queue.shutdown, + freshQueueOnReopen: true, // Bedrock with ANTHROPIC_DEFAULT_*_MODEL set, as the probe reports it. routeModelCatalog: (catalog) => Effect.succeed( @@ -6213,13 +6219,41 @@ describe("ClaudeAdapterV2 background wake turns", () => { ); yield* Ref.set(modelCatalog, manifestCatalog); + const openedPins = () => { + const env = ( + harness.getOpenedOptions()?.settings as { env?: Record } | undefined + )?.env; + return [env?.ANTHROPIC_DEFAULT_HAIKU_MODEL, env?.ANTHROPIC_DEFAULT_SONNET_MODEL]; + }; yield* harness.runtime.startTurn(turn(1, "claude-haiku-6")); assert.equal(harness.getOpenedOptions()?.model, "haiku"); + // The user's ANTHROPIC_DEFAULT_HAIKU_MODEL maps the routed alias to the Bedrock id. + assert.deepEqual(openedPins(), [undefined, undefined]); yield* harness.offerAndWait(turnOneResult); yield* Queue.take(harness.terminalReceipts); yield* harness.runtime.startTurn(turn(2, "claude-sonnet-6")); assert.deepEqual(harness.liveSelectionChanges[0], { model: "sonnet" }); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000b02", result: "Two." }), + ); + yield* Queue.take(harness.terminalReceipts); + + // Custom models named like an alias pin it, so a change of pin reopens Claude Code. + yield* harness.runtime.startTurn(turn(3, "haiku")); + assert.deepEqual(openedPins(), ["haiku", undefined]); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000b03", result: "Three." }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.runtime.startTurn(turn(4, "sonnet")); + assert.deepEqual(openedPins(), [undefined, "sonnet"]); + yield* harness.offerAndWait( + makeResultFrame({ uuid: "00000000-0000-4000-8000-000000000b04", result: "Four." }), + ); + yield* Queue.take(harness.terminalReceipts); + yield* harness.runtime.startTurn(turn(5, "claude-haiku-6")); + assert.deepEqual(openedPins(), [undefined, undefined]); }).pipe( Effect.provide( Layer.mergeAll( diff --git a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts index 7da96ae4eb31d..4823f6f2d84de 100644 --- a/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts +++ b/apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts @@ -1852,7 +1852,8 @@ function canApplyClaudeSelectionLive( ): boolean { return ( live.settings.alwaysThinkingEnabled === next.settings.alwaysThinkingEnabled && - (next.effort !== undefined || live.effort === undefined) + (next.effort !== undefined || live.effort === undefined) && + live.pinnedAlias === next.pinnedAlias ); } @@ -1940,8 +1941,6 @@ const subagentDeviceInstructions: HookCallback = () => }, }); -const CLAUDE_CODE_MODEL_ALIASES = new Set(["opus", "sonnet", "haiku", "fable"]); - export function makeClaudeQueryOptions(input: { readonly modelSelection: ModelSelection; readonly modelCatalog?: ClaudeModelCatalog; @@ -2019,12 +2018,13 @@ export function makeClaudeQueryOptions(input: { } as ClaudeSdkSettings); // Claude Code expands a bare alias to its own model, so a custom model with an alias's name // pins the alias to itself. - const aliasOverrideEnv = CLAUDE_CODE_MODEL_ALIASES.has(compiledSelection.apiModelId) - ? { - [`ANTHROPIC_DEFAULT_${compiledSelection.apiModelId.toUpperCase()}_MODEL`]: - compiledSelection.apiModelId, - } - : undefined; + const aliasOverrideEnv = + compiledSelection.pinnedAlias === undefined + ? undefined + : { + [`ANTHROPIC_DEFAULT_${compiledSelection.pinnedAlias.toUpperCase()}_MODEL`]: + compiledSelection.pinnedAlias, + }; // A settings file path cannot take extra keys, so it goes through unchanged. const effectiveQuerySettings = (input.toolSearchOverride === undefined && aliasOverrideEnv === undefined) ||