Skip to content
Open
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
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 @@ -22,6 +32,7 @@ vi.mock(import("./useScopedSettings"), async (original) => ({
useScopedSettings: (() => ({
...DEFAULT_UNIFIED_SETTINGS,
sourceControlWritingStyle: state.styles[0],
providerInstances: state.providerInstances,
})) as never,
useScopedSettingsMixed: (keys) =>
!keys.includes("reviewGuidesEnabled") &&
Expand All @@ -32,17 +43,33 @@ vi.mock(import("./SettingsScopeContext"), async (original) => ({
...(await original()),
useSettingsScope: (() => ({
scope: { kind: "all", environmentIds: [] },
environment: null,
connectedEnvironments: [],
targets: state.styles.map((style) => ({
settings: { ...DEFAULT_UNIFIED_SETTINGS, sourceControlWritingStyle: style },
environment: { environmentId: EnvironmentId.make("representative"), serverConfig: {} },
connectedEnvironments: [...state.providers.keys()].map((environmentId) => ({
environmentId,
serverConfig: {},
})),
representative: { environmentId: EnvironmentId.make("representative") },
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 +96,8 @@ const content = () => (
);

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

it("rejects a scoped model when another environment requires extensions for it", async () => {
state.updateSettings.mockReset();
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("representative"), [provider]],
[
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>
</>
);
}
const availability = () => (
<>
<Availability />
<SourceControlWritingSettingsSection />
</>
);
await using screen = await render(availability());
await userEvent.click(screen.getByRole("button", { name: "Advanced" }));
await userEvent.click(
screen.getByRole("switch", { name: "Use a separate source control writer model" }),
);
expect(state.updateSettings).not.toHaveBeenCalled();
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();
state.providers = new Map([
[EnvironmentId.make("representative"), [provider]],
[EnvironmentId.make("environment-0"), [provider]],
]);
await screen.rerender(availability());
await userEvent.click(
screen.getByRole("switch", { name: "Use a separate source control writer model" }),
);
expect(state.updateSettings).toHaveBeenCalledWith({
sourceControlWriterModelSelection: { instanceId, model: "demo/model" },
});
});
28 changes: 21 additions & 7 deletions apps/web/src/components/settings/SourceControlWritingSettings.tsx
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 Expand Up @@ -577,7 +577,21 @@ export function SourceControlWritingSettingsSection() {
<Switch
checked={usesDedicatedModel}
disabled={!usesDedicatedModel && !canEnableDedicatedModel}
onCheckedChange={(checked) =>
onCheckedChange={(checked) => {
if (checked) {
const reason = writerModelDisabledReason(
defaultModelSelection.instanceId,
defaultModelSelection.model,
);
if (reason) {
toastManager.add({
type: "error",
title: "Writer model not saved",
description: reason,
});
return;
}
}
updateSettings({
sourceControlWriterModelSelection: checked
? createModelSelection(
Expand All @@ -586,8 +600,8 @@ export function SourceControlWritingSettingsSection() {
defaultModelSelection.options,
)
: null,
})
}
});
}}
aria-label="Use a separate source control writer model"
/>
</div>
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
Loading
Loading