Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
b8d7636
fix(pi): stop offering extension-only models for text generation
only21mil Oct 22, 2026
81be30b
Merge upstream main into r13/b124-stop-offering-extension-only-models…
only21mil Oct 22, 2026
1174f26
fix(pi): preserve discovery and exclude customized extension models
only21mil Oct 22, 2026
eda6e35
test(pi): synchronize discovery timeout with the pending request
only21mil Oct 22, 2026
6c9d66f
test(pi): advance optional and normal discovery deadlines separately
only21mil Oct 22, 2026
09631f3
fix(pi): fall back to a usable catalog model when all options are hidden
only21mil Oct 23, 2026
6962144
merge: bring main into pi model filtering
maria-rcks Oct 26, 2026
9c79d17
refactor(pi): keep model filtering scoped and tighten timeout coverage
maria-rcks Oct 26, 2026
356cd0b
fix(web): validate text models across selected environments
maria-rcks Oct 26, 2026
1888526
merge: bring main into pi text model filtering
maria-rcks Oct 27, 2026
e292e3f
merge: bring main into pi text model filtering
maria-rcks Oct 30, 2026
0bab335
fix(web): preserve extension models for thread defaults
maria-rcks Oct 30, 2026
12a5a9e
fix(web): guard scoped writer model enablement
maria-rcks Oct 30, 2026
7c1915f
merge: bring main into pi text model filtering
maria-rcks Nov 3, 2026
ff4c1fd
Merge branch 'main' into r13/b124-stop-offering-extension-only-models…
only21mil Nov 12, 2026
f68ddf0
fix(pi): Pi drops out of text generation when it cannot start without…
only21mil Nov 10, 2026
13069a5
fix(server): text generation skips a saved provider that cannot gener…
only21mil Nov 10, 2026
6a61ed2
Merge remote-tracking branch 'origin/main' into r13/b124-stop-offerin…
juliusmarminge Nov 12, 2026
cd85b8c
test: keep the Pi text-generation cases that catch regressions
juliusmarminge Nov 12, 2026
fab45e1
fix(server): text generation skips a saved model the provider cannot run
juliusmarminge Nov 12, 2026
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
18 changes: 18 additions & 0 deletions apps/server/src/textGeneration/CodexTextGeneration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -546,6 +546,8 @@ const PREFLIGHT_PRIMARY_STATES = [
"not installed, fallback disabled for project",
"signed out",
"signed out, fallback failed",
"cannot generate text",
"model cannot generate text",
] as const;

