Skip to content
Open
Show file tree
Hide file tree
Changes from 12 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
5 changes: 3 additions & 2 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ import { ThreadTitleInstructionsSettings } from "./ThreadTitleInstructionsSettin
import { SnoozePresetsRow } from "./SnoozePresetsSettings";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
import {
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
resolveAppModelSelectionState,
selectsPlanAgent,
} from "../../modelSelection";
Expand Down Expand Up @@ -1516,7 +1516,7 @@ export function ThreadsSettingsPanel() {
);
const textGenProvider: ProviderDriverKind =
textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
const textGenerationModelOptionsByInstance = getTextGenerationModelOptionsByInstance(
settings,
textGenerationProviders,
textGenInstanceId,
Expand All @@ -1529,6 +1529,7 @@ export function ThreadsSettingsPanel() {
const textGenerationModelDisabledReason = useScopedModelDisabledReason(
settings,
textGenerationModelInstanceEntries,
true,
);
const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]);
const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]);
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,13 @@
import { render } from "../../../test/render";
import { DEFAULT_UNIFIED_SETTINGS } from "@t3tools/contracts/settings";
import {
EnvironmentId,
ProviderDriverKind,
ProviderInstanceId,
type ServerProvider,
} from "@t3tools/contracts";
import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
import { deriveProviderInstanceEntries } from "../../providerInstances";
import { StrictMode, type ReactNode } from "react";
import { describe, expect, it, vi } from "vite-plus/test";
import { page, userEvent } from "vite-plus/test/browser";
Expand All @@ -10,6 +18,8 @@ import type { ScopedSettingsPatch } from "./scopedSettings";
type WritingStyle = typeof DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle;
const state = vi.hoisted(() => ({
styles: [] as WritingStyle[],
providerInstances: {} as typeof DEFAULT_UNIFIED_SETTINGS.providerInstances,
providers: new Map<EnvironmentId, readonly ServerProvider[]>(),
updateSettings: vi.fn<(patch: ScopedSettingsPatch) => void>(),
}));

