Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/src/components/ChatView.logic.ts
Original file line number Diff line number Diff line change
Expand Up @@ -531,6 +531,7 @@ export function resolveComposerProviderSelection(input: {
: requestedInstanceId;
return {
selectedProviderEntry,
requestedInstanceId,
requestedDriverKind,
lockedContinuationGroupKey,
unavailableProviderInstanceId,
Expand Down
1 change: 1 addition & 0 deletions apps/web/src/components/chat/useComposerProviderModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,7 @@ export function useComposerProviderModel(
providers: providerStatuses,
selectedProvider,
selectedInstanceId,
requestedInstanceId: selection.requestedInstanceId,
threadModelSelection,
projectModelSelection,
settings,
Expand Down
76 changes: 60 additions & 16 deletions apps/web/src/composerDraftStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -637,17 +637,10 @@ interface ComposerDraftModelState {
modelSelectionByProvider: Partial<Record<ProviderInstanceId, ModelSelection>>;
}

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<Record<string, ModelSelection>> | null {
return modelSelection ? { [modelSelection.instanceId]: modelSelection } : null;
}

function modelSelectionByProviderToOptions(
Expand Down Expand Up @@ -1100,6 +1093,8 @@ export function deriveEffectiveComposerModelState(input: {
providers: ReadonlyArray<ServerProvider>;
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;
Expand Down Expand Up @@ -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<Record<string, ModelSelection>> | null;
readonly selectedModel: string;
readonly providers: ReadonlyArray<ServerProvider>;
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];
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
) {
return modelOptions;
}
return { ...modelOptions, [selectedInstanceId]: requestedOptions };
}

function revokeObjectPreviewUrl(previewUrl: string): void {
if (typeof URL === "undefined") {
return;
Expand Down
163 changes: 162 additions & 1 deletion apps/web/src/modelSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<ServerProvider> = [
{
...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<ServerProvider>;
readonly draft?: Parameters<typeof deriveEffectiveComposerModelState>[0]["draft"];
readonly threadModelSelection: ReturnType<typeof createModelSelection>;
}) {
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<ServerProvider> = [
{
...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();
});
});
Loading