diff --git a/apps/web/src/components/ChatView.logic.ts b/apps/web/src/components/ChatView.logic.ts index 2e4b2b30929db..2ba094c23e1ac 100644 --- a/apps/web/src/components/ChatView.logic.ts +++ b/apps/web/src/components/ChatView.logic.ts @@ -531,6 +531,7 @@ export function resolveComposerProviderSelection(input: { : requestedInstanceId; return { selectedProviderEntry, + requestedInstanceId, requestedDriverKind, lockedContinuationGroupKey, unavailableProviderInstanceId, diff --git a/apps/web/src/components/chat/useComposerProviderModel.ts b/apps/web/src/components/chat/useComposerProviderModel.ts index 7fd1130859fec..7d36d56b08922 100644 --- a/apps/web/src/components/chat/useComposerProviderModel.ts +++ b/apps/web/src/components/chat/useComposerProviderModel.ts @@ -70,6 +70,7 @@ export function useComposerProviderModel( providers: providerStatuses, selectedProvider, selectedInstanceId, + requestedInstanceId: selection.requestedInstanceId, threadModelSelection, projectModelSelection, settings, diff --git a/apps/web/src/composerDraftStore.ts b/apps/web/src/composerDraftStore.ts index e823d2ddf33a3..08faddc903708 100644 --- a/apps/web/src/composerDraftStore.ts +++ b/apps/web/src/composerDraftStore.ts @@ -637,17 +637,10 @@ interface ComposerDraftModelState { modelSelectionByProvider: Partial>; } -function providerSelectionsFromModelSelection( +function selectionsByInstance( modelSelection: ModelSelection | null | undefined, -): ProviderOptionSelectionsByProvider | null { - if (!modelSelection) { - return null; - } - const options = modelSelection.options; - if (!options || options.length === 0) { - return null; - } - return { [modelSelection.instanceId]: options }; +): Partial> | null { + return modelSelection ? { [modelSelection.instanceId]: modelSelection } : null; } function modelSelectionByProviderToOptions( @@ -1100,6 +1093,8 @@ export function deriveEffectiveComposerModelState(input: { providers: ReadonlyArray; selectedProvider: ProviderDriverKind; selectedInstanceId?: ProviderInstanceId | null | undefined; + /** The instance the draft, thread or project asked for; differs from the selection on fallback. */ + requestedInstanceId?: ProviderInstanceId | null | undefined; threadModelSelection: ModelSelection | null | undefined; projectModelSelection: ModelSelection | null | undefined; settings: UnifiedSettings; @@ -1159,18 +1154,67 @@ export function deriveEffectiveComposerModelState(input: { activeSelection.model, )) : baseModel; - const modelOptions = - modelSelectionByProviderToOptions(input.draft?.modelSelectionByProvider) ?? - providerSelectionsFromModelSelection(input.threadModelSelection) ?? - providerSelectionsFromModelSelection(input.projectModelSelection) ?? - null; + // Options come from the first of the draft, thread and project that has any. Their selections + // stay with them, so a fallback instance can check which model the options belong to. + const optionSelections = + [ + input.draft?.modelSelectionByProvider, + selectionsByInstance(input.threadModelSelection), + selectionsByInstance(input.projectModelSelection), + ].find((selections) => modelSelectionByProviderToOptions(selections) !== null) ?? null; + const modelOptions = modelSelectionByProviderToOptions(optionSelections); return { selectedModel, - modelOptions, + modelOptions: withFallbackInstanceOptions({ + ...input, + modelOptions, + optionSelections, + selectedModel, + }), }; } +/** + * When the requested instance is disabled or unavailable, the composer shows another instance of + * the same driver. When that instance has no options of its own and shows the model the requested + * instance's options were picked with, it takes those options instead of the model's defaults. + */ +function withFallbackInstanceOptions(input: { + readonly modelOptions: ProviderOptionSelectionsByProvider | null; + readonly optionSelections: Partial> | null; + readonly selectedModel: string; + readonly providers: ReadonlyArray; + readonly selectedProvider: ProviderDriverKind; + readonly selectedInstanceId?: ProviderInstanceId | null | undefined; + readonly requestedInstanceId?: ProviderInstanceId | null | undefined; +}): ProviderOptionSelectionsByProvider | null { + const { modelOptions, selectedInstanceId, requestedInstanceId } = input; + if ( + !modelOptions || + !selectedInstanceId || + !requestedInstanceId || + selectedInstanceId === requestedInstanceId || + modelOptions[selectedInstanceId] !== undefined + ) { + return modelOptions; + } + const requestedOptions = modelOptions[requestedInstanceId]; + const requestedDriver = input.providers.find( + (provider) => provider.instanceId === requestedInstanceId, + )?.driver; + // The model of the selection the options came from, which can differ from the shown model's source. + const requestedModel = input.optionSelections?.[requestedInstanceId]?.model; + if ( + !requestedOptions || + requestedDriver !== input.selectedProvider || + requestedModel !== input.selectedModel + ) { + return modelOptions; + } + return { ...modelOptions, [selectedInstanceId]: requestedOptions }; +} + function revokeObjectPreviewUrl(previewUrl: string): void { if (typeof URL === "undefined") { return; diff --git a/apps/web/src/modelSelection.test.ts b/apps/web/src/modelSelection.test.ts index 05a7e76b6b25b..b5e9be7d9e011 100644 --- a/apps/web/src/modelSelection.test.ts +++ b/apps/web/src/modelSelection.test.ts @@ -10,7 +10,12 @@ import { describe, expect, it } from "vite-plus/test"; import { createModelSelection } from "@t3tools/shared/model"; import { deriveEffectiveComposerModelState } from "./composerDraftStore"; import { getComposerProviderState } from "./components/chat/composerProviderState"; -import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances"; +import { resolveComposerProviderSelection } from "./components/ChatView.logic"; +import { + deriveProviderInstanceEntries, + NO_PROVIDER_MODEL_SELECTION, + providerInstanceEntriesFor, +} from "./providerInstances"; import { getAppModelOptionsForInstance, resolveAppModelSelectionForInstance, @@ -780,3 +785,159 @@ describe("default model settings and new-thread composer parity", () => { }, ); }); + +describe("composer fallback to another instance of the same driver", () => { + const claude = ProviderDriverKind.make("claudeAgent"); + const codex = ProviderDriverKind.make("codex"); + const disabled = ProviderInstanceId.make("claudeAgent"); + const fallback = ProviderInstanceId.make("claude_team"); + const effortModels: ServerProvider["models"] = [ + { + slug: "claude-opus-5-5", + name: "Claude Opus 5.5", + isCustom: false, + capabilities: { + optionDescriptors: [ + { + id: "effort", + label: "Effort", + type: "select", + options: ["medium", "high", "xhigh"].map((id) => ({ + id, + label: id, + isDefault: id === "medium", + })), + }, + ], + }, + }, + ]; + const providers: ReadonlyArray = [ + { + ...provider({ provider: claude, instanceId: disabled }), + enabled: false, + models: effortModels, + }, + { ...provider({ provider: claude, instanceId: fallback }), models: effortModels }, + ]; + const picked = createModelSelection(disabled, "claude-opus-5-5", [ + { id: "effort", value: "xhigh" }, + ]); + + // The same steps useComposerProviderModel takes, without the draft store. + function composerFor(input: { + readonly providers: ReadonlyArray; + readonly draft?: Parameters[0]["draft"]; + readonly threadModelSelection: ReturnType; + }) { + const settings = { + ...DEFAULT_UNIFIED_SETTINGS, + providerInstances: Object.fromEntries( + input.providers.map((provider) => [ + provider.instanceId, + { + driver: provider.driver, + enabled: provider.enabled, + config: {}, + }, + ]), + ), + }; + const selection = resolveComposerProviderSelection({ + entries: providerInstanceEntriesFor(input.providers, settings, null), + candidateInstanceIds: [input.threadModelSelection.instanceId], + lockedProvider: null, + lockedInstanceId: undefined, + }); + const selectedInstanceId = selection.selectedProviderEntry!.instanceId; + const selectedProvider = selection.selectedProviderEntry!.driverKind; + const state = deriveEffectiveComposerModelState({ + draft: input.draft ?? null, + providers: input.providers, + selectedProvider, + selectedInstanceId, + requestedInstanceId: selection.requestedInstanceId, + threadModelSelection: input.threadModelSelection, + projectModelSelection: null, + settings, + }); + const traits = getComposerProviderState({ + provider: selectedProvider, + model: state.selectedModel, + models: selection.selectedProviderEntry!.models, + modelOptions: state.modelOptions?.[selectedInstanceId], + planModeEnabled: false, + }); + return { selectedInstanceId, state, traits }; + } + + it("keeps the requested instance's effort on the fallback instance", () => { + const { selectedInstanceId, state, traits } = composerFor({ + providers, + threadModelSelection: picked, + }); + + expect(selectedInstanceId).toBe(fallback); + expect(state.selectedModel).toBe("claude-opus-5-5"); + expect(traits.promptEffort).toBe("xhigh"); + expect( + createModelSelection(fallback, state.selectedModel, traits.modelOptionsForDispatch), + ).toEqual( + createModelSelection(fallback, "claude-opus-5-5", [{ id: "effort", value: "xhigh" }]), + ); + }); + + it("keeps options the fallback instance already has", () => { + const { traits } = composerFor({ + providers, + threadModelSelection: picked, + draft: { + activeProvider: disabled, + modelSelectionByProvider: { + [disabled]: picked, + [fallback]: createModelSelection(fallback, "claude-opus-5-5", [ + { id: "effort", value: "high" }, + ]), + }, + }, + }); + + expect(traits.promptEffort).toBe("high"); + }); + + it("does not carry options picked with another model", () => { + // An optionless draft pick takes its options from the thread, which holds another model. + const { state, traits } = composerFor({ + providers, + threadModelSelection: createModelSelection(disabled, "claude-sonnet-4-6", [ + { id: "effort", value: "high" }, + ]), + draft: { + activeProvider: disabled, + modelSelectionByProvider: { [disabled]: createModelSelection(disabled, "claude-opus-5-5") }, + }, + }); + + expect(state.selectedModel).toBe("claude-opus-5-5"); + expect(traits.promptEffort).toBe("medium"); + expect(traits.modelOptionsForDispatch).toBeUndefined(); + }); + + it("does not carry options to another driver", () => { + const codexOnly: ReadonlyArray = [ + { + ...provider({ provider: claude, instanceId: disabled }), + enabled: false, + models: effortModels, + }, + provider({ provider: codex, instanceId: "codex", models: ["gpt-6-astra"] }), + ]; + const { selectedInstanceId, state } = composerFor({ + providers: codexOnly, + threadModelSelection: picked, + }); + + expect(selectedInstanceId).toBe(ProviderInstanceId.make("codex")); + expect(state.modelOptions?.[selectedInstanceId]).toBeUndefined(); + }); +});