Expand All @@ -34,15 +44,27 @@ vi.mock(import("./SettingsScopeContext"), async (original) => ({
scope: { kind: "all", environmentIds: [] },
environment: null,
connectedEnvironments: [],
targets: state.styles.map((style) => ({
settings: { ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: style },
targets: state.styles.map((style, index) => ({
environmentId: EnvironmentId.make(`environment-${index}`),
settings: {
...DEFAULT_UNIFIED_SETTINGS,
providerInstances: state.providerInstances,
sourceControlWritingStyle: style,
},
})),
target: null,
})) as never,
}));
vi.mock(import("../../state/environments"), async (original) => ({
...(await original()),
useEnvironmentProviders: () => new Map(),
useEnvironmentProviders: () => state.providers,
useEnvironments: (() => ({
environments: [...state.providers.keys()].map((environmentId) => ({
environmentId,
label: environmentId,
serverConfig: {},
})),
})) as never,
}));
vi.mock(import("./settingsLayout"), async (original) => ({
...(await original()),
Expand All @@ -69,6 +91,8 @@ const content = () => (
);

async function setup() {
state.providers = new Map();
state.providerInstances = {};
state.styles = [
{
...DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle,
Expand Down Expand Up @@ -112,3 +136,61 @@ describe("mixed source control instructions", () => {
).toEqual(initialInstructions);
});
});

it("rejects a scoped model when another environment requires extensions for it", async () => {
state.styles = [DEFAULT_UNIFIED_SETTINGS.sourceControlWritingStyle];
const instanceId = ProviderInstanceId.make("pi");
state.providerInstances = {
[instanceId]: { driver: ProviderDriverKind.make("pi"), enabled: true },
};
const provider: ServerProvider = {
instanceId,
driver: ProviderDriverKind.make("pi"),
enabled: true,
installed: true,
version: null,
status: "ready",
auth: { status: "authenticated" },
checkedAt: "2026-01-01T00:00:00.000Z",
slashCommands: [],
skills: [],
models: [{ slug: "demo/model", name: "Demo", isCustom: false, capabilities: {} }],
};
state.providers = new Map([
[
EnvironmentId.make("environment-0"),
[
{
...provider,
models: provider.models.map((model) => ({ ...model, supportsTextGeneration: false })),
},
],
],
]);
function Availability() {
const backgroundReason = useScopedModelDisabledReason(
{ ...DEFAULT_UNIFIED_SETTINGS, providerInstances: state.providerInstances },
deriveProviderInstanceEntries([provider]),
true,
);
const threadReason = useScopedModelDisabledReason(
{ ...DEFAULT_UNIFIED_SETTINGS, providerInstances: state.providerInstances },
deriveProviderInstanceEntries([provider]),
);
return (
<>
<p>{backgroundReason(instanceId, "demo/model") ?? "Available"}</p>
<p>{threadReason(instanceId, "demo/model") ?? "Available for threads"}</p>
</>
);
}
await using screen = await render(<Availability />);
await expect.element(screen.getByText("Available for threads")).toBeVisible();
await expect
.element(
screen.getByText(
"This model is unavailable on environment-0. Select that environment to choose its model separately.",
),
)
.toBeVisible();
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { useScopedModelDisabledReason } from "./useScopedModelAvailability";
import { useSettingsScope } from "./SettingsScopeContext";
import { providerInstanceEntriesFor } from "../../providerInstances";
import {
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
resolveAppModelSelectionState,
selectsPlanAgent,
} from "../../modelSelection";
Expand Down Expand Up @@ -139,13 +139,13 @@ export function SourceControlWritingSettingsSection() {
(entry) =>
entry.instanceId === defaultModelSelection.instanceId && entry.enabled && entry.isAvailable,
);
const modelOptionsByInstance = getCustomModelOptionsByInstance(
const modelOptionsByInstance = getTextGenerationModelOptionsByInstance(
settings,
projectEffectiveTextGenerationProviders,
activeSelection.instanceId,
activeSelection.model,
);
const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries);
const writerModelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries, true);
const writerInstanceEntry = instanceEntries.find(
(entry) => entry.instanceId === activeSelection.instanceId,
);
Expand All @@ -161,7 +161,7 @@ export function SourceControlWritingSettingsSection() {
},
textGenerationProviders,
);
const reviewGuideModelOptionsByInstance = getCustomModelOptionsByInstance(
const reviewGuideModelOptionsByInstance = getTextGenerationModelOptionsByInstance(
settings,
textGenerationProviders,
reviewGuideSelection.instanceId,
Expand Down
11 changes: 9 additions & 2 deletions apps/web/src/components/settings/useScopedModelAvailability.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,10 @@
import type { ProviderInstanceId, UnifiedSettings } from "@t3tools/contracts";

import { mergeEnvironmentSettings } from "../../hooks/useSettings";
import { getCustomModelOptionsByInstance } from "../../modelSelection";
import {
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
} from "../../modelSelection";
import {
applyProviderInstanceSettings,
deriveProviderInstanceEntries,
Expand All @@ -13,6 +16,7 @@ import { useSettingsScope } from "./SettingsScopeContext";
export function useScopedModelDisabledReason(
settings: UnifiedSettings,
entries: readonly ProviderInstanceEntry[],
textGeneration = false,
) {
const { targets } = useSettingsScope();
const { environments } = useEnvironments();
Expand All @@ -30,7 +34,10 @@ export function useScopedModelDisabledReason(
candidate.settings,
candidate.projectId,
).find((option) => option.instanceId === instanceId);
const options = getCustomModelOptionsByInstance(
const optionsForInstance = textGeneration
? getTextGenerationModelOptionsByInstance
: getCustomModelOptionsByInstance;
const options = optionsForInstance(
mergeEnvironmentSettings(candidate.settings, settings),
providers,
).get(instanceId);
Expand Down
76 changes: 76 additions & 0 deletions apps/web/src/modelSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import { getComposerProviderState } from "./components/chat/composerProviderStat
import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances";
import {
getAppModelOptionsForInstance,
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
resolveAppModelSelectionForInstance,
resolveAppModelSelectionState,
resolveEffectiveDefaultModelSelection,
Expand Down Expand Up @@ -670,6 +672,80 @@ describe("instance-scoped model selection", () => {
NO_PROVIDER_MODEL_SELECTION,
);
});

it("does not select a model that the provider cannot use for text generation", () => {
const codex = provider({ instanceId: "codex", models: ["gpt-5.6-sol", "extension-model"] });
const providers = [
{
...codex,
models: codex.models.map((m) =>
m.slug === "extension-model" ? { ...m, supportsTextGeneration: false } : m,
),
},
];
const settings: UnifiedSettings = {
...settingsWithProviderInstances(),
textGenerationModelSelection: createModelSelection(codex.instanceId, "extension-model"),
};

expect(resolveAppModelSelectionState(settings, providers)).toEqual(
createModelSelection(codex.instanceId, "gpt-5.6-sol"),
);
});

it("excludes customized extension models while preserving unknown custom and thread models", () => {
const pi = provider({
provider: ProviderDriverKind.make("pi"),
instanceId: "pi",
models: ["default", "demo/demo-model"],
});
const providers = [
{
...pi,
models: pi.models.map((model) =>
model.slug === "demo/demo-model" ? { ...model, supportsTextGeneration: false } : model,
),
},
];
const settings: UnifiedSettings = {
...DEFAULT_UNIFIED_SETTINGS,
providerInstances: {
[pi.instanceId]: {
driver: ProviderDriverKind.make("pi"),
config: {
customModels: [
{ slug: "demo/demo-model", name: "My extension model" },
{ slug: "unknown/model", name: "My custom model" },
],
},
},
},
textGenerationModelSelection: createModelSelection(pi.instanceId, "demo/demo-model"),
};
const supported = providers.filter((provider) => provider.supportsTextGeneration !== false);
expect(
getTextGenerationModelOptionsByInstance(settings, supported, pi.instanceId, "demo/demo-model")
.get(pi.instanceId)
?.map((option) => option.slug),
).toEqual(["default", "unknown/model"]);
expect(resolveAppModelSelectionState(settings, supported)).toEqual(
createModelSelection(pi.instanceId, "default"),
);
expect(
resolveAppModelSelectionState(
{
...settings,
textGenerationModelSelection: createModelSelection(pi.instanceId, "unknown/model"),
},
supported,
).model,
).toBe("unknown/model");
expect(
getCustomModelOptionsByInstance(settings, providers)
.get(pi.instanceId)
?.map((option) => option.slug),
).toEqual(["default", "demo/demo-model", "unknown/model"]);
});
});

describe("resolveAppModelSelectionState with the opencode plan agent", () => {
Expand Down
48 changes: 40 additions & 8 deletions apps/web/src/modelSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -328,6 +328,42 @@ export function getCustomModelOptionsByInstance(
return out;
}

function getTextGenerationModelOptionsForInstance(
settings: UnifiedSettings,
entry: ProviderInstanceEntry,
selectedModel?: string | null,
): AppModelOption[] {
const unsupported = new Set(
entry.models
.filter((model) => model.supportsTextGeneration === false)
.map((model) => model.slug),
);
// Filter after custom entries are rebuilt so a display-name override cannot restore a model.
return getAppModelOptionsForInstance(settings, entry, selectedModel).filter(
(option) => !unsupported.has(option.slug),
);
}

export function getTextGenerationModelOptionsByInstance(
settings: UnifiedSettings,
providers: ReadonlyArray<ServerProvider>,
selectedInstanceId?: ProviderInstanceId | null,
selectedModel?: string | null,
): ReadonlyMap<ProviderInstanceId, ReadonlyArray<ModelEsque>> {
return new Map(
deriveProviderInstanceEntries(
providers.filter((provider) => provider.supportsTextGeneration !== false),
).map((entry) => [
entry.instanceId,
getTextGenerationModelOptionsForInstance(
settings,
entry,
entry.instanceId === selectedInstanceId ? selectedModel : null,
),
]),
);
}

export function selectsPlanAgent(options: ModelSelection["options"]): boolean {
return options?.some((option) => option.id === "agent" && option.value === "plan") ?? false;
}
Expand All @@ -352,21 +388,17 @@ export function resolveAppModelSelectionState(
if (entry) {
const selectedModel = selectedEntry ? selection.model : null;
// Use the cheap generation default; the catalog default is the expensive chat flagship.
const options = getAppModelOptionsForInstance(settings, entry);
const options = getTextGenerationModelOptionsForInstance(settings, entry);
const model =
resolveSelectableModel(entry.driverKind, selectedModel, options) ??
resolveSelectableModel(
entry.driverKind,
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind],
options,
) ??
resolveAppModelSelectionForInstance(
entry.instanceId,
settings,
supportedProviders,
selectedModel,
) ??
entry.models[0]?.slug ??
options.find((option) => option.isDefault)?.slug ??
options[0]?.slug ??
entry.models.find((model) => model.supportsTextGeneration !== false)?.slug ??
DEFAULT_TEXT_GENERATION_MODEL_BY_PROVIDER[entry.driverKind];
if (!model) {
return createModelSelection(entry.instanceId, "", []);
Expand Down
3 changes: 3 additions & 0 deletions packages/contracts/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ export const ServerProviderModel = Schema.Struct({
isCustom: Schema.Boolean,
isDefault: Schema.optional(Schema.Boolean),
isLegacy: Schema.optional(Schema.Boolean),
// False when the provider can run this model in sessions but not for
// application text generation. Absent means supported.
supportsTextGeneration: Schema.optional(Schema.Boolean),
capabilities: Schema.NullOr(ModelCapabilities),
});
export type ServerProviderModel = typeof ServerProviderModel.Type;
Expand Down
Loading
Loading