it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (primaryState) =>
Expand Down Expand Up @@ -590,6 +592,22 @@ it.effect.each(PREFLIGHT_PRIMARY_STATES)("text generation preflight: %s", (prima
...(primaryState === "signed out, fallback failed" && instanceId === fallback
? { status: "error", auth: { status: "unknown" } }
: {}),
...(primaryState === "cannot generate text" && instanceId === primary
? { supportsTextGeneration: false }
: {}),
...(primaryState === "model cannot generate text" && instanceId === primary
? {
models: [
{
slug: selection.model,
name: selection.model,
isCustom: false,
supportsTextGeneration: false,
capabilities: null,
},
],
}
: {}),
...(primaryState === "exhausted" && instanceId === primary
? {
usageLimits: {
Expand Down
7 changes: 5 additions & 2 deletions apps/server/src/textGeneration/TextGeneration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,11 @@ export const make = Effect.gen(function* () {
const unusable =
textGeneratorCannotRun ||
(selected?.enabled === true &&
selected.status === "error" &&
(!selected.installed || selected.auth.status === "unauthenticated"));
(!isProviderTextGenerationCapable(selected) ||
selected.models.find((model) => model.slug === selection.model)
?.supportsTextGeneration === false ||
(selected.status === "error" &&
(!selected.installed || selected.auth.status === "unauthenticated"))));
if (unusable || exhaustedUsageWindow(selected, now, selection.model)) {
const settings = yield* serverSettings.getSettings.pipe(
Effect.mapError(
Expand Down
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 @@ -67,7 +67,7 @@ import { SnoozePresetsRow } from "./SnoozePresetsSettings";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
import { managedTelemetryOffMessage, usePrimaryManagedPolicy } from "../../state/managedPolicy";
import {
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
resolveAppModelSelectionState,
selectsPlanAgent,
} from "../../modelSelection";
Expand Down Expand Up @@ -1743,7 +1743,7 @@ export function ThreadsSettingsPanel() {
);
const textGenProvider: ProviderDriverKind =
textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
const textGenerationModelOptionsByInstance = getTextGenerationModelOptionsByInstance(
settings,
textGenerationProviders,
textGenInstanceId,
Expand All @@ -1756,6 +1756,7 @@ export function ThreadsSettingsPanel() {
const textGenerationModelDisabledReason = useScopedModelDisabledReason(
settings,
textGenerationModelInstanceEntries,
true,
);
const mixedAddProjectBaseDirectory = useScopedSettingsMixed(["addProjectBaseDirectory"]);
const mixedTextGenerationModel = useScopedSettingsMixed(["textGenerationModelSelection"]);
Expand Down
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 @@ -23,7 +23,7 @@ import { useSettingsScope } from "./SettingsScopeContext";
import { providerInstanceEntriesFor } from "../../providerInstances";
import { useTextGenerationTargets } from "../../textGenerators";
import {
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
resolveAppModelSelectionState,
selectsPlanAgent,
} from "../../modelSelection";
Expand Down Expand Up @@ -151,13 +151,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, writerEntries);
const writerModelDisabledReason = useScopedModelDisabledReason(settings, writerEntries, true);
const writerInstanceEntry = writerEntries.find(
(entry) => entry.instanceId === activeSelection.instanceId,
);
Expand All @@ -179,7 +179,7 @@ export function SourceControlWritingSettingsSection() {
},
projectAgentProviders,
);
const reviewGuideModelOptionsByInstance = getCustomModelOptionsByInstance(
const reviewGuideModelOptionsByInstance = getTextGenerationModelOptionsByInstance(
settings,
projectAgentProviders,
reviewGuideSelection.instanceId,
Expand Down Expand Up @@ -595,7 +595,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 @@ -604,8 +618,8 @@ export function SourceControlWritingSettingsSection() {
defaultModelSelection.options,
)
: null,
})
}
});
}}
aria-label="Use a separate source control writer model"
/>
</div>
Expand Down
4 changes: 2 additions & 2 deletions apps/web/src/components/settings/TextGeneratorsSettings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ import {
textGeneratorBrand,
textGeneratorDisplayName,
} from "../../textGenerators";
import { getCustomModelOptionsByInstance } from "../../modelSelection";
import { getTextGenerationModelOptionsByInstance } from "../../modelSelection";
import { ProviderInstanceIcon } from "../chat/ProviderInstanceIcon";
import { ProviderModelPicker } from "../chat/ProviderModelPicker";
import {
Expand Down Expand Up @@ -563,7 +563,7 @@ function TextGenerationTaskPicker({
model={selection.model}
lockedProvider={null}
instanceEntries={targets.entries}
modelOptionsByInstance={getCustomModelOptionsByInstance(
modelOptionsByInstance={getTextGenerationModelOptionsByInstance(
settings,
targets.providers,
selection.instanceId,
Expand Down
6 changes: 3 additions & 3 deletions apps/web/src/components/settings/VoiceSettingsPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import {
import { updateClientSettings, useClientSettings } from "../../hooks/useSettings";
import { runEnvironmentSpeech } from "../../speech/runEnvironmentSpeech";
import {
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
resolveAppModelSelectionState,
} from "../../modelSelection";
import { useTextGenerationTargets } from "../../textGenerators";
Expand Down Expand Up @@ -531,14 +531,14 @@ function CleanupModelRow() {
},
textGenerationProviders,
);
const modelOptionsByInstance = getCustomModelOptionsByInstance(
const modelOptionsByInstance = getTextGenerationModelOptionsByInstance(
settings,
textGenerationProviders,
selection.instanceId,
selection.model,
);
const instanceEntry = instanceEntries.find((entry) => entry.instanceId === selection.instanceId);
const modelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries);
const modelDisabledReason = useScopedModelDisabledReason(settings, instanceEntries, true);
const setModel = (next: ReturnType<typeof createModelSelection>) => {
const reason = modelDisabledReason(next.instanceId, next.model);
if (reason) {
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
Expand Up @@ -5,7 +5,10 @@ import {
} from "@t3tools/contracts";

import { mergeEnvironmentSettings } from "../../hooks/useSettings";
import { getCustomModelOptionsByInstance } from "../../modelSelection";
import {
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
} from "../../modelSelection";
import {
applyProviderInstanceSettings,
deriveProviderInstanceEntries,
Expand All @@ -17,6 +20,7 @@ import { useSettingsScope } from "./SettingsScopeContext";
export function useScopedModelDisabledReason(
settings: UnifiedSettings,
entries: readonly ProviderInstanceEntry[],
textGeneration = false,
) {
const { targets } = useSettingsScope();
const { environments } = useEnvironments();
Expand Down Expand Up @@ -48,7 +52,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
56 changes: 56 additions & 0 deletions apps/web/src/modelSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ import {
import {
defaultModelSelectionsAreMixed,
getAppModelOptionsForInstance,
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
resolveAppModelSelectionForInstance,
resolveAppModelSelectionState,
resolveEffectiveDefaultModelSelection,
Expand Down Expand Up @@ -917,6 +919,60 @@ describe("instance-scoped model selection", () => {
NO_PROVIDER_MODEL_SELECTION,
);
});

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 @@ -366,6 +366,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 @@ -390,21 +426,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 @@ -83,6 +83,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