diff --git a/.agents/upstream-review.md b/.agents/upstream-review.md index 4ae463c5e..75a4766f4 100644 --- a/.agents/upstream-review.md +++ b/.agents/upstream-review.md @@ -15,6 +15,34 @@ Two standing sections outlive any single batch and must be read on every review: ## Review batches +## 2026-09-02 — scoped adoption, cursor unchanged + +**Not a batch review.** The developer asked for upstream's Claude Fable 5.1 +support specifically, ahead of any other upstream work, so this session reviewed +exactly one change set out of the 129 commits sitting in +`9b2d04317c68233782e0630464ac86d77d0686f3..57a66608b918d673eeec7e6c94ea5906b756fcd0`. +The other 128 commits were **not** reviewed and have received no decision. + +`reviewed-through` therefore stays at `9b2d0431`. A later full review must treat +the whole range as unreviewed apart from the row below, which it should skip as +already adopted. + +| ID | Upstream | Decision | Pylon reference | Notes | +| --- | ---------------------------------------------- | -------- | --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| S-1 | `c17d02cff` / `#9078` — Claude Fable 5.1 model | adopted | `#247` | Clean `cherry-pick -x`, no conflicts, applied line-for-line. Self-contained: it does not depend on `#9084` (remote manifest discovery), which is the large `ClaudeProvider.ts` restructure that follows it upstream and was deliberately left out of scope. | + +Two consequences worth carrying forward. The bundled manifest swap promotes +`claude-fable-5-1` into the current Claude set and thereby marks +`claude-fable-5` **legacy** — that is upstream's intent, since 5.1 supersedes 5 +in the same tier, but it is a user-visible change to an existing model's status. +And the catalog is gated on Claude Code `v2.1.257`, so the model stays hidden on +older CLIs rather than failing at spawn time. + +`#9084` (`03542836`, remote Claude model manifest) is a separate 26-file +restructure that another session had begun stacking on the same branch. It was +split back out and is **not** adopted here; it remains undecided in the +unreviewed range above. + ## 2026-09-01 — `1f8ed54add4133ac39effceded8fc1fff12d8e03..9b2d04317c68233782e0630464ac86d77d0686f3` Twenty-nine upstream commits, reviewed as change sets C-1 through C-21. The diff --git a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts index de6f7d903..689eee02b 100644 --- a/apps/server/src/provider/Layers/ClaudeAdapter.test.ts +++ b/apps/server/src/provider/Layers/ClaudeAdapter.test.ts @@ -527,6 +527,29 @@ describe("ClaudeAdapterLive", () => { ); }); + it.effect("preserves xhigh effort for Claude Fable 5.1", () => { + const harness = makeHarness(); + return Effect.gen(function* () { + const adapter = yield* ClaudeAdapter; + yield* adapter.startSession({ + threadId: THREAD_ID, + provider: ProviderDriverKind.make("claudeAgent"), + modelSelection: createModelSelection( + ProviderInstanceId.make("claudeAgent"), + "claude-fable-5-1", + [{ id: "effort", value: "xhigh" }], + ), + runtimeMode: "full-access", + }); + + const createInput = harness.getLastCreateQueryInput(); + assert.equal(createInput?.options.effort, "xhigh"); + }).pipe( + Effect.provideService(Random.Random, makeDeterministicRandomService()), + Effect.provide(harness.layer), + ); + }); + it.effect("preserves xhigh effort for Claude Fable 5", () => { const harness = makeHarness(); return Effect.gen(function* () { diff --git a/apps/server/src/provider/Layers/ClaudeProvider.ts b/apps/server/src/provider/Layers/ClaudeProvider.ts index db21a83f0..be33627b5 100644 --- a/apps/server/src/provider/Layers/ClaudeProvider.ts +++ b/apps/server/src/provider/Layers/ClaudeProvider.ts @@ -56,12 +56,48 @@ const CLAUDE_PRESENTATION = { showInteractionModeToggle: true, supportsConversationRollback: false, } as const; +const MINIMUM_CLAUDE_FABLE_5_1_VERSION = "2.1.257"; const MINIMUM_CLAUDE_OPUS_5_VERSION = "2.1.219"; const MINIMUM_CLAUDE_FABLE_5_VERSION = "2.1.169"; const MINIMUM_CLAUDE_OPUS_4_8_VERSION = "2.1.154"; const MINIMUM_CLAUDE_OPUS_4_7_VERSION = "2.1.111"; const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ + { + slug: "claude-fable-5-1", + name: "Claude Fable 5.1", + isCustom: false, + capabilities: createModelCapabilities({ + optionDescriptors: [ + buildSelectOptionDescriptor({ + id: "effort", + label: "Reasoning", + options: [ + { value: "low", label: "Low" }, + { value: "medium", label: "Medium" }, + { value: "high", label: "High", isDefault: true }, + { value: "xhigh", label: "Extra High" }, + { value: "max", label: "Max" }, + { + value: "ultracode", + label: "Ultracode", + description: "xhigh effort plus multi-agent workflow orchestration", + }, + { value: "ultrathink", label: "Ultrathink" }, + ], + promptInjectedValues: ["ultrathink"], + }), + buildSelectOptionDescriptor({ + id: "contextWindow", + label: "Context Window", + options: [ + { value: "200k", label: "200k" }, + { value: "1m", label: "1M", isDefault: true }, + ], + }), + ], + }), + }, { slug: "claude-fable-5", name: "Claude Fable 5", @@ -330,6 +366,10 @@ const CLAUDE_MODEL_CATALOG: ReadonlyArray = [ // so the catalog itself carries no `isLegacy` flags. const BUILT_IN_MODELS: ReadonlyArray = CLAUDE_MODEL_CATALOG; +function supportsClaudeFable51(version: string | null | undefined): boolean { + return version ? compareSemverVersions(version, MINIMUM_CLAUDE_FABLE_5_1_VERSION) >= 0 : false; +} + function supportsClaudeOpus5(version: string | null | undefined): boolean { return version ? compareSemverVersions(version, MINIMUM_CLAUDE_OPUS_5_VERSION) >= 0 : false; } @@ -350,6 +390,9 @@ function getBuiltInClaudeModelsForVersion( version: string | null | undefined, ): ReadonlyArray { return BUILT_IN_MODELS.filter((model) => { + if (model.slug === "claude-fable-5-1") { + return supportsClaudeFable51(version); + } if (model.slug === "claude-opus-5") { return supportsClaudeOpus5(version); } @@ -366,6 +409,11 @@ function getBuiltInClaudeModelsForVersion( }); } +function formatClaudeFable51UpgradeMessage(version: string | null): string { + const versionLabel = version ? `v${version}` : "the installed version"; + return `Claude Code ${versionLabel} is too old for Claude Fable 5.1. Upgrade to v${MINIMUM_CLAUDE_FABLE_5_1_VERSION} or newer to access it.`; +} + function formatClaudeOpus5UpgradeMessage(version: string | null): string { const versionLabel = version ? `v${version}` : "the installed version"; return `Claude Code ${versionLabel} is too old for Claude Opus 5. Upgrade to v${MINIMUM_CLAUDE_OPUS_5_VERSION} or newer to access it.`; @@ -429,6 +477,7 @@ export function normalizeClaudeCliEffort( } if ( effort === "xhigh" && + model !== "claude-fable-5-1" && model !== "claude-fable-5" && model !== "claude-opus-5" && model !== "claude-opus-4-8" && @@ -1026,15 +1075,17 @@ export const checkClaudeProviderStatus = Effect.fn("checkClaudeProviderStatus")( claudeSettings.customModels, DEFAULT_CLAUDE_MODEL_CAPABILITIES, ); - const versionUpgradeMessage = supportsClaudeOpus5(parsedVersion) + const versionUpgradeMessage = supportsClaudeFable51(parsedVersion) ? undefined - : supportsClaudeFable5(parsedVersion) - ? formatClaudeOpus5UpgradeMessage(parsedVersion) - : supportsClaudeOpus48(parsedVersion) - ? formatClaudeFable5UpgradeMessage(parsedVersion) - : supportsClaudeOpus47(parsedVersion) - ? formatClaudeOpus48UpgradeMessage(parsedVersion) - : formatClaudeOpus47UpgradeMessage(parsedVersion); + : supportsClaudeOpus5(parsedVersion) + ? formatClaudeFable51UpgradeMessage(parsedVersion) + : supportsClaudeFable5(parsedVersion) + ? formatClaudeOpus5UpgradeMessage(parsedVersion) + : supportsClaudeOpus48(parsedVersion) + ? formatClaudeFable5UpgradeMessage(parsedVersion) + : supportsClaudeOpus47(parsedVersion) + ? formatClaudeOpus48UpgradeMessage(parsedVersion) + : formatClaudeOpus47UpgradeMessage(parsedVersion); const capabilities = resolveCapabilities ? yield* resolveCapabilities(claudeSettings).pipe(Effect.orElseSucceed(() => undefined)) diff --git a/apps/server/src/provider/Layers/ProviderRegistry.test.ts b/apps/server/src/provider/Layers/ProviderRegistry.test.ts index 90fd66774..852518ed0 100644 --- a/apps/server/src/provider/Layers/ProviderRegistry.test.ts +++ b/apps/server/src/provider/Layers/ProviderRegistry.test.ts @@ -2740,6 +2740,62 @@ it.layer(Layer.mergeAll(NodeServices.layer, ServerSettingsModule.layerTest(), Te ), ); + it.effect("includes Claude Fable 5.1 on supported Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + const fable51 = status.models.find((model) => model.slug === "claude-fable-5-1"); + assert.strictEqual(fable51?.name, "Claude Fable 5.1"); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.257\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + + it.effect("hides Claude Fable 5.1 on older Claude Code versions", () => + Effect.gen(function* () { + const status = yield* checkClaudeProviderStatus( + defaultClaudeSettings, + claudeCapabilities(), + ); + assert.strictEqual( + status.models.some((model) => model.slug === "claude-fable-5-1"), + false, + ); + assert.strictEqual( + status.message, + "Claude Code v2.1.256 is too old for Claude Fable 5.1. Upgrade to v2.1.257 or newer to access it.", + ); + }).pipe( + Effect.provide( + mockSpawnerLayer((args) => { + const joined = args.join(" "); + if (joined === "--version") return { stdout: "2.1.256\n", stderr: "", code: 0 }; + if (joined === "auth status") + return { + stdout: '{"loggedIn":true,"authMethod":"claude.ai"}\n', + stderr: "", + code: 0, + }; + throw new Error(`Unexpected args: ${joined}`); + }), + ), + ), + ); + it.effect("hides Claude Fable 5 on older Claude Code versions", () => Effect.gen(function* () { const status = yield* checkClaudeProviderStatus( diff --git a/apps/server/src/provider/ModelManifest.test.ts b/apps/server/src/provider/ModelManifest.test.ts index f9c613301..31f9598a6 100644 --- a/apps/server/src/provider/ModelManifest.test.ts +++ b/apps/server/src/provider/ModelManifest.test.ts @@ -47,12 +47,16 @@ describe("isLegacyModel (bundled manifest)", () => { it("keeps only the Claude 5 family out of legacy models", () => { assert.deepStrictEqual( - ["claude-fable-5", "claude-opus-5", "claude-sonnet-5", "claude-opus-4-8"].map((model) => [ - model, - isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model), - ]), [ - ["claude-fable-5", false], + "claude-fable-5-1", + "claude-fable-5", + "claude-opus-5", + "claude-sonnet-5", + "claude-opus-4-8", + ].map((model) => [model, isLegacyModel(BUNDLED_MODEL_MANIFEST, CLAUDE, model)]), + [ + ["claude-fable-5-1", false], + ["claude-fable-5", true], ["claude-opus-5", false], ["claude-sonnet-5", false], ["claude-opus-4-8", true], diff --git a/apps/server/src/provider/model-manifest.json b/apps/server/src/provider/model-manifest.json index 7022ce226..337c32e1f 100644 --- a/apps/server/src/provider/model-manifest.json +++ b/apps/server/src/provider/model-manifest.json @@ -8,6 +8,6 @@ "gpt-daybreak-blue-latest", "gpt-daybreak-red-latest" ], - "claudeAgent": ["claude-fable-5", "claude-opus-5", "claude-sonnet-5"] + "claudeAgent": ["claude-fable-5-1", "claude-opus-5", "claude-sonnet-5"] } } diff --git a/packages/contracts/src/model.ts b/packages/contracts/src/model.ts index 65dfef3ac..3874196f3 100644 --- a/packages/contracts/src/model.ts +++ b/packages/contracts/src/model.ts @@ -179,6 +179,9 @@ export const MODEL_SLUG_ALIASES_BY_PROVIDER: Partial< "gpt-5.3-spark": "gpt-5.3-codex-spark", }, [CLAUDE_DRIVER_KIND]: { + fable: "claude-fable-5-1", + "fable-5.1": "claude-fable-5-1", + "claude-fable-5.1": "claude-fable-5-1", opus: "claude-opus-5", "opus-5": "claude-opus-5", "claude-opus-5.0": "claude-opus-5", diff --git a/packages/shared/src/model.test.ts b/packages/shared/src/model.test.ts index 9daf73da9..91942b3df 100644 --- a/packages/shared/src/model.test.ts +++ b/packages/shared/src/model.test.ts @@ -153,6 +153,7 @@ describe("model slug normalization", () => { it("preserves exact custom slugs instead of expanding provider aliases", () => { const claude = ProviderDriverKind.make("claudeAgent"); + expect(normalizeModelSlug("fable", claude)).toBe("claude-fable-5-1"); expect(normalizeModelSlug("opus", claude)).toBe("claude-opus-5"); expect(normalizeCustomModelSlug(" opus ")).toBe("opus"); });