diff --git a/docs/custom-providers.md b/docs/custom-providers.md index 9ff609eba..e31fefe06 100644 --- a/docs/custom-providers.md +++ b/docs/custom-providers.md @@ -696,7 +696,7 @@ Each entry in the `models` array: The built-in `claude` provider appends concrete model IDs from `~/.claude/settings.json` to its first-party Claude model list. Paseo reads the top-level `model` field and these `env` keys: `ANTHROPIC_MODEL`, `ANTHROPIC_SMALL_FAST_MODEL`, `ANTHROPIC_DEFAULT_OPUS_MODEL`, `ANTHROPIC_DEFAULT_SONNET_MODEL`, and `ANTHROPIC_DEFAULT_HAIKU_MODEL`. -This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top. +This lets users who already configured Claude Code for Bedrock, OpenRouter, ollama, Z.AI, or another Anthropic-compatible gateway select the exact model ID in Paseo. Explicit model IDs are passed unchanged to Claude Code, even when the same string is a compatibility alias for a built-in model. When `agents.providers.claude.models` is set it **replaces** both the hardcoded first-party Claude list and any settings.json-discovered entries; use `agents.providers.claude.additionalModels` to keep the first-party list and append curated entries on top. ### Gotcha: `extends: "claude"` with third-party endpoints diff --git a/packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts b/packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts index 9690aed03..59b2e5169 100644 --- a/packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts +++ b/packages/app/e2e/browser/schedules-edit-model-hydration.spec.ts @@ -140,7 +140,8 @@ test.describe("Schedules", () => { await expectSettled(projectTrigger); await expect(modelTrigger).toContainText("Ten second stream", { timeout: 30_000 }); await expectSettled(modelTrigger); - await expect(thinkingTrigger).toHaveCount(0); + await expect(thinkingTrigger).toContainText("Low"); + await expectSettled(thinkingTrigger); await expect(modeTrigger).toBeVisible({ timeout: 30_000 }); await expectSettled(modeTrigger); await expect(page.getByTestId("cadence-mode")).toHaveCount(0); diff --git a/packages/app/e2e/browser/schedules-project-target.spec.ts b/packages/app/e2e/browser/schedules-project-target.spec.ts index 4479213e0..15d26dde5 100644 --- a/packages/app/e2e/browser/schedules-project-target.spec.ts +++ b/packages/app/e2e/browser/schedules-project-target.spec.ts @@ -217,7 +217,8 @@ test.describe("Schedules project target", () => { const modelTrigger = page.getByTestId("schedule-model-trigger"); await expect(modelTrigger).toContainText("Ten second stream"); await expectSettled(modelTrigger); - await expect(page.getByTestId("schedule-thinking-trigger")).toHaveCount(0); + await expect(page.getByTestId("schedule-thinking-trigger")).toContainText("Low"); + await expectSettled(page.getByTestId("schedule-thinking-trigger")); await expect(page.getByTestId("schedule-mode-trigger")).toBeVisible(); await expectSettled(page.getByTestId("schedule-mode-trigger")); await expect(page.getByTestId("schedule-isolation-trigger")).toHaveCount(0); @@ -327,7 +328,8 @@ test.describe("Schedules project target", () => { await selectModelByLabel(page, "Ten second stream"); await expect(modelTrigger).toContainText("Ten second stream"); await expectSettled(modelTrigger); - await expect(thinkingTrigger).toHaveCount(0); + await expect(thinkingTrigger).toContainText("Low"); + await expectSettled(thinkingTrigger); await expect(modeTrigger).toBeVisible(); await expectSettled(modeTrigger); diff --git a/packages/app/e2e/browser/workspace-draft-thinking-preferences.spec.ts b/packages/app/e2e/browser/workspace-draft-thinking-preferences.spec.ts new file mode 100644 index 000000000..27b0bb98b --- /dev/null +++ b/packages/app/e2e/browser/workspace-draft-thinking-preferences.spec.ts @@ -0,0 +1,190 @@ +import { expect, test, type Page } from "../support/fixtures"; +import { clickNewChat, gotoWorkspace } from "../support/helpers/launcher"; +import { seedWorkspace } from "../support/helpers/seed-client"; +import { + chooseCommandCenterAgentControl, + submitDraftAgent, + waitForDraftComposer, +} from "../support/helpers/command-center-agent-controls"; +import { openCommandCenter } from "../support/helpers/command-center"; +import { selectModel } from "../support/helpers/app"; + +const DISABLE_DEFAULT_SEED_ONCE_KEY = "@paseo:e2e-disable-default-seed-once"; +const SEED_NONCE_KEY = "@paseo:e2e-seed-nonce"; + +async function openNewAgentTab(page: Page): Promise { + await clickNewChat(page); + await waitForDraftComposer(page); +} + +async function chooseDraftControl(page: Page, query: string, choice: string): Promise { + await openCommandCenter(page); + await chooseCommandCenterAgentControl({ page, query, choice }); +} + +async function expectThinkingSelected(page: Page, label: string): Promise { + await expect( + page + .getByRole("button", { name: `Select thinking option (${label})` }) + .filter({ visible: true }), + ).toBeVisible({ timeout: 30_000 }); +} + +async function expectModeSelected(page: Page, label: string): Promise { + await expect( + page.getByRole("button", { name: `Select agent mode (${label})` }).filter({ visible: true }), + ).toBeVisible({ timeout: 30_000 }); +} + +async function reloadWithPersistedPreferences(page: Page): Promise { + await page.evaluate( + ({ disableKey, nonceKey }) => { + const nonce = localStorage.getItem(nonceKey); + if (!nonce) throw new Error("Expected the Playwright seed nonce before reload"); + localStorage.setItem(disableKey, nonce); + }, + { disableKey: DISABLE_DEFAULT_SEED_ONCE_KEY, nonceKey: SEED_NONCE_KEY }, + ); + await page.reload(); + await waitForDraftComposer(page); +} + +async function readRememberedThinking(page: Page, modelId: string): Promise { + return page.evaluate((selectedModelId) => { + const raw = localStorage.getItem("@paseo:create-agent-preferences"); + if (!raw) return null; + const preferences = JSON.parse(raw) as { + providerPreferences?: { mock?: { thinkingByModel?: Record } }; + }; + return preferences.providerPreferences?.mock?.thinkingByModel?.[selectedModelId] ?? null; + }, modelId); +} + +async function seedLegacyModelPreference(page: Page): Promise { + await page.evaluate(() => { + const storageKey = "@paseo:create-agent-preferences"; + const raw = localStorage.getItem(storageKey); + const preferences = raw ? (JSON.parse(raw) as Record) : {}; + localStorage.setItem( + storageKey, + JSON.stringify({ + ...preferences, + provider: "mock", + providerPreferences: { + ...(preferences.providerPreferences as Record | undefined), + mock: { + model: "legacy-five-minute-stream", + thinkingByModel: { "legacy-five-minute-stream": "medium" }, + }, + }, + }), + ); + }); +} + +test.describe("Workspace draft thinking preferences", () => { + test.describe.configure({ timeout: 180_000 }); + + test("restores a model's remembered thinking after another model and a reload", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "draft-thinking-preferences-" }); + + try { + await gotoWorkspace(page, workspace.workspaceId); + await openNewAgentTab(page); + await chooseDraftControl( + page, + "five minute stream", + "Model › Mock Load Test › Five minute stream", + ); + await chooseDraftControl(page, "medium", "Thinking › Medium"); + await expectThinkingSelected(page, "Medium"); + await chooseDraftControl( + page, + "ten second stream", + "Model › Mock Load Test › Ten second stream", + ); + await chooseDraftControl( + page, + "five minute stream", + "Model › Mock Load Test › Five minute stream", + ); + await expectThinkingSelected(page, "Medium"); + await submitDraftAgent(page, "Remember medium thinking for this model"); + + await openNewAgentTab(page); + await expectThinkingSelected(page, "Medium"); + await chooseDraftControl( + page, + "ten second stream", + "Model › Mock Load Test › Ten second stream", + ); + await submitDraftAgent(page, "Hello from the other model"); + + await openNewAgentTab(page); + await reloadWithPersistedPreferences(page); + await expect.poll(() => readRememberedThinking(page, "five-minute-stream")).toBe("medium"); + await chooseDraftControl( + page, + "five minute stream", + "Model › Mock Load Test › Five minute stream", + ); + + await expectThinkingSelected(page, "Medium"); + } finally { + await workspace.cleanup(); + } + }); + + test("restores a provider's permission mode after another provider and a reload", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "draft-mode-preferences-" }); + + try { + await gotoWorkspace(page, workspace.workspaceId); + await openNewAgentTab(page); + await chooseDraftControl( + page, + "five minute stream", + "Model › Mock Load Test › Five minute stream", + ); + await chooseDraftControl(page, "approval test", "Mode › Approval test"); + await expectModeSelected(page, "Approval test"); + await submitDraftAgent(page, "Remember this permission mode"); + + await openNewAgentTab(page); + await expectModeSelected(page, "Approval test"); + await selectModel(page, "gpt-5.4-mini"); + + await openNewAgentTab(page); + await reloadWithPersistedPreferences(page); + await selectModel(page, "Five minute stream"); + + await expectModeSelected(page, "Approval test"); + } finally { + await workspace.cleanup(); + } + }); + + test("canonicalizes a retired model preference and restores its thinking option", async ({ + page, + }) => { + const workspace = await seedWorkspace({ repoPrefix: "draft-model-alias-preferences-" }); + + try { + await gotoWorkspace(page, workspace.workspaceId); + await openNewAgentTab(page); + await seedLegacyModelPreference(page); + await reloadWithPersistedPreferences(page); + + await expect( + page.getByRole("button", { name: "Select model (Five minute stream)" }), + ).toBeVisible({ timeout: 30_000 }); + await expectThinkingSelected(page, "Medium"); + } finally { + await workspace.cleanup(); + } + }); +}); diff --git a/packages/app/src/components/provider-diagnostic-models.test.ts b/packages/app/src/components/provider-diagnostic-models.test.ts index 37159c75a..4ad536945 100644 --- a/packages/app/src/components/provider-diagnostic-models.test.ts +++ b/packages/app/src/components/provider-diagnostic-models.test.ts @@ -42,6 +42,23 @@ describe("resolveProviderDiscoveredModels", () => { expect(refreshing.models).toEqual([grokModel]); }); + it("excludes compatibility-only models from display and cache", () => { + const compatibilityModel: AgentModelDefinition = { + ...piModel, + id: "pi/model-legacy", + label: "Pi Model legacy", + isSelectable: false, + }; + + const result = resolveModels({ + provider: "pi", + currentModels: [piModel, compatibilityModel], + }); + + expect(result.models).toEqual([piModel]); + expect(result.cache?.models).toEqual([piModel]); + }); + it("does not show one provider's cached models while another provider loads", () => { const ready = resolveModels({ provider: "pi", currentModels: [piModel] }); diff --git a/packages/app/src/components/provider-diagnostic-models.ts b/packages/app/src/components/provider-diagnostic-models.ts index d0368a43f..066fbe568 100644 --- a/packages/app/src/components/provider-diagnostic-models.ts +++ b/packages/app/src/components/provider-diagnostic-models.ts @@ -1,4 +1,5 @@ import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import { filterSelectableModels } from "@/provider-selection/model-catalog"; export interface ProviderDiscoveredModelsCache { serverId: string; @@ -26,9 +27,10 @@ export function resolveProviderDiscoveredModels({ providerSnapshotRefreshing, previousCache, }: ResolveProviderDiscoveredModelsInput): ResolveProviderDiscoveredModelsResult { - if (currentModels && currentModels.length > 0) { - const cache = { serverId, provider, models: currentModels }; - return { models: currentModels, cache }; + const selectableModels = filterSelectableModels(currentModels ?? null) ?? []; + if (selectableModels.length > 0) { + const cache = { serverId, provider, models: selectableModels }; + return { models: selectableModels, cache }; } if ( diff --git a/packages/app/src/composer/agent-controls/index.tsx b/packages/app/src/composer/agent-controls/index.tsx index 6e727fee0..3b2a3e07b 100644 --- a/packages/app/src/composer/agent-controls/index.tsx +++ b/packages/app/src/composer/agent-controls/index.tsx @@ -32,6 +32,7 @@ import { buildSelectableProviderSelectorProviders, type ProviderSelectorProvider, } from "@/provider-selection/provider-selection"; +import { filterSelectableModels } from "@/provider-selection/model-catalog"; import { useSessionStore } from "@/stores/session-store"; import { useProvidersSnapshot } from "@/hooks/use-providers-snapshot"; import { resolveProviderDefinition } from "@/utils/provider-definitions"; @@ -1448,7 +1449,7 @@ export const AgentControls = memo(function AgentControls({ [snapshotEntries, agent?.provider], ); - const models = snapshotSelectedEntry?.models ?? null; + const models = filterSelectableModels(snapshotSelectedEntry?.models ?? null); const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading"; const agentProviderDefinitions = useMemo( diff --git a/packages/app/src/composer/agent-controls/utils.test.ts b/packages/app/src/composer/agent-controls/utils.test.ts index cd6934d12..5b566fdeb 100644 --- a/packages/app/src/composer/agent-controls/utils.test.ts +++ b/packages/app/src/composer/agent-controls/utils.test.ts @@ -53,6 +53,28 @@ describe("normalizeModelId", () => { }); describe("resolveAgentModelSelection", () => { + it("resolves a configured model alias to its canonical catalog model", () => { + const selection = resolveAgentModelSelection({ + models: [ + { + provider: "claude", + id: "claude-fable-5", + aliases: ["claude-fable-5[1m]"], + label: "Fable 5", + thinkingOptions: [{ id: "high", label: "High" }], + defaultThinkingOptionId: "high", + }, + ], + runtimeModelId: null, + configuredModelId: "claude-fable-5[1m]", + explicitThinkingOptionId: null, + }); + + expect(selection.activeModelId).toBe("claude-fable-5"); + expect(selection.displayModel).toBe("Fable 5"); + expect(selection.selectedThinkingId).toBe("high"); + }); + it("prefers runtime model over configured model", () => { const selection = resolveAgentModelSelection({ models: [ diff --git a/packages/app/src/composer/agent-controls/utils.ts b/packages/app/src/composer/agent-controls/utils.ts index 7313e160b..b28b69336 100644 --- a/packages/app/src/composer/agent-controls/utils.ts +++ b/packages/app/src/composer/agent-controls/utils.ts @@ -55,7 +55,11 @@ function findModelById( if (!models || !modelId) { return null; } - return models.find((model) => model.id === modelId) ?? null; + return ( + models.find((model) => model.id === modelId) ?? + models.find((model) => model.aliases?.includes(modelId)) ?? + null + ); } function getFallbackModel(models: AgentModelDefinition[] | null): AgentModelDefinition | null { diff --git a/packages/app/src/create-agent-preferences/optimistic-preferences.test.ts b/packages/app/src/create-agent-preferences/optimistic-preferences.test.ts new file mode 100644 index 000000000..09e289d98 --- /dev/null +++ b/packages/app/src/create-agent-preferences/optimistic-preferences.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import { OptimisticFormPreferences } from "./optimistic-preferences"; + +describe("optimistic form preferences", () => { + it("removes a rejected update while preserving later queued updates", () => { + const preferences = new OptimisticFormPreferences({}); + const failed = preferences.begin({ provider: "claude" }); + const pending = preferences.begin({ isolation: "worktree" }); + + expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" }); + + preferences.reject(failed); + expect(preferences.current()).toEqual({ isolation: "worktree" }); + + preferences.commit(pending, { isolation: "worktree" }); + expect(preferences.current()).toEqual({ isolation: "worktree" }); + }); + + it("does not replace pending optimistic state with a stale external snapshot", () => { + const preferences = new OptimisticFormPreferences({ provider: "codex" }); + const pending = preferences.begin({ provider: "claude" }); + + preferences.reconcile({ provider: "codex", isolation: "local" }); + expect(preferences.current()).toEqual({ provider: "claude" }); + + preferences.commit(pending, { provider: "claude" }); + preferences.reconcile({ provider: "claude", isolation: "worktree" }); + expect(preferences.current()).toEqual({ provider: "claude", isolation: "worktree" }); + }); +}); diff --git a/packages/app/src/create-agent-preferences/optimistic-preferences.ts b/packages/app/src/create-agent-preferences/optimistic-preferences.ts new file mode 100644 index 000000000..1a111c5b6 --- /dev/null +++ b/packages/app/src/create-agent-preferences/optimistic-preferences.ts @@ -0,0 +1,43 @@ +import type { FormPreferences } from "./preferences"; +import type { FormPreferenceUpdate } from "./service"; + +type PendingUpdateId = number; + +export class OptimisticFormPreferences { + private committed: FormPreferences; + private readonly pending = new Map(); + private nextId = 0; + + constructor(initial: FormPreferences) { + this.committed = initial; + } + + current(): FormPreferences { + let current = this.committed; + for (const update of this.pending.values()) { + current = typeof update === "function" ? update(current) : { ...current, ...update }; + } + return current; + } + + begin(update: FormPreferenceUpdate): PendingUpdateId { + const id = this.nextId++; + this.pending.set(id, update); + return id; + } + + commit(id: PendingUpdateId, persisted: FormPreferences): void { + this.pending.delete(id); + this.committed = persisted; + } + + reject(id: PendingUpdateId): void { + this.pending.delete(id); + } + + reconcile(preferences: FormPreferences): void { + if (this.pending.size === 0) { + this.committed = preferences; + } + } +} diff --git a/packages/app/src/create-agent-preferences/preferences.test.ts b/packages/app/src/create-agent-preferences/preferences.test.ts index 994a4b60d..5b73d51c4 100644 --- a/packages/app/src/create-agent-preferences/preferences.test.ts +++ b/packages/app/src/create-agent-preferences/preferences.test.ts @@ -49,6 +49,25 @@ describe("create agent preferences", () => { }); }); + it("does not commit a failed write or leak it into the next update", async () => { + const storage = new FakeCreateAgentPreferenceStorage(); + const preferences = new CreateAgentPreferencesService(storage); + + const failedWrite = preferences.update({ provider: "claude" }); + await storage.nextWrite(); + storage.failOldestWrite(new Error("disk full")); + await expect(failedWrite).rejects.toThrow("disk full"); + + expect(await preferences.load()).toEqual({}); + + const successfulWrite = preferences.update({ isolation: "worktree" }); + await storage.nextWrite(); + storage.finishOldestWrite(); + await successfulWrite; + + expect(storage.savedPreferences()).toEqual({ isolation: "worktree" }); + }); + it("flushes the full create-agent selection into provider preferences", async () => { const storage = new FakeCreateAgentPreferenceStorage(); const preferences = new CreateAgentPreferencesService(storage); diff --git a/packages/app/src/create-agent-preferences/service.ts b/packages/app/src/create-agent-preferences/service.ts index b3988623f..6085a147c 100644 --- a/packages/app/src/create-agent-preferences/service.ts +++ b/packages/app/src/create-agent-preferences/service.ts @@ -50,9 +50,10 @@ export class CreateAgentPreferencesService { await previousWrite; const current = await this.load(); const next = typeof update === "function" ? update(current) : { ...current, ...update }; - this.preferences = parseFormPreferences(next); + const parsed = parseFormPreferences(next); + await this.storage.write(parsed); + this.preferences = parsed; this.isLoaded = true; - await this.storage.write(this.preferences); return this.preferences; } } diff --git a/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts b/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts index 884819c6b..6b0fcf245 100644 --- a/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts +++ b/packages/app/src/create-agent-preferences/test-utils/fake-preference-storage.ts @@ -4,6 +4,7 @@ import type { CreateAgentPreferenceStorage } from "../storage"; interface PendingWrite { preferences: FormPreferences; finish: () => void; + fail: (error: Error) => void; } export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceStorage { @@ -20,13 +21,16 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt } write(preferences: FormPreferences): Promise { - return new Promise((resolve) => { + return new Promise((resolve, reject) => { const write = { preferences, finish: () => { this.stored = clone(preferences); resolve(); }, + fail: (error: Error) => { + reject(error); + }, }; this.pendingWrites.push(write); this.pendingWriteWaiters.shift()?.(write); @@ -55,6 +59,14 @@ export class FakeCreateAgentPreferenceStorage implements CreateAgentPreferenceSt write.finish(); } + failOldestWrite(error: Error): void { + const write = this.pendingWrites.shift(); + if (!write) { + throw new Error("No pending create-agent preference write"); + } + write.fail(error); + } + savedPreferences(): unknown { return this.stored; } diff --git a/packages/app/src/hooks/use-agent-form-state.ts b/packages/app/src/hooks/use-agent-form-state.ts index af650e338..29e90a6a9 100644 --- a/packages/app/src/hooks/use-agent-form-state.ts +++ b/packages/app/src/hooks/use-agent-form-state.ts @@ -12,6 +12,8 @@ import { buildSelectableProviderSelectorProviders, type ProviderSelectorProvider, } from "@/provider-selection/provider-selection"; +import { filterSelectableModels } from "@/provider-selection/model-catalog"; +import { OptimisticFormPreferences } from "@/create-agent-preferences/optimistic-preferences"; import { useProvidersSnapshot } from "./use-providers-snapshot"; import { useFormPreferences, @@ -156,7 +158,7 @@ function buildAllProviderModels( ): Map { const map = new Map(); for (const entry of snapshotEntries ?? []) { - map.set(entry.provider, entry.models ?? []); + map.set(entry.provider, filterSelectableModels(entry.models ?? []) ?? []); } return map; } @@ -166,7 +168,7 @@ function buildProviderModelsByProvider( ): ProviderModelsByProvider { const map: ProviderModelsByProvider = new Map(); for (const entry of snapshotEntries ?? []) { - map.set(entry.provider, entry.models ?? null); + map.set(entry.provider, filterSelectableModels(entry.models ?? null)); } return map; } @@ -177,7 +179,7 @@ async function persistProviderPreferences(input: { availableModels: AgentModelDefinition[] | null; updatePreferences: ( updates: Partial | ((current: FormPreferences) => FormPreferences), - ) => Promise; + ) => Promise; }): Promise { const { provider, formState, availableModels, updatePreferences } = input; const resolvedModel = resolveEffectiveModel(availableModels, formState.model); @@ -208,6 +210,28 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg } = options; const { preferences, isLoading: isPreferencesLoading, updatePreferences } = useFormPreferences(); + const preferenceOverlayRef = useRef(new OptimisticFormPreferences(preferences)); + + useEffect(() => { + preferenceOverlayRef.current.reconcile(preferences); + }, [preferences]); + + const updateCurrentPreferences = useCallback( + async ( + updates: Partial | ((current: FormPreferences) => FormPreferences), + ): Promise => { + const pendingId = preferenceOverlayRef.current.begin(updates); + try { + const persisted = await updatePreferences(updates); + preferenceOverlayRef.current.commit(pendingId, persisted); + return persisted; + } catch (error) { + preferenceOverlayRef.current.reject(pendingId); + throw error; + } + }, + [updatePreferences], + ); const daemons = useHosts(); @@ -294,7 +318,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg : null, [formState.provider, snapshotEntries], ); - const snapshotSelectedProviderModels = snapshotSelectedEntry?.models ?? null; + const snapshotSelectedProviderModels = filterSelectableModels( + snapshotSelectedEntry?.models ?? null, + ); const selectedProviderIsLoading = snapshotSelectedEntry?.status === "loading"; const snapshotSelectedProviderModes = resolveSelectedProviderModes({ selectedEntry: snapshotSelectedEntry, @@ -404,7 +430,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg } const providerModels = allProviderModels.get(provider) ?? null; const providerDef = selectableProviderDefinitionMap.get(provider); - const providerPrefs = preferences?.providerPreferences?.[provider]; + const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider]; dispatch({ type: "SET_PROVIDER_FROM_USER", @@ -413,14 +439,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg providerDef, providerPrefs, }); - void updatePreferences({ provider }); + void updateCurrentPreferences({ provider }); }, - [ - allProviderModels, - preferences?.providerPreferences, - selectableProviderDefinitionMap, - updatePreferences, - ], + [allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences], ); const setProviderAndModelFromUser = useCallback( @@ -430,7 +451,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg } const providerDef = selectableProviderDefinitionMap.get(provider); const providerModels = allProviderModels.get(provider) ?? null; - const providerPrefs = preferences?.providerPreferences?.[provider]; + const providerPrefs = preferenceOverlayRef.current.current().providerPreferences?.[provider]; const normalizedModelId = normalizeSelectedModelId(modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(providerModels); @@ -442,7 +463,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg providerModels, providerPrefs, }); - void updatePreferences((current) => + void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ preferences: current, provider, @@ -452,12 +473,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg }), ); }, - [ - allProviderModels, - preferences?.providerPreferences, - selectableProviderDefinitionMap, - updatePreferences, - ], + [allProviderModels, selectableProviderDefinitionMap, updateCurrentPreferences], ); const clearProviderSelectionFromUser = useCallback(() => { @@ -469,7 +485,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg dispatch({ type: "SET_MODE_FROM_USER", modeId }); const provider = reducerStateRef.current.form.provider; if (provider) { - void updatePreferences((current) => + void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ preferences: current, provider, @@ -480,17 +496,25 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } }, - [updatePreferences], + [updateCurrentPreferences], ); const setModelFromUser = useCallback( (modelId: string) => { - dispatch({ type: "SET_MODEL_FROM_USER", modelId, availableModels }); const provider = reducerStateRef.current.form.provider; + const providerPrefs = provider + ? preferenceOverlayRef.current.current().providerPreferences?.[provider] + : undefined; + dispatch({ + type: "SET_MODEL_FROM_USER", + modelId, + availableModels, + providerPrefs, + }); if (provider) { const normalizedModelId = normalizeSelectedModelId(modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(availableModels); - void updatePreferences((current) => + void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ preferences: current, provider, @@ -501,7 +525,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } }, - [availableModels, updatePreferences], + [availableModels, updateCurrentPreferences], ); const setThinkingOptionFromUser = useCallback( @@ -509,7 +533,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg dispatch({ type: "SET_THINKING_OPTION_FROM_USER", thinkingOptionId }); const { provider, model: modelId } = reducerStateRef.current.form; if (provider && modelId) { - void updatePreferences((current) => + void updateCurrentPreferences((current) => mergeSelectedComposerPreferences({ preferences: current, provider, @@ -522,7 +546,7 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg ); } }, - [updatePreferences], + [updateCurrentPreferences], ); const setWorkingDir = useCallback((value: string) => { @@ -556,9 +580,9 @@ export function useAgentFormState(options: UseAgentFormStateOptions = {}): UseAg provider: formState.provider, formState, availableModels, - updatePreferences, + updatePreferences: updateCurrentPreferences, }); - }, [availableModels, formState, updatePreferences]); + }, [availableModels, formState, updateCurrentPreferences]); const agentDefinition = formState.provider ? providerDefinitionMap.get(formState.provider) diff --git a/packages/app/src/hooks/use-form-preferences.ts b/packages/app/src/hooks/use-form-preferences.ts index aeb161bc5..ee69498b6 100644 --- a/packages/app/src/hooks/use-form-preferences.ts +++ b/packages/app/src/hooks/use-form-preferences.ts @@ -29,7 +29,7 @@ async function loadFormPreferences(): Promise { export interface UseFormPreferencesReturn { preferences: FormPreferences; isLoading: boolean; - updatePreferences: (updates: FormPreferenceUpdate) => Promise; + updatePreferences: (updates: FormPreferenceUpdate) => Promise; } export function useFormPreferences(): UseFormPreferencesReturn { @@ -47,6 +47,7 @@ export function useFormPreferences(): UseFormPreferencesReturn { async (updates: FormPreferenceUpdate) => { const next = await createAgentPreferencesService.update(updates); queryClient.setQueryData(FORM_PREFERENCES_QUERY_KEY, next); + return next; }, [queryClient], ); diff --git a/packages/app/src/provider-selection/model-catalog.test.ts b/packages/app/src/provider-selection/model-catalog.test.ts new file mode 100644 index 000000000..c4adc8c06 --- /dev/null +++ b/packages/app/src/provider-selection/model-catalog.test.ts @@ -0,0 +1,23 @@ +import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types"; +import { describe, expect, it } from "vitest"; +import { findModelByReference } from "./model-catalog"; + +describe("findModelByReference", () => { + it("prefers an exact model id over another model's alias", () => { + const models: AgentModelDefinition[] = [ + { + provider: "claude", + id: "canonical-model", + label: "Canonical model", + aliases: ["gateway-model"], + }, + { + provider: "claude", + id: "gateway-model", + label: "Exact gateway model", + }, + ]; + + expect(findModelByReference(models, "gateway-model")?.label).toBe("Exact gateway model"); + }); +}); diff --git a/packages/app/src/provider-selection/model-catalog.ts b/packages/app/src/provider-selection/model-catalog.ts new file mode 100644 index 000000000..ad8c8e1a7 --- /dev/null +++ b/packages/app/src/provider-selection/model-catalog.ts @@ -0,0 +1,21 @@ +import type { AgentModelDefinition } from "@getpaseo/protocol/agent-types"; + +export function findModelByReference( + models: AgentModelDefinition[] | null, + modelId: string, +): AgentModelDefinition | null { + if (!models || models.length === 0) return null; + const normalizedModelId = modelId.trim(); + if (!normalizedModelId) return null; + return ( + models.find((model) => model.id === normalizedModelId) ?? + models.find((model) => model.aliases?.includes(normalizedModelId)) ?? + null + ); +} + +export function filterSelectableModels( + models: AgentModelDefinition[] | null, +): AgentModelDefinition[] | null { + return models?.filter((model) => model.isSelectable !== false) ?? null; +} diff --git a/packages/app/src/provider-selection/provider-selection.test.ts b/packages/app/src/provider-selection/provider-selection.test.ts index 06c76f74b..4c40fe61b 100644 --- a/packages/app/src/provider-selection/provider-selection.test.ts +++ b/packages/app/src/provider-selection/provider-selection.test.ts @@ -66,6 +66,24 @@ describe("combined model selector data", () => { ]); }); + it("hides compatibility-only model entries from new clients", () => { + const compatibilityModel: AgentModelDefinition = { + ...codexModel, + id: "gpt-5.4-legacy", + label: "GPT-5.4 legacy", + isSelectable: false, + }; + + const [provider] = buildSelectableProviderSelectorProviders([ + snapshotEntry({ provider: "codex", models: [codexModel, compatibilityModel] }), + ]); + + expect(provider?.modelSelection).toMatchObject({ + kind: "models", + rows: [{ modelId: "gpt-5.4" }], + }); + }); + it("synthesizes a default model row for ready enabled providers without explicit models", () => { expect( buildSelectableProviderSelectorProviders([ diff --git a/packages/app/src/provider-selection/provider-selection.ts b/packages/app/src/provider-selection/provider-selection.ts index c100ecc67..00b9a766c 100644 --- a/packages/app/src/provider-selection/provider-selection.ts +++ b/packages/app/src/provider-selection/provider-selection.ts @@ -9,6 +9,7 @@ import type { DraftCommandConfig } from "@/hooks/use-agent-commands-query"; import { buildFavoriteModelKey, type FavoriteModelRow } from "@/hooks/use-form-preferences"; import { i18n } from "@/i18n/i18next"; import { compareMatchScores, scoreTextFields } from "@/utils/score-match"; +import { filterSelectableModels } from "./model-catalog"; export type ProviderSelectionModelRow = FavoriteModelRow & { isDefault?: boolean }; @@ -76,10 +77,11 @@ function buildModelSelection( if (models === null) { return { kind: "loading" }; } - if (models.length === 0) { + const selectableModels = filterSelectableModels(models) ?? []; + if (selectableModels.length === 0) { return { kind: "models", rows: [buildSyntheticDefaultRow(provider, providerLabel)] }; } - return { kind: "models", rows: buildModelRows(provider, providerLabel, models) }; + return { kind: "models", rows: buildModelRows(provider, providerLabel, selectableModels) }; } function buildEntryModelSelection( diff --git a/packages/app/src/provider-selection/resolve-agent-form.test.ts b/packages/app/src/provider-selection/resolve-agent-form.test.ts index b58ee145b..82d029046 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.test.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.test.ts @@ -61,6 +61,10 @@ const CODEX_MODELS: AgentModelDefinition[] = [ }, ]; +const ALIASED_CODEX_MODELS: AgentModelDefinition[] = [ + { ...CODEX_MODELS[0], aliases: ["gpt-5.3-codex-legacy"] }, +]; + function makeProviderMap( ...definitions: AgentProviderDefinition[] ): Map { @@ -120,6 +124,79 @@ describe("resolveDefaultModel", () => { }); }); +describe("model aliases", () => { + it("canonicalizes a retired preferred model and restores thinking from its alias key", () => { + const resolved = resolveFormState( + undefined, + { + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.3-codex-legacy", + thinkingByModel: { "gpt-5.3-codex-legacy": "low" }, + }, + }, + }, + ALIASED_CODEX_MODELS, + INITIAL_USER_MODIFIED, + makeState().form, + codexProviderMap, + ); + + expect(resolved.model).toBe("gpt-5.3-codex"); + expect(resolved.thinkingOptionId).toBe("low"); + }); + + it("prefers thinking stored under the canonical model id over an alias", () => { + const resolved = resolveFormState( + undefined, + { + provider: "codex", + providerPreferences: { + codex: { + model: "gpt-5.3-codex-legacy", + thinkingByModel: { + "gpt-5.3-codex": "xhigh", + "gpt-5.3-codex-legacy": "low", + }, + }, + }, + }, + ALIASED_CODEX_MODELS, + INITIAL_USER_MODIFIED, + makeState().form, + codexProviderMap, + ); + + expect(resolved.model).toBe("gpt-5.3-codex"); + expect(resolved.thinkingOptionId).toBe("xhigh"); + }); + + it("prefers an exact configured model id over another model's alias", () => { + const configuredAlias: AgentModelDefinition = { + provider: "codex", + id: "gpt-5.3-codex-legacy", + label: "Gateway legacy model", + defaultThinkingOptionId: "medium", + thinkingOptions: [{ id: "medium", label: "medium", isDefault: true }], + }; + const resolved = resolveFormState( + undefined, + { + provider: "codex", + providerPreferences: { codex: { model: configuredAlias.id } }, + }, + [...ALIASED_CODEX_MODELS, configuredAlias], + INITIAL_USER_MODIFIED, + makeState().form, + codexProviderMap, + ); + + expect(resolved.model).toBe("gpt-5.3-codex-legacy"); + expect(resolved.thinkingOptionId).toBe("medium"); + }); +}); + describe("resolveThinkingOptionId", () => { it("returns empty string when model has no thinking options", () => { const modelsWithoutThinking: AgentModelDefinition[] = [ @@ -843,6 +920,7 @@ describe("resolveAgentForm", () => { type: "SET_MODEL_FROM_USER", modelId: "gpt-5.4-codex", availableModels: alternateModels, + providerPrefs: undefined, }); const next = resolveAgentForm(userChanged, { type: "COMPLETE_RESOLUTION", @@ -925,6 +1003,22 @@ describe("resolveAgentForm", () => { expect(next.form.modeId).toBe("auto"); expect(next.form.model).toBe("gpt-5.3-codex"); }); + + it("canonicalizes an aliased preferred model and restores its thinking option", () => { + const next = resolveAgentForm(makeState(), { + type: "SET_PROVIDER_FROM_USER", + provider: "codex", + providerModels: ALIASED_CODEX_MODELS, + providerDef: TEST_CODEX_DEFINITION, + providerPrefs: { + model: "gpt-5.3-codex-legacy", + thinkingByModel: { "gpt-5.3-codex-legacy": "low" }, + }, + }); + + expect(next.form.model).toBe("gpt-5.3-codex"); + expect(next.form.thinkingOptionId).toBe("low"); + }); }); describe("SET_PROVIDER_AND_MODEL_FROM_USER", () => { @@ -981,9 +1075,10 @@ describe("resolveAgentForm", () => { modelId: "gpt-5.3-codex", providerDef: TEST_CODEX_DEFINITION, providerModels: CODEX_MODELS, + providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "low" } }, }); - expect(next.form.thinkingOptionId).toBe("xhigh"); + expect(next.form.thinkingOptionId).toBe("low"); }); }); @@ -1004,6 +1099,7 @@ describe("resolveAgentForm", () => { type: "SET_MODEL_FROM_USER", modelId: "gpt-5.3-codex", availableModels: CODEX_MODELS, + providerPrefs: undefined, }); expect(next.form.model).toBe("gpt-5.3-codex"); @@ -1020,6 +1116,7 @@ describe("resolveAgentForm", () => { type: "SET_MODEL_FROM_USER", modelId: "gpt-5.3-codex", availableModels: CODEX_MODELS, + providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "xhigh" } }, }); expect(next.form.thinkingOptionId).toBe("low"); @@ -1031,10 +1128,37 @@ describe("resolveAgentForm", () => { type: "SET_MODEL_FROM_USER", modelId: " ", availableModels: CODEX_MODELS, + providerPrefs: undefined, }); expect(next.form.model).toBe("gpt-5.3-codex"); }); + + it("restores the target model's saved thinking option", () => { + const models = [ + ...CODEX_MODELS, + { + provider: "codex" as const, + id: "gpt-other", + label: "Other", + defaultThinkingOptionId: "xhigh", + thinkingOptions: CODEX_MODELS[0].thinkingOptions, + }, + ]; + const state = makeState({ + provider: "codex", + model: "gpt-other", + thinkingOptionId: "xhigh", + }); + const next = resolveAgentForm(state, { + type: "SET_MODEL_FROM_USER", + modelId: "gpt-5.3-codex", + availableModels: models, + providerPrefs: { thinkingByModel: { "gpt-5.3-codex": "low" } }, + }); + + expect(next.form.thinkingOptionId).toBe("low"); + }); }); describe("SET_THINKING_OPTION_FROM_USER", () => { diff --git a/packages/app/src/provider-selection/resolve-agent-form.ts b/packages/app/src/provider-selection/resolve-agent-form.ts index 7c818ee60..1e2b56bab 100644 --- a/packages/app/src/provider-selection/resolve-agent-form.ts +++ b/packages/app/src/provider-selection/resolve-agent-form.ts @@ -9,6 +9,7 @@ import { type FormPreferences, type ProviderPreferences, } from "@/hooks/use-form-preferences"; +import { findModelByReference } from "./model-catalog"; export interface FormInitialValues { serverId?: string | null; @@ -98,6 +99,7 @@ export type AgentFormAction = type: "SET_MODEL_FROM_USER"; modelId: string; availableModels: AgentModelDefinition[] | null; + providerPrefs: ProviderPrefs | undefined; } | { type: "CLEAR_PROVIDER_SELECTION_FROM_USER" } | { type: "SET_THINKING_OPTION_FROM_USER"; thinkingOptionId: string } @@ -123,17 +125,36 @@ export function resolveDefaultModelId(availableModels: AgentModelDefinition[] | return resolveDefaultModel(availableModels)?.id ?? ""; } +function resolveCanonicalModelId( + availableModels: AgentModelDefinition[] | null, + modelId: string, +): string { + const normalizedModelId = normalizeSelectedModelId(modelId); + if (!normalizedModelId || !availableModels) return normalizedModelId; + return findModelByReference(availableModels, normalizedModelId)?.id ?? ""; +} + export function resolveEffectiveModel( availableModels: AgentModelDefinition[] | null, modelId: string, ): AgentModelDefinition | null { if (!availableModels || availableModels.length === 0) return null; - const normalizedModelId = modelId.trim(); - if (!normalizedModelId) return null; - return ( - availableModels.find((model) => model.id === normalizedModelId) ?? - resolveDefaultModel(availableModels) - ); + if (!normalizeSelectedModelId(modelId)) return null; + return findModelByReference(availableModels, modelId) ?? resolveDefaultModel(availableModels); +} + +function resolvePreferredThinkingOptionId(input: { + availableModels: AgentModelDefinition[] | null; + providerPrefs: ProviderPrefs | undefined; + modelId: string; +}): string { + const model = findModelByReference(input.availableModels, input.modelId); + const modelReferences = model ? [model.id, ...(model.aliases ?? [])] : [input.modelId]; + for (const modelReference of modelReferences) { + const thinkingOptionId = input.providerPrefs?.thinkingByModel?.[modelReference]?.trim(); + if (thinkingOptionId) return thinkingOptionId; + } + return ""; } export function resolveThinkingOptionId(args: { @@ -313,15 +334,18 @@ function resolveModelField(input: { input; if (userModified) return currentModel; if (!provider) return ""; - const isValidModel = (m: string) => availableModels?.some((am) => am.id === m) ?? false; const initialModel = normalizeSelectedModelId(initialValues?.model); const preferredModel = normalizeSelectedModelId(providerPrefs?.model); const defaultModelId = resolveDefaultModelId(availableModels); if (initialModel) { - return !availableModels || isValidModel(initialModel) ? initialModel : defaultModelId; + return !availableModels + ? initialModel + : resolveCanonicalModelId(availableModels, initialModel) || defaultModelId; } if (preferredModel) { - return !availableModels || isValidModel(preferredModel) ? preferredModel : defaultModelId; + return !availableModels + ? preferredModel + : resolveCanonicalModelId(availableModels, preferredModel) || defaultModelId; } return ""; } @@ -333,19 +357,28 @@ function resolveThinkingOption(input: { modelId: string; initialValues: FormInitialValues | undefined; providerPrefs: ProviderPrefs | undefined; + availableModels: AgentModelDefinition[] | null; }): string { - const { provider, userModified, currentThinkingOptionId, modelId, initialValues, providerPrefs } = - input; + const { + provider, + userModified, + currentThinkingOptionId, + modelId, + initialValues, + providerPrefs, + availableModels, + } = input; if (!provider) return ""; if (userModified) return currentThinkingOptionId; const initialThinkingOptionId = typeof initialValues?.thinkingOptionId === "string" ? initialValues.thinkingOptionId.trim() : ""; - const effectiveModelId = modelId.trim(); - const preferredThinking = effectiveModelId - ? (providerPrefs?.thinkingByModel?.[effectiveModelId]?.trim() ?? "") - : ""; + const preferredThinking = resolvePreferredThinkingOptionId({ + availableModels, + providerPrefs, + modelId, + }); if (initialThinkingOptionId.length > 0) return initialThinkingOptionId; if (preferredThinking.length > 0) return preferredThinking; return ""; @@ -399,6 +432,7 @@ export function resolveFormState( modelId: result.model, initialValues, providerPrefs, + availableModels, }); if (result.provider && availableModels) { @@ -455,11 +489,11 @@ function pickNextModelForProvider(input: { providerPrefs: ProviderPrefs | undefined; }): string { const { providerModels, providerPrefs } = input; - const isValidModel = (m: string) => providerModels?.some((am) => am.id === m) ?? false; const preferredModel = normalizeSelectedModelId(providerPrefs?.model); const defaultModelId = resolveDefaultModelId(providerModels); - if (preferredModel && (!providerModels || isValidModel(preferredModel))) { - return preferredModel; + if (preferredModel) { + if (!providerModels) return preferredModel; + return resolveCanonicalModelId(providerModels, preferredModel) || defaultModelId; } return defaultModelId; } @@ -496,9 +530,11 @@ function pickNextThinkingOptionForProvider(input: { modelId: string; }): string { const { providerModels, providerPrefs, modelId } = input; - const preferredThinking = modelId - ? (providerPrefs?.thinkingByModel?.[modelId]?.trim() ?? "") - : ""; + const preferredThinking = resolvePreferredThinkingOptionId({ + availableModels: providerModels, + providerPrefs, + modelId, + }); return resolveThinkingOptionId({ availableModels: providerModels, modelId, @@ -506,6 +542,30 @@ function pickNextThinkingOptionForProvider(input: { }); } +function pickNextThinkingOptionForTarget(input: { + availableModels: AgentModelDefinition[] | null; + providerPrefs: ProviderPrefs | undefined; + modelId: string; + currentModelId: string; + currentThinkingOptionId: string; + isSameProvider: boolean; +}): string { + const requestedThinkingOptionId = + input.isSameProvider && + resolveCanonicalModelId(input.availableModels, input.currentModelId) === input.modelId + ? input.currentThinkingOptionId + : resolvePreferredThinkingOptionId({ + availableModels: input.availableModels, + providerPrefs: input.providerPrefs, + modelId: input.modelId, + }); + return resolveThinkingOptionId({ + availableModels: input.availableModels, + modelId: input.modelId, + requestedThinkingOptionId, + }); +} + function completeResolution( state: AgentFormReducerState, action: CompleteResolutionAction, @@ -579,12 +639,15 @@ export function resolveAgentForm( } case "SET_PROVIDER_AND_MODEL_FROM_USER": { - const normalizedModelId = normalizeSelectedModelId(action.modelId); + const normalizedModelId = resolveCanonicalModelId(action.providerModels, action.modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(action.providerModels); - const nextThinkingOptionId = resolveThinkingOptionId({ + const nextThinkingOptionId = pickNextThinkingOptionForTarget({ availableModels: action.providerModels, modelId: nextModelId, - requestedThinkingOptionId: "", + providerPrefs: action.providerPrefs, + currentModelId: state.form.model, + currentThinkingOptionId: state.form.thinkingOptionId, + isSameProvider: state.form.provider === action.provider, }); const nextModeId = pickNextModeForProviderAndModel({ currentProvider: state.form.provider, @@ -614,14 +677,15 @@ export function resolveAgentForm( }; case "SET_MODEL_FROM_USER": { - const normalizedModelId = normalizeSelectedModelId(action.modelId); + const normalizedModelId = resolveCanonicalModelId(action.availableModels, action.modelId); const nextModelId = normalizedModelId || resolveDefaultModelId(action.availableModels); - const nextThinkingOptionId = resolveThinkingOptionId({ + const nextThinkingOptionId = pickNextThinkingOptionForTarget({ availableModels: action.availableModels, modelId: nextModelId, - requestedThinkingOptionId: state.userModified.thinkingOptionId - ? state.form.thinkingOptionId - : "", + providerPrefs: action.providerPrefs, + currentModelId: state.form.model, + currentThinkingOptionId: state.form.thinkingOptionId, + isSameProvider: true, }); return { ...state, diff --git a/packages/app/src/schedules/schedule-form-model.test.ts b/packages/app/src/schedules/schedule-form-model.test.ts index 51307fc0c..9ec61dd7c 100644 --- a/packages/app/src/schedules/schedule-form-model.test.ts +++ b/packages/app/src/schedules/schedule-form-model.test.ts @@ -32,6 +32,7 @@ const HOST_B_MODELS: AgentModelDefinition[] = [ provider: "mock", id: "model-b", label: "Model B", + aliases: ["model-b-legacy"], isDefault: true, defaultThinkingOptionId: "high", thinkingOptions: [ @@ -41,6 +42,20 @@ const HOST_B_MODELS: AgentModelDefinition[] = [ }, ]; +const THINKING_MODELS: AgentModelDefinition[] = [ + HOST_B_MODELS[0]!, + { + provider: "mock", + id: "model-c", + label: "Model C", + defaultThinkingOptionId: "low", + thinkingOptions: [ + { id: "low", label: "Low", isDefault: true }, + { id: "high", label: "High" }, + ], + }, +]; + const ALL_MODELS = [...HOST_A_MODELS, ...HOST_B_MODELS]; function target(input: { @@ -81,6 +96,7 @@ function scheduleOnHost(input: { serverName: string; cwd: string; model: string; + thinkingOptionId?: string | null; cadence?: ScheduleSummary["cadence"]; }): TestSchedule { return { @@ -97,7 +113,8 @@ function scheduleOnHost(input: { cwd: input.cwd, model: input.model, modeId: "load-test", - thinkingOptionId: "high", + thinkingOptionId: + input.thinkingOptionId === undefined ? "high" : (input.thinkingOptionId ?? undefined), archiveOnFinish: false, isolation: "worktree", }, @@ -133,13 +150,16 @@ function heartbeatOnHost(cadence: ScheduleSummary["cadence"]): TestSchedule { }; } -function providerSnapshot(models: AgentModelDefinition[]): { entries: ProviderSnapshotEntry[] } { +function providerSnapshot( + models: AgentModelDefinition[], + status: ProviderSnapshotEntry["status"] = "ready", +): { entries: ProviderSnapshotEntry[] } { return { entries: [ { provider: "mock", label: "Mock", - status: "ready", + status, enabled: true, fetchedAt: "2026-07-01T00:00:00.000Z", models, @@ -223,6 +243,69 @@ describe("schedule form model", () => { }); }); + it("reconciles untouched thinking when a loading provider snapshot becomes ready", () => { + const form = open({ + mode: "edit", + schedule: scheduleOnHost({ + serverId: "host-b", + serverName: "Host B", + cwd: "/repo/b", + model: "model-b", + thinkingOptionId: null, + }), + defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + const loadingModel = { ...HOST_B_MODELS[0] }; + delete loadingModel.thinkingOptions; + delete loadingModel.defaultThinkingOptionId; + + form.applyProviderSnapshot("host-b", providerSnapshot([loadingModel], "loading")); + expect(form.getState().selectedThinkingOptionId).toBe(""); + + form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS)); + + expect(form.getState()).toMatchObject({ + selectedModel: "model-b", + selectedThinkingOptionId: "high", + selectedThinkingDisplay: { label: "High" }, + }); + + form.setThinking("low"); + form.applyProviderSnapshot("host-b", providerSnapshot(HOST_B_MODELS)); + expect(form.getState().selectedThinkingOptionId).toBe("low"); + }); + + it("preserves per-model thinking across model switches and snapshot updates", () => { + const form = open({ + mode: "edit", + schedule: scheduleOnHost({ + serverId: "host-b", + serverName: "Host B", + cwd: "/repo/b", + model: "model-b-legacy", + thinkingOptionId: "low", + }), + defaults: { serverId: null, projectTargets: PROJECT_TARGETS, preferences: {} }, + }); + form.applyProviderSnapshot("host-b", providerSnapshot(THINKING_MODELS)); + + form.setModel("mock", "model-b"); + expect(form.getState().selectedThinkingOptionId).toBe("low"); + + form.setThinking("high"); + form.setModel("mock", "model-c"); + expect(form.getState().selectedThinkingOptionId).toBe("low"); + + form.setModel("mock", "model-b"); + expect(form.getState().selectedThinkingOptionId).toBe("high"); + + form.setModel("mock", "model-b"); + expect(form.getState().selectedThinkingOptionId).toBe("high"); + + form.applyProviderSnapshot("host-b", providerSnapshot(THINKING_MODELS)); + expect(form.getState().selectedThinkingOptionId).toBe("high"); + }); + it("opens create pristine after an edit instance closes", () => { const edit = open({ mode: "edit", diff --git a/packages/app/src/schedules/schedule-form-model.ts b/packages/app/src/schedules/schedule-form-model.ts index 483545391..4a889f9ef 100644 --- a/packages/app/src/schedules/schedule-form-model.ts +++ b/packages/app/src/schedules/schedule-form-model.ts @@ -11,6 +11,7 @@ import { buildSelectableProviderSelectorProviders, type ProviderSelectorProvider, } from "@/provider-selection/provider-selection"; +import { filterSelectableModels, findModelByReference } from "@/provider-selection/model-catalog"; import { buildProviderDefinitionMapForStatuses, INITIAL_USER_MODIFIED, @@ -221,7 +222,7 @@ function resolveProjectDisplay(input: { function buildProviderModelsByProvider(entries: ProviderSnapshotEntry[]): ProviderModelsByProvider { const map: ProviderModelsByProvider = new Map(); for (const entry of entries) { - map.set(entry.provider, entry.models ?? null); + map.set(entry.provider, filterSelectableModels(entry.models ?? null)); } return map; } @@ -247,7 +248,7 @@ function resolveAvailableModels( entries: readonly ProviderSnapshotEntry[], provider: AgentProvider | null, ): AgentModelDefinition[] | null { - return resolveSelectedEntry(entries, provider)?.models ?? null; + return filterSelectableModels(resolveSelectedEntry(entries, provider)?.models ?? null); } function resolveEffectiveModel( @@ -789,6 +790,58 @@ function pickModelForProvider(input: { return resolveDefaultModelId(resolveAvailableModels(input.entries, input.provider)); } +function thinkingDraftKey(provider: AgentProvider, modelId: string): string { + return `${provider}:${modelId}`; +} + +function seedThinkingDrafts( + drafts: Map, + preferences: FormPreferences | null, +): void { + for (const [provider, providerPreferences] of Object.entries( + preferences?.providerPreferences ?? {}, + )) { + for (const [modelId, thinkingOptionId] of Object.entries( + providerPreferences?.thinkingByModel ?? {}, + )) { + const key = thinkingDraftKey(provider as AgentProvider, modelId); + if (!drafts.has(key)) { + drafts.set(key, thinkingOptionId); + } + } + } +} + +function canonicalizeThinkingDrafts( + drafts: Map, + entries: readonly ProviderSnapshotEntry[], +): void { + for (const entry of entries) { + const models = filterSelectableModels(entry.models ?? null); + if (!models) { + continue; + } + for (const model of models) { + const canonicalKey = thinkingDraftKey(entry.provider, model.id); + if (drafts.has(canonicalKey)) { + continue; + } + const resolvedAlias = model.aliases?.find( + (alias) => + findModelByReference(models, alias)?.id === model.id && + drafts.has(thinkingDraftKey(entry.provider, alias)), + ); + if (!resolvedAlias) { + continue; + } + const aliasedThinking = drafts.get(thinkingDraftKey(entry.provider, resolvedAlias)); + if (aliasedThinking !== undefined) { + drafts.set(canonicalKey, aliasedThinking); + } + } + } +} + export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormModel { const listeners = new Set<() => void>(); const initialValues = normalizeInitialValues({ @@ -799,6 +852,19 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo let hosts = snapshot.hosts; let projectTargets = snapshot.defaults.projectTargets; let preferences = snapshot.defaults.preferences ?? null; + const thinkingDrafts = new Map(); + seedThinkingDrafts(thinkingDrafts, preferences); + const initialAgentConfig = newAgentConfig(snapshot.schedule); + if ( + initialAgentConfig?.provider && + initialAgentConfig.model && + initialAgentConfig.thinkingOptionId + ) { + thinkingDrafts.set( + thinkingDraftKey(initialAgentConfig.provider, initialAgentConfig.model), + initialAgentConfig.thinkingOptionId, + ); + } let providerEntries: ProviderSnapshotEntry[] = []; let userModified = { ...INITIAL_USER_MODIFIED, isolation: false }; const timezone = snapshot.defaults.timezone ?? DEFAULT_TIMEZONE; @@ -913,6 +979,7 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo return; } preferences = normalizedPreferences; + seedThinkingDrafts(thinkingDrafts, preferences); publish(resolvePreferences(state)); }, applyProviderSnapshot(serverId, providerSnapshot) { @@ -920,17 +987,19 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo return; } providerEntries = providerSnapshot.entries; + canonicalizeThinkingDrafts(thinkingDrafts, providerEntries); const isPendingResolution = state.providerSnapshotRequest?.serverId === serverId; - const resolved = isPendingResolution - ? resolveSnapshotSelection({ - state, - snapshot, - initialValues, - preferences: preferencesForSnapshotResolution(snapshot, preferences), - providerEntries, - userModified, - }) - : state; + const resolved = + state.targetKind === "new-agent" + ? resolveSnapshotSelection({ + state, + snapshot, + initialValues, + preferences: preferencesForSnapshotResolution(snapshot, preferences), + providerEntries, + userModified, + }) + : state; const providerResolutionByServerId: Record = { ...state.providerResolutionByServerId, }; @@ -1000,9 +1069,19 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo const selectedThinkingOptionId = resolveThinkingOptionId({ availableModels, modelId: selectedModel, - requestedThinkingOptionId: "", + requestedThinkingOptionId: + thinkingDrafts.get(thinkingDraftKey(provider, selectedModel)) ?? "", }); - userModified = { ...userModified, provider: true, model: true }; + if (selectedModel && selectedThinkingOptionId) { + thinkingDrafts.set(thinkingDraftKey(provider, selectedModel), selectedThinkingOptionId); + } + userModified = { + ...userModified, + provider: true, + model: true, + modeId: true, + thinkingOptionId: true, + }; publish({ ...state, selectedProvider: provider, @@ -1020,6 +1099,12 @@ export function openScheduleForm(snapshot: ScheduleFormSnapshot): ScheduleFormMo if (closed) { return; } + if (state.selectedProvider && state.selectedModel) { + thinkingDrafts.set( + thinkingDraftKey(state.selectedProvider, state.selectedModel), + thinkingOptionId, + ); + } userModified = { ...userModified, thinkingOptionId: true }; publish({ ...state, selectedThinkingOptionId: thinkingOptionId }); }, diff --git a/packages/app/src/screens/settings/providers-section.tsx b/packages/app/src/screens/settings/providers-section.tsx index 716eb3b8a..78d142fcf 100644 --- a/packages/app/src/screens/settings/providers-section.tsx +++ b/packages/app/src/screens/settings/providers-section.tsx @@ -34,6 +34,7 @@ import { import { SettingsSection } from "@/screens/settings/settings-section"; import { useProviderSettingsStore } from "@/stores/provider-settings-store"; import { confirmDialog } from "@/utils/confirm-dialog"; +import { filterSelectableModels } from "@/provider-selection/model-catalog"; import { ChevronRight, MoreHorizontal, Trash2 } from "lucide-react-native"; type ProviderDefinition = ReturnType[number]; @@ -188,7 +189,7 @@ function ProviderRow({ entry.error.trim().length > 0 ? entry.error.trim() : null; - const modelCount = entry.models?.length ?? 0; + const modelCount = filterSelectableModels(entry.models ?? null)?.length ?? 0; const providerStatus = getProviderStatus(entry.status, enabled, modelCount, t); const handlePress = useCallback(() => { diff --git a/packages/cli/tests/15-provider.test.ts b/packages/cli/tests/15-provider.test.ts index 421f68aa5..3dba994fe 100644 --- a/packages/cli/tests/15-provider.test.ts +++ b/packages/cli/tests/15-provider.test.ts @@ -108,11 +108,6 @@ const EXPECTED_CLAUDE_MODELS = [ ] as const; const EXPECTED_CLAUDE_CONTEXT_MODELS = [ - { - id: "claude-fable-5[1m]", - model: "Fable 5 1M", - descriptionFragment: "1M context window", - }, { id: "claude-sonnet-5[1m]", model: "Sonnet 5 1M", diff --git a/packages/protocol/src/agent-types.ts b/packages/protocol/src/agent-types.ts index 756ea3fd1..367b5a631 100644 --- a/packages/protocol/src/agent-types.ts +++ b/packages/protocol/src/agent-types.ts @@ -74,6 +74,8 @@ export type ProviderStatus = "ready" | "loading" | "error" | "unavailable"; export interface AgentModelDefinition { provider: AgentProvider; id: string; + aliases?: string[]; + isSelectable?: boolean; label: string; description?: string; isDefault?: boolean; diff --git a/packages/protocol/src/messages.ts b/packages/protocol/src/messages.ts index 1b011bc2d..e9acb24bd 100644 --- a/packages/protocol/src/messages.ts +++ b/packages/protocol/src/messages.ts @@ -259,6 +259,8 @@ export const AgentFeatureSchema = z.discriminatedUnion("type", [ const AgentModelDefinitionSchema = z.object({ provider: AgentProviderSchema, id: z.string(), + aliases: z.array(z.string()).optional(), + isSelectable: z.boolean().optional(), label: z.string(), description: z.string().optional(), isDefault: z.boolean().optional(), diff --git a/packages/protocol/src/provider-manifest.ts b/packages/protocol/src/provider-manifest.ts index 0e98efcb5..82d097836 100644 --- a/packages/protocol/src/provider-manifest.ts +++ b/packages/protocol/src/provider-manifest.ts @@ -175,6 +175,13 @@ const MOCK_LOAD_TEST_MODES: AgentProviderModeDefinition[] = [ icon: "ShieldOff", colorTier: "dangerous", }, + { + id: "approval-test", + label: "Approval Test", + description: "Alternate development-only permission mode for preference tests", + icon: "ShieldCheck", + colorTier: "safe", + }, ]; const MOCK_SLOW_MODES: AgentProviderModeDefinition[] = [ diff --git a/packages/protocol/src/provider-snapshot-codec.test.ts b/packages/protocol/src/provider-snapshot-codec.test.ts index b042c5a4e..292126085 100644 --- a/packages/protocol/src/provider-snapshot-codec.test.ts +++ b/packages/protocol/src/provider-snapshot-codec.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from "vitest"; import type { ProviderSnapshotEntry } from "./agent-types.js"; +import { CompactProviderSnapshotSchema } from "./messages.js"; import { compactProviderSnapshot, expandProviderSnapshot } from "./provider-snapshot-codec.js"; const sharedThinking = [ @@ -21,6 +22,8 @@ function providerEntry(): ProviderSnapshotEntry { { provider: "pi", id: "openai/gpt-a", + aliases: ["openai/gpt-a-legacy"], + isSelectable: false, label: "GPT A", description: "openai/gpt-a", metadata: { provider: "openai", modelId: "gpt-a" }, @@ -48,6 +51,9 @@ describe("provider snapshot codec", () => { expect(compact.thinkingSets).toHaveLength(1); expect(compact.entries[0]?.models?.map((model) => model.thinkingSet)).toEqual([0, 0]); + expect(CompactProviderSnapshotSchema.parse(compact)).toEqual(compact); + expect(compact.entries[0]?.models?.[0]?.aliases).toEqual(["openai/gpt-a-legacy"]); + expect(compact.entries[0]?.models?.[0]?.isSelectable).toBe(false); expect(expanded).toEqual(original); expect(expanded[0]?.models?.[0]?.thinkingOptions).toBe( expanded[0]?.models?.[1]?.thinkingOptions, diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index e473f75ec..c710bad91 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -75,6 +75,8 @@ export type ProviderStatus = "ready" | "loading" | "error" | "unavailable"; export interface AgentModelDefinition { provider: AgentProvider; id: string; + aliases?: string[]; + isSelectable?: boolean; label: string; description?: string; isDefault?: boolean; @@ -101,6 +103,12 @@ export function normalizeAgentModelDefinition(model: AgentModelDefinition): Agen return { ...model, defaultThinkingOptionId }; } +export function filterSelectableAgentModels( + models: AgentModelDefinition[] | undefined, +): AgentModelDefinition[] { + return models?.filter((model) => model.isSelectable !== false) ?? []; +} + export interface ProviderSnapshotEntry { provider: AgentProvider; status: ProviderStatus; @@ -703,6 +711,8 @@ export interface AgentClient { * The registry is responsible for merging configured model overrides. */ fetchCatalog(options: FetchCatalogOptions): Promise; + /** Apply provider-owned defaults to a model supplied through provider configuration. */ + resolveConfiguredModel?(model: AgentModelDefinition): AgentModelDefinition; resolveDefaultModeId?(input: ResolveAgentDefaultModeInput): Promise; resolveCreateConfig?(input: ResolveAgentCreateConfigInput): ResolveAgentCreateConfigResult; isCreateConfigUnattended?(input: AgentCreateConfigUnattendedInput): boolean; diff --git a/packages/server/src/server/agent/provider-registry.test.ts b/packages/server/src/server/agent/provider-registry.test.ts index 262051844..d41d8092c 100644 --- a/packages/server/src/server/agent/provider-registry.test.ts +++ b/packages/server/src/server/agent/provider-registry.test.ts @@ -10,6 +10,16 @@ import type { ProviderCatalog, } from "./agent-sdk-types.js"; +const CLAUDE_CUSTOM_THINKING_FIELDS = { + thinkingOptions: [ + { id: "low", label: "Low" }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High", isDefault: true }, + { id: "max", label: "Max" }, + ], + defaultThinkingOptionId: "high", +} satisfies Partial; + const mockState = vi.hoisted(() => { interface ConstructorEntry { runtimeSettings?: unknown; @@ -64,55 +74,62 @@ vi.mock("../../executable-resolution/executable-resolution.js", () => ({ isCommandAvailable: mockState.isCommandAvailable, })); -vi.mock("./providers/claude/agent.js", () => ({ - ClaudeAgentClient: class ClaudeAgentClient { - readonly capabilities = { - supportsStreaming: true, - supportsSessionPersistence: true, - supportsDynamicModes: true, - supportsMcpServers: true, - supportsReasoningStream: true, - supportsToolInvocations: true, - }; - readonly provider = "claude"; - readonly runtimeSettings?: unknown; - - constructor(options: { runtimeSettings?: unknown }) { - this.runtimeSettings = options.runtimeSettings; - mockState.constructorArgs.claude.push({ - runtimeSettings: options.runtimeSettings, - }); - } - - async createSession(): Promise { - throw new Error("not implemented"); - } - - async resumeSession(): Promise { - throw new Error("not implemented"); - } - - async fetchCatalog(): Promise { - return { - models: mockState.runtimeModels.get(this.provider) ?? [], - modes: [], +vi.mock("./providers/claude/agent.js", async () => { + const { resolveConfiguredClaudeModel } = await import("./providers/claude/models.js"); + return { + ClaudeAgentClient: class ClaudeAgentClient { + readonly capabilities = { + supportsStreaming: true, + supportsSessionPersistence: true, + supportsDynamicModes: true, + supportsMcpServers: true, + supportsReasoningStream: true, + supportsToolInvocations: true, }; - } + readonly provider = "claude"; + readonly runtimeSettings?: unknown; - async isAvailable(): Promise { - const command: { mode?: string; argv?: string[] } | undefined = - typeof this.runtimeSettings === "object" && this.runtimeSettings !== null - ? Reflect.get(this.runtimeSettings, "command") - : undefined; - if (command?.mode === "replace") { - const { isCommandAvailable } = - await import("../../executable-resolution/executable-resolution.js"); - return await isCommandAvailable(command.argv?.[0] ?? ""); + constructor(options: { runtimeSettings?: unknown }) { + this.runtimeSettings = options.runtimeSettings; + mockState.constructorArgs.claude.push({ + runtimeSettings: options.runtimeSettings, + }); } - return true; - } - }, -})); + + async createSession(): Promise { + throw new Error("not implemented"); + } + + async resumeSession(): Promise { + throw new Error("not implemented"); + } + + async fetchCatalog(): Promise { + return { + models: mockState.runtimeModels.get(this.provider) ?? [], + modes: [], + }; + } + + resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition { + return resolveConfiguredClaudeModel(model); + } + + async isAvailable(): Promise { + const command: { mode?: string; argv?: string[] } | undefined = + typeof this.runtimeSettings === "object" && this.runtimeSettings !== null + ? Reflect.get(this.runtimeSettings, "command") + : undefined; + if (command?.mode === "replace") { + const { isCommandAvailable } = + await import("../../executable-resolution/executable-resolution.js"); + return await isCommandAvailable(command.argv?.[0] ?? ""); + } + return true; + } + }, + }; +}); vi.mock("./providers/codex-app-server-agent.js", () => ({ CodexAppServerAgentClient: class CodexAppServerAgentClient { @@ -1114,6 +1131,7 @@ describe("model merging", () => { provider: "claude", id: "profile-fast", label: "Profile Fast", + ...CLAUDE_CUSTOM_THINKING_FIELDS, }, ]); }); @@ -1160,11 +1178,13 @@ describe("model merging", () => { provider: "claude", id: "shared-model", label: "Profile Label", + ...CLAUDE_CUSTOM_THINKING_FIELDS, }, { provider: "claude", id: "profile-model", label: "Profile Model", + ...CLAUDE_CUSTOM_THINKING_FIELDS, }, ]); }); @@ -1249,6 +1269,7 @@ describe("model merging", () => { id: "shared-model", label: "Profile Label", description: "Runtime description", + ...CLAUDE_CUSTOM_THINKING_FIELDS, metadata: { source: "runtime", }, @@ -1314,6 +1335,7 @@ describe("model merging", () => { id: "profile-default", label: "Profile Default", isDefault: true, + ...CLAUDE_CUSTOM_THINKING_FIELDS, }, ]); }); @@ -1345,6 +1367,45 @@ describe("model merging", () => { ]); }); + test("Claude configured models can override or disable inferred thinking options", async () => { + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + models: [ + { id: "custom-defaults", label: "Defaults" }, + { id: "claude-sonnet-5", label: "Known" }, + { id: "claude-opus-5", label: "Disabled", thinkingOptions: [] }, + { + id: "custom-explicit", + label: "Explicit", + thinkingOptions: [{ id: "bespoke", label: "Bespoke", isDefault: true }], + }, + ], + }, + }, + }); + + const { models } = await registry.claude.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models.find((model) => model.id === "custom-defaults")).toMatchObject( + CLAUDE_CUSTOM_THINKING_FIELDS, + ); + expect( + models + .find((model) => model.id === "claude-sonnet-5") + ?.thinkingOptions?.map((option) => option.id), + ).toEqual(["off", "low", "medium", "high", "xhigh", "max", "ultracode"]); + expect(models.find((model) => model.id === "claude-opus-5")?.thinkingOptions).toEqual([]); + expect(models.find((model) => model.id === "custom-explicit")).toMatchObject({ + thinkingOptions: [{ id: "bespoke", label: "Bespoke", isDefault: true }], + defaultThinkingOptionId: "bespoke", + }); + }); + test("built-in createClient().fetchCatalog() honors profile model replacement (issue #579)", async () => { mockState.runtimeModels.set("codex", [ { @@ -1415,6 +1476,39 @@ describe("model merging", () => { expect(defaultModel?.id).toBe("profile-default"); }); + test("explicit additional models override hidden compatibility entries", async () => { + mockState.runtimeModels.set("claude", [ + { + provider: "claude", + id: "claude-fable-5[1m]", + label: "Fable 5", + isSelectable: false, + }, + ]); + const registry = buildProviderRegistry(logger, { + providerOverrides: { + claude: { + additionalModels: [{ id: "claude-fable-5[1m]", label: "Gateway Fable 5" }], + }, + }, + }); + + const { models } = await registry.claude.fetchCatalog({ + scope: "workspace", + cwd: "/tmp/registry-models", + force: false, + }); + + expect(models).toEqual([ + expect.objectContaining({ + id: "claude-fable-5[1m]", + label: "Gateway Fable 5", + isSelectable: true, + defaultThinkingOptionId: "high", + }), + ]); + }); + test("built-in Claude models override replaces hardcoded first-party models (issue #1299)", async () => { mockState.runtimeModels.set("claude", [ { provider: "claude", id: "claude-opus-4-8", label: "Opus 4.8", isDefault: true }, diff --git a/packages/server/src/server/agent/provider-registry.ts b/packages/server/src/server/agent/provider-registry.ts index 96be83d16..541f78675 100644 --- a/packages/server/src/server/agent/provider-registry.ts +++ b/packages/server/src/server/agent/provider-registry.ts @@ -287,6 +287,17 @@ function mapModel( return normalizeAgentModelDefinition({ ...model, provider }); } +function resolveConfiguredModels( + provider: AgentProvider, + client: AgentClient, + models: ProviderProfileModel[], +): AgentModelDefinition[] { + return models.map((model) => { + const mapped = mapModel(provider, model); + return client.resolveConfiguredModel?.(mapped) ?? mapped; + }); +} + function mergeModels( provider: AgentProvider, profileModels: ProviderProfileModel[], @@ -309,7 +320,7 @@ function mergeModels( function mergeModelAdditions( provider: AgentProvider, baseModels: AgentModelDefinition[], - modelAdditions: ProviderProfileModel[], + modelAdditions: Array, ): AgentModelDefinition[] { if (modelAdditions.length === 0) { return baseModels; @@ -328,9 +339,13 @@ function mergeModelAdditions( continue; } + const existingModel = mergedModels[existingIndex]; + const explicitlyEnablesCompatibilityModel = + existingModel?.isSelectable === false && additionalModel.isSelectable === undefined; mergedModels[existingIndex] = { - ...mergedModels[existingIndex], + ...existingModel, ...additionalModel, + ...(explicitlyEnablesCompatibilityModel ? { isSelectable: true } : {}), }; } @@ -444,6 +459,7 @@ function wrapClientProvider( }) : undefined, resolveCreateConfig: inner.resolveCreateConfig?.bind(inner), + resolveConfiguredModel: inner.resolveConfiguredModel?.bind(inner), isCreateConfigUnattended: inner.isCreateConfigUnattended?.bind(inner), listFeatures: listFeatures ? async (config) => await listFeatures({ ...config, provider: inner.provider }) @@ -490,10 +506,15 @@ function createRegistryEntry( resolved: ResolvedProvider, ): ProviderDefinition { const modelClient = resolved.createBaseClient(logger); - const hasReplacementModels = - resolved.profileModels.length > 0 && !resolved.profileModelsAreAdditive; + const profileModels = resolveConfiguredModels(provider, modelClient, resolved.profileModels); + const additionalModels = resolveConfiguredModels( + provider, + modelClient, + resolved.additionalModels, + ); + const hasReplacementModels = profileModels.length > 0 && !resolved.profileModelsAreAdditive; const replacementModels = hasReplacementModels - ? resolved.profileModels.map((model) => mapModel(provider, model)) + ? profileModels.map((model) => mapModel(provider, model)) : []; const decorateModes = (modes: AgentMode[]): AgentMode[] => @@ -524,7 +545,7 @@ function createRegistryEntry( // Replacement models skip runtime model discovery, but additionalModels // must still be merged on top. If modes are dynamic, probe for modes via // the single catalog API; otherwise use static/empty modes with no runtime. - const models = mergeModelAdditions(provider, replacementModels, resolved.additionalModels); + const models = mergeModelAdditions(provider, replacementModels, additionalModels); if (hasStaticModes) { const defaultModeId = await catalogClient.resolveDefaultModeId?.({ config: { @@ -545,15 +566,9 @@ function createRegistryEntry( const catalog = await catalogClient.fetchCatalog(options); return { ...catalog, - models: mergeModels( - provider, - resolved.profileModels, - resolved.additionalModels, - catalog.models, - { - profileModelsAreAdditive: resolved.profileModelsAreAdditive, - }, - ), + models: mergeModels(provider, profileModels, additionalModels, catalog.models, { + profileModelsAreAdditive: resolved.profileModelsAreAdditive, + }), modes: decorateModes(catalog.modes), }; }, @@ -566,16 +581,17 @@ function createResolvedProviderClient( resolved: ResolvedProvider, ): AgentClient { const inner = resolved.createBaseClient(logger); - const hasModelOverrides = - resolved.profileModels.length > 0 || resolved.additionalModels.length > 0; + const profileModels = resolveConfiguredModels(provider, inner, resolved.profileModels); + const additionalModels = resolveConfiguredModels(provider, inner, resolved.additionalModels); + const hasModelOverrides = profileModels.length > 0 || additionalModels.length > 0; if (inner.provider === provider && !hasModelOverrides) { return inner; } return wrapClientProvider( provider, inner, - resolved.profileModels, - resolved.additionalModels, + profileModels, + additionalModels, resolved.profileModelsAreAdditive, ); } diff --git a/packages/server/src/server/agent/provider-snapshot-manager.test.ts b/packages/server/src/server/agent/provider-snapshot-manager.test.ts index 980abe088..591ae705e 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.test.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.test.ts @@ -440,6 +440,38 @@ describe("ProviderSnapshotManager public surface", () => { } }); + test("listModels excludes compatibility-only catalog entries", async () => { + const client = createExtraClient("codex", { + isAvailable: async () => true, + fetchCatalog: async () => ({ + models: [ + { provider: "codex", id: "gpt-5.4", label: "GPT 5.4" }, + { + provider: "codex", + id: "gpt-5.4-legacy", + label: "GPT 5.4 legacy", + isSelectable: false, + }, + ], + modes: [], + }), + }); + const manager = new ProviderSnapshotManager({ + logger: createTestLogger(), + extraClients: { codex: client }, + }); + try { + const models = await manager.listModels({ + cwd: "/tmp/project", + provider: "codex", + wait: true, + }); + expect(models.map((model) => model.id)).toEqual(["gpt-5.4"]); + } finally { + manager.destroy(); + } + }); + test("listModes rejects when the provider is disabled", async () => { const manager = new ProviderSnapshotManager({ logger: createTestLogger(), diff --git a/packages/server/src/server/agent/provider-snapshot-manager.ts b/packages/server/src/server/agent/provider-snapshot-manager.ts index 771c4d24c..cbfc1c3ed 100644 --- a/packages/server/src/server/agent/provider-snapshot-manager.ts +++ b/packages/server/src/server/agent/provider-snapshot-manager.ts @@ -6,14 +6,15 @@ import type { Logger } from "pino"; import { expandTilde } from "../../utils/path.js"; import { withTimeout } from "../../utils/promise-timeout.js"; -import type { - AgentClient, - AgentCreateConfigParent, - AgentMode, - AgentModelDefinition, - AgentProvider, - FetchCatalogOptions, - ProviderSnapshotEntry, +import { + filterSelectableAgentModels, + type AgentClient, + type AgentCreateConfigParent, + type AgentMode, + type AgentModelDefinition, + type AgentProvider, + type FetchCatalogOptions, + type ProviderSnapshotEntry, } from "./agent-sdk-types.js"; import type { ManagedAgent } from "./agent-manager.js"; import type { WorkspaceGitService } from "../workspace-git-service.js"; @@ -317,7 +318,7 @@ export class ProviderSnapshotManager { async listModels(input: ProviderSnapshotProviderOptions): Promise { const entry = await this.getReadyProvider(input); - return entry.models ?? []; + return filterSelectableAgentModels(entry.models); } async listModes(input: ProviderSnapshotProviderOptions): Promise { diff --git a/packages/server/src/server/agent/providers/claude/agent.test.ts b/packages/server/src/server/agent/providers/claude/agent.test.ts index 4bcb0eead..a4cd72f4f 100644 --- a/packages/server/src/server/agent/providers/claude/agent.test.ts +++ b/packages/server/src/server/agent/providers/claude/agent.test.ts @@ -417,8 +417,8 @@ describe("ClaudeAgentClient.fetchCatalog", () => { expect(models.map((m) => m.id)).toEqual([ "claude-opus-5", - "claude-fable-5[1m]", "claude-fable-5", + "claude-fable-5[1m]", "claude-opus-4-8[1m]", "claude-opus-4-8", "claude-sonnet-5", @@ -431,6 +431,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { "claude-sonnet-4-6", "claude-haiku-4-5", ]); + expect(models.find((model) => model.id === "claude-fable-5[1m]")?.isSelectable).toBe(false); for (const model of models) { expect(model.provider).toBe("claude"); @@ -461,7 +462,7 @@ describe("ClaudeAgentClient.fetchCatalog", () => { }); expect(models.find((model) => model.isDefault)?.id).toBe("claude-opus-5"); - expect(models.map((model) => model.id)).toContain("claude-fable-5[1m]"); + expect(models.map((model) => model.id)).toContain("claude-fable-5"); } finally { await fs.rm(emptyConfigDir, { recursive: true, force: true }); } @@ -626,6 +627,31 @@ describe("ClaudeAgentSession features", () => { return { queryFactory, queryMock, launches }; } + test("passes exact configured Fable 5 IDs through to Claude Code", async () => { + const { queryFactory, queryMock } = createQueryMock(); + const client = new ClaudeAgentClient({ + logger, + queryFactory, + resolveBinary: async () => "/test/claude/bin", + }); + const session = await client.createSession({ + provider: "claude", + cwd: process.cwd(), + model: "claude-fable-5[1m]", + }); + + await ( + session as unknown as { + ensureQuery(): Promise; + } + ).ensureQuery(); + expect(queryFactory.mock.calls[0]?.[0].options.model).toBe("claude-fable-5[1m]"); + + await session.setModel?.("claude-fable-5[1m]"); + expect(queryMock.setModel).toHaveBeenCalledWith("claude-fable-5[1m]"); + await session.close(); + }); + test("lists fast mode only for supported Opus models", async () => { const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" }); @@ -751,7 +777,7 @@ describe("ClaudeAgentSession features", () => { test.each([ ["supported model", "claude-opus-4-8", { type: "disabled" }, undefined], - ["unsupported model", "claude-fable-5", { type: "adaptive" }, "low"], + ["unsupported model", "claude-fable-5", { type: "adaptive" }, "high"], ["custom model", "openrouter/anthropic/claude-opus-4-8", undefined, undefined], ["provider default", null, undefined, undefined], ])("reconciles Off when switching to a %s", async (_label, modelId, thinking, effort) => { diff --git a/packages/server/src/server/agent/providers/claude/agent.ts b/packages/server/src/server/agent/providers/claude/agent.ts index bc79c586c..3aa82215e 100644 --- a/packages/server/src/server/agent/providers/claude/agent.ts +++ b/packages/server/src/server/agent/providers/claude/agent.ts @@ -33,6 +33,7 @@ import { findClaudeModel, getClaudeModelsWithSettings, normalizeClaudeRuntimeModelId, + resolveConfiguredClaudeModel, } from "./models.js"; import { CLAUDE_DISABLED_THINKING_OPTION_ID, @@ -85,6 +86,7 @@ import { type AgentLaunchContext, type AgentMetadata, type AgentMode, + type AgentModelDefinition, type AgentPermissionRequest, type AgentPermissionRequestKind, type AgentPermissionResponse, @@ -1467,6 +1469,10 @@ export class ClaudeAgentClient implements AgentClient { this.configDir = options.configDir; } + resolveConfiguredModel(model: AgentModelDefinition): AgentModelDefinition { + return resolveConfiguredClaudeModel(model); + } + async createSession( config: AgentSessionConfig, launchContext?: AgentLaunchContext, @@ -1629,7 +1635,12 @@ export class ClaudeAgentClient implements AgentClient { if (config.provider !== "claude") { throw new Error(`ClaudeAgentClient received config for provider '${config.provider}'`); } - return { ...config, provider: "claude" } as ClaudeAgentConfig; + const model = config.model?.trim(); + return { + ...config, + provider: "claude", + model: model || undefined, + } as ClaudeAgentConfig; } } @@ -2287,7 +2298,7 @@ class ClaudeAgentSession implements AgentSession { async setModel(modelId: string | null): Promise { const normalizedModelId = - typeof modelId === "string" && modelId.trim().length > 0 ? modelId : null; + typeof modelId === "string" && modelId.trim().length > 0 ? modelId.trim() : null; const activeQuery = await this.ensureQuery(); await activeQuery.setModel(normalizedModelId ?? undefined); this.config.model = normalizedModelId ?? undefined; diff --git a/packages/server/src/server/agent/providers/claude/model-manifest.ts b/packages/server/src/server/agent/providers/claude/model-manifest.ts index d091e07c7..d8d6cc398 100644 --- a/packages/server/src/server/agent/providers/claude/model-manifest.ts +++ b/packages/server/src/server/agent/providers/claude/model-manifest.ts @@ -4,6 +4,7 @@ type ClaudeEffortLevel = "low" | "medium" | "high" | "xhigh" | "max"; interface ClaudeModelManifestEntry { id: string; + aliases?: readonly string[]; label: string; description: string; defaultPriority?: number; @@ -27,6 +28,8 @@ const CLAUDE_EFFORT_LABELS = { max: "Max", } as const satisfies Record; +export const CLAUDE_DEFAULT_THINKING_OPTION_ID = "high"; + export const CLAUDE_DISABLED_THINKING_OPTION_ID = "off"; export const CLAUDE_ULTRACODE_THINKING_OPTION_ID = "ultracode"; @@ -41,20 +44,14 @@ export const CLAUDE_MODEL_MANIFEST = [ effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, supportsThinkingDisabled: true, }, - { - id: "claude-fable-5[1m]", - label: "Fable 5 1M", - description: "Fable 5 with 1M context window", - minimumClaudeCodeVersion: "2.1.169", - contextWindowMaxTokens: 1_000_000, - effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, - }, { id: "claude-fable-5", + // COMPAT(claudeFable5OneMillionId): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 app preferences are outside support. + aliases: ["claude-fable-5[1m]"], label: "Fable 5", description: "Fable 5 · Most powerful model", minimumClaudeCodeVersion: "2.1.169", - contextWindowMaxTokens: 200_000, + contextWindowMaxTokens: 1_000_000, effortLevels: CLAUDE_EFFORT_LEVELS.xhigh, }, { @@ -165,6 +162,7 @@ function buildThinkingOptions( ...effortLevels.map((id) => ({ id, label: CLAUDE_EFFORT_LABELS[id], + ...(id === CLAUDE_DEFAULT_THINKING_OPTION_ID ? { isDefault: true } : {}), })), ]; @@ -185,7 +183,8 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD undefined, ); - return availableModels.map((model) => { + const definitions: AgentModelDefinition[] = []; + for (const model of availableModels) { const thinkingOptions = buildThinkingOptions( model.effortLevels, model.supportsThinkingDisabled === true, @@ -196,6 +195,9 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD label: model.label, description: model.description, }; + if ("aliases" in model && model.aliases) { + definition.aliases = [...model.aliases]; + } if (model === defaultModel) { definition.isDefault = true; } @@ -204,10 +206,24 @@ export function getClaudeManifestModels(claudeCodeVersion?: string): AgentModelD } if (thinkingOptions) { definition.thinkingOptions = thinkingOptions; - definition.defaultThinkingOptionId = model.effortLevels?.[0]; + definition.defaultThinkingOptionId = CLAUDE_DEFAULT_THINKING_OPTION_ID; } - return definition; - }); + definitions.push(definition); + if (!("aliases" in model) || !model.aliases) { + continue; + } + // COMPAT(claudeFable5LegacyCatalogEntry): added in v0.3.0, remove after 2027-02-06 once pre-v0.3.0 apps are outside support. + for (const alias of model.aliases) { + definitions.push({ + ...definition, + id: alias, + aliases: undefined, + isDefault: undefined, + isSelectable: false, + }); + } + } + return definitions; } function isModelAvailableInClaudeCode( @@ -262,7 +278,7 @@ export function resolveClaudeDisabledThinkingForModel( supported: !!model && "supportsThinkingDisabled" in model && model.supportsThinkingDisabled === true, fallbackThinkingOptionId: - model && "effortLevels" in model ? model.effortLevels?.[0] : undefined, + model && "effortLevels" in model ? CLAUDE_DEFAULT_THINKING_OPTION_ID : undefined, }; } @@ -368,6 +384,14 @@ export function normalizeClaudeRuntimeModelId(value: string | null | undefined): ); } +export function getClaudeCustomModelThinkingOptions(): AgentSelectOption[] { + return CLAUDE_EFFORT_LEVELS.standard.map((id) => { + const option: AgentSelectOption = { id, label: CLAUDE_EFFORT_LABELS[id] }; + if (id === CLAUDE_DEFAULT_THINKING_OPTION_ID) option.isDefault = true; + return option; + }); +} + function normalizeSingleSegmentClaudeModelId( familyValue: string, major: string, diff --git a/packages/server/src/server/agent/providers/claude/models.test.ts b/packages/server/src/server/agent/providers/claude/models.test.ts index 24a49baa7..bb7c651bd 100644 --- a/packages/server/src/server/agent/providers/claude/models.test.ts +++ b/packages/server/src/server/agent/providers/claude/models.test.ts @@ -51,8 +51,8 @@ describe("getClaudeModels", () => { const models = getClaudeModels(); expect(models.map((m) => m.id)).toEqual([ "claude-opus-5", - "claude-fable-5[1m]", "claude-fable-5", + "claude-fable-5[1m]", "claude-opus-4-8[1m]", "claude-opus-4-8", "claude-sonnet-5", @@ -82,8 +82,8 @@ describe("getClaudeModels", () => { expect(contextWindows).toEqual( new Map([ ["claude-opus-5", 1_000_000], + ["claude-fable-5", 1_000_000], ["claude-fable-5[1m]", 1_000_000], - ["claude-fable-5", 200_000], ["claude-opus-4-8[1m]", 1_000_000], ["claude-opus-4-8", 200_000], ["claude-sonnet-5", 200_000], @@ -105,9 +105,7 @@ describe("getClaudeModels", () => { expect(oldVersionModels.find((model) => model.isDefault)?.id).toBe("claude-opus-4-8"); expect(getClaudeModels("2.1.219").map((model) => model.id)).toContain("claude-opus-5"); - expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5[1m]"); expect(getClaudeModels("2.1.168").map((model) => model.id)).not.toContain("claude-fable-5"); - expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5[1m]"); expect(getClaudeModels("2.1.169").map((model) => model.id)).toContain("claude-fable-5"); }); @@ -138,7 +136,7 @@ describe("getClaudeModels", () => { ?.thinkingOptions?.find((option) => option.id === CLAUDE_ULTRACODE_THINKING_OPTION_ID) ?.label, ).toBe("Ultra Code"); - expect(models.get("claude-sonnet-5")?.defaultThinkingOptionId).toBe("low"); + expect(models.get("claude-sonnet-5")?.defaultThinkingOptionId).toBe("high"); expect(models.get("claude-sonnet-5[1m]")?.thinkingOptions).toEqual( models.get("claude-sonnet-5")?.thinkingOptions, ); @@ -162,19 +160,16 @@ describe("getClaudeModels", () => { expect(models.get("claude-fable-5")?.thinkingOptions?.map((option) => option.id)).not.toContain( CLAUDE_DISABLED_THINKING_OPTION_ID, ); - expect(models.get("claude-fable-5[1m]")?.thinkingOptions).toEqual( - models.get("claude-fable-5")?.thinkingOptions, - ); expect(models.get("claude-haiku-4-5")?.thinkingOptions).toBeUndefined(); }); it.each([ - ["claude-opus-5", true, "low"], - ["claude-opus-5-20260724", true, "low"], - ["claude-sonnet-5", true, "low"], - ["claude-sonnet-5[1m]", true, "low"], - ["claude-sonnet-5-20260101", true, "low"], - ["claude-fable-5", false, "low"], + ["claude-opus-5", true, "high"], + ["claude-opus-5-20260724", true, "high"], + ["claude-sonnet-5", true, "high"], + ["claude-sonnet-5[1m]", true, "high"], + ["claude-sonnet-5-20260101", true, "high"], + ["claude-fable-5", false, "high"], ["claude-haiku-4-5", false, undefined], ["openrouter/anthropic/claude-opus-4-8", false, undefined], [null, false, undefined], @@ -327,6 +322,26 @@ describe("ClaudeAgentClient.fetchCatalog", () => { ]); }); + it("lets an exact settings model override the hidden Fable compatibility entry", async () => { + const configDir = await createClaudeConfigDir({ model: "claude-fable-5[1m]" }); + vi.stubEnv("CLAUDE_CONFIG_DIR", configDir); + const client = createCatalogClient(); + + const { models } = await client.fetchCatalog({ + scope: "workspace", + cwd: os.tmpdir(), + force: true, + }); + + const configured = models.filter((model) => model.id === "claude-fable-5[1m]"); + expect(configured).toHaveLength(1); + expect(configured[0]).toMatchObject({ + id: "claude-fable-5[1m]", + isSelectable: true, + defaultThinkingOptionId: "high", + }); + }); + it("omits models that require a newer Claude Code version", async () => { const client = createCatalogClient("2.1.218"); @@ -345,7 +360,7 @@ describe("normalizeClaudeRuntimeModelId", () => { it("returns exact match for known model IDs", () => { expect(normalizeClaudeRuntimeModelId("claude-opus-5")).toBe("claude-opus-5"); expect(normalizeClaudeRuntimeModelId("claude-fable-5")).toBe("claude-fable-5"); - expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]"); + expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-5")).toBe("claude-sonnet-5"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-opus-4-6")).toBe("claude-opus-4-6"); @@ -361,14 +376,14 @@ describe("normalizeClaudeRuntimeModelId", () => { expect(normalizeClaudeRuntimeModelId("claude-opus-4-6-20260101")).toBe("claude-opus-4-6"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-4-6-20260101")).toBe("claude-sonnet-4-6"); expect(normalizeClaudeRuntimeModelId("claude-haiku-4-5-20251001")).toBe("claude-haiku-4-5"); - expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5[1m]"); + expect(normalizeClaudeRuntimeModelId("claude-fable-5-20260301[1m]")).toBe("claude-fable-5"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-5-20260101[1m]")).toBe( "claude-sonnet-5[1m]", ); }); - it("preserves [1m] suffix from runtime model strings", () => { - expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5[1m]"); + it("preserves [1m] only when it identifies a distinct catalog entry", () => { + expect(normalizeClaudeRuntimeModelId("claude-fable-5[1m]")).toBe("claude-fable-5"); expect(normalizeClaudeRuntimeModelId("claude-sonnet-5[1m]")).toBe("claude-sonnet-5[1m]"); expect(normalizeClaudeRuntimeModelId("claude-opus-4-6[1m]")).toBe("claude-opus-4-6[1m]"); }); @@ -435,11 +450,47 @@ describe("Claude Opus 5 catalog", () => { it("keeps disabled thinking available for agents persisted on the retired 1M ID", () => { expect(resolveClaudeDisabledThinkingForModel("claude-opus-5[1m]")).toEqual({ supported: true, - fallbackThinkingOptionId: "low", + fallbackThinkingOptionId: "high", }); }); }); +describe("Claude Fable 5 catalog", () => { + it("offers one selectable Fable 5 entry and a compatibility entry for old apps", () => { + const fable5Models = getClaudeModels() + .filter((model) => model.id.startsWith("claude-fable-5")) + .map(({ id, aliases, isSelectable, label, contextWindowMaxTokens }) => ({ + id, + aliases, + isSelectable, + label, + contextWindowMaxTokens, + })); + + expect(fable5Models).toEqual([ + { + id: "claude-fable-5", + aliases: ["claude-fable-5[1m]"], + isSelectable: undefined, + label: "Fable 5", + contextWindowMaxTokens: 1_000_000, + }, + { + id: "claude-fable-5[1m]", + aliases: undefined, + isSelectable: false, + label: "Fable 5", + contextWindowMaxTokens: 1_000_000, + }, + ]); + }); + + it("resolves retired Fable 5 IDs to the canonical catalog entry", () => { + expect(findClaudeModel("claude-fable-5[1m]")?.id).toBe("claude-fable-5"); + expect(findClaudeModel("claude-fable-5-20260301[1m]")?.id).toBe("claude-fable-5"); + }); +}); + describe("claudeManifestModelSupportsFastMode", () => { it("keeps fast mode strict to first-party manifest model IDs", () => { expect(normalizeClaudeManifestModelId("openrouter/anthropic/claude-opus-4-8")).toBeNull(); diff --git a/packages/server/src/server/agent/providers/claude/models.ts b/packages/server/src/server/agent/providers/claude/models.ts index 51601ecb9..c24e5ad36 100644 --- a/packages/server/src/server/agent/providers/claude/models.ts +++ b/packages/server/src/server/agent/providers/claude/models.ts @@ -5,7 +5,9 @@ import type { Logger } from "pino"; import type { AgentModelDefinition } from "../../agent-sdk-types.js"; import { + getClaudeCustomModelThinkingOptions, getClaudeManifestModels, + normalizeClaudeManifestModelId, normalizeClaudeRuntimeModelId as normalizeClaudeManifestRuntimeModelId, } from "./model-manifest.js"; @@ -21,6 +23,21 @@ export function getClaudeModels(claudeCodeVersion?: string): AgentModelDefinitio return getClaudeManifestModels(claudeCodeVersion); } +export function resolveConfiguredClaudeModel(model: AgentModelDefinition): AgentModelDefinition { + if (model.thinkingOptions !== undefined) return model; + + const manifestModelId = normalizeClaudeManifestModelId(model.id); + const manifestModel = manifestModelId + ? getClaudeModels().find((candidate) => candidate.id === manifestModelId) + : undefined; + if (manifestModel) { + return manifestModel.thinkingOptions + ? { ...model, thinkingOptions: manifestModel.thinkingOptions } + : model; + } + return { ...model, thinkingOptions: getClaudeCustomModelThinkingOptions() }; +} + export function findClaudeModel( modelId: string | null | undefined, ): AgentModelDefinition | undefined { @@ -42,14 +59,17 @@ export async function getClaudeModelsWithSettings( return hardcodedModels; } - const seenModelIds = new Set(hardcodedModels.map((model) => model.id)); const models = [...hardcodedModels]; for (const model of settingsModels) { - if (seenModelIds.has(model.id)) { + const existingIndex = models.findIndex((candidate) => candidate.id === model.id); + if (existingIndex !== -1) { + const existing = models[existingIndex]; + if (existing?.isSelectable === false) { + models[existingIndex] = { ...existing, ...model, isSelectable: true }; + } continue; } - seenModelIds.add(model.id); models.push(model); } @@ -149,8 +169,8 @@ const CLAUDE_PLACEHOLDER_MODEL_IDS = new Set([""]); * (docs/custom-providers.md) among them. Manifest-only resolution would blank the model for * exactly those users. * - * Note a `[1m]` suffix is preserved where it names its own manifest entry: a 1M-context variant - * is a distinct model with a distinct context window, not a spelling of the 200K one. + * A `[1m]` suffix is preserved where it names its own manifest entry. Models such as Fable 5 + * that only have a 1M entry normalize the retired suffixed spelling to the canonical ID. * * Returns null for placeholders and empty values, meaning "not observed". */ diff --git a/packages/server/src/server/agent/providers/mock-load-test-agent.ts b/packages/server/src/server/agent/providers/mock-load-test-agent.ts index 12d90b5db..147045ea0 100644 --- a/packages/server/src/server/agent/providers/mock-load-test-agent.ts +++ b/packages/server/src/server/agent/providers/mock-load-test-agent.ts @@ -57,10 +57,17 @@ const MODELS: AgentModelDefinition[] = [ { provider: MOCK_LOAD_TEST_PROVIDER_ID, id: MOCK_LOAD_TEST_DEFAULT_MODEL_ID, + aliases: ["legacy-five-minute-stream"], label: "Five minute stream", description: "Realistic agent flow streamed as sub-word tokens for five minutes (good for scroll/coalesce debugging).", isDefault: true, + thinkingOptions: [ + { id: "low", label: "Low", isDefault: true }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "low", metadata: { durationMs: MOCK_LOAD_TEST_DURATION_MS, intervalMs: MOCK_LOAD_TEST_INTERVAL_MS, @@ -76,6 +83,22 @@ const MODELS: AgentModelDefinition[] = [ intervalMs: 50, }, }, + { + provider: MOCK_LOAD_TEST_PROVIDER_ID, + id: "legacy-five-minute-stream", + label: "Legacy five minute stream", + isSelectable: false, + thinkingOptions: [ + { id: "low", label: "Low", isDefault: true }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "low", + metadata: { + durationMs: MOCK_LOAD_TEST_DURATION_MS, + intervalMs: MOCK_LOAD_TEST_INTERVAL_MS, + }, + }, { provider: MOCK_LOAD_TEST_PROVIDER_ID, id: "one-minute-stream", @@ -91,6 +114,12 @@ const MODELS: AgentModelDefinition[] = [ id: "ten-second-stream", label: "Ten second stream", description: "Fast realistic stream for tests and smoke checks.", + thinkingOptions: [ + { id: "low", label: "Low", isDefault: true }, + { id: "medium", label: "Medium" }, + { id: "high", label: "High" }, + ], + defaultThinkingOptionId: "low", metadata: { durationMs: 10_000, intervalMs: 5, diff --git a/packages/server/src/server/session/provider/provider-catalog-session.test.ts b/packages/server/src/server/session/provider/provider-catalog-session.test.ts index 8ed07b0d5..53d869b0d 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.test.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.test.ts @@ -217,6 +217,38 @@ describe("ProviderCatalogSession", () => { expect(res?.payload.error).toBe("Provider codex is disabled"); }); + it("hides compatibility-only entries from list_provider_models", async () => { + const { subsystem, emitted } = makeSubsystem({ + snapshot: { + getSnapshot: () => [ + { + provider: "codex", + status: "ready", + enabled: true, + models: [ + { provider: "codex", id: "gpt-5.4", label: "GPT 5.4" }, + { + provider: "codex", + id: "gpt-5.4-legacy", + label: "GPT 5.4 legacy", + isSelectable: false, + }, + ], + }, + ], + }, + }); + + await subsystem.handleListProviderModelsRequest({ + type: "list_provider_models_request", + provider: "codex", + requestId: "m-selectable", + }); + + const response = findByType(emitted, "list_provider_models_response"); + expect(response?.payload.models?.map((model) => model.id)).toEqual(["gpt-5.4"]); + }); + it("preserves missing cwd as the semantic global snapshot for model list reads", async () => { const getSnapshot = vi.fn(() => [{ provider: "codex", status: "loading", enabled: true }]); const warmUpSnapshotForCwd = vi.fn(async () => {}); diff --git a/packages/server/src/server/session/provider/provider-catalog-session.ts b/packages/server/src/server/session/provider/provider-catalog-session.ts index a394e4832..7b122411c 100644 --- a/packages/server/src/server/session/provider/provider-catalog-session.ts +++ b/packages/server/src/server/session/provider/provider-catalog-session.ts @@ -10,11 +10,12 @@ import { isGlobalProviderSnapshotKey, type ProviderSnapshotManager, } from "../../agent/provider-snapshot-manager.js"; -import type { - AgentFeature, - AgentProvider, - AgentSessionConfig, - ProviderSnapshotEntry, +import { + filterSelectableAgentModels, + type AgentFeature, + type AgentProvider, + type AgentSessionConfig, + type ProviderSnapshotEntry, } from "../../agent/agent-sdk-types.js"; import type { ProviderAvailability } from "../../agent/agent-manager.js"; import type { ProviderUsageService } from "../../../services/quota-fetcher/service.js"; @@ -202,7 +203,7 @@ export class ProviderCatalogSession { type: "list_provider_models_response", payload: { provider: msg.provider, - models: entry.models ?? [], + models: filterSelectableAgentModels(entry.models), error: null, fetchedAt: entry.fetchedAt ?? fetchedAt, requestId: msg.requestId,