Skip to content
Open
Show file tree
Hide file tree
Changes from 6 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
9 changes: 4 additions & 5 deletions apps/web/src/components/settings/SettingsPanels.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ import { useSettingsScope } from "./SettingsScopeContext";
import { ProjectDefaultsSettings } from "./ProjectDefaultsSettings";
import { useDesktopUpdateState } from "../../state/desktopUpdate";
import {
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
getTextGenerationProviders,
resolveAppModelSelectionState,
selectsPlanAgent,
} from "../../modelSelection";
Expand Down Expand Up @@ -812,9 +813,7 @@ export function GeneralSettingsPanel() {
(target) => target.serverConfig?.environment.capabilities.threadRestartContinuation === true,
);

const textGenerationProviders = serverProviders.filter(
(provider) => provider.supportsTextGeneration !== false,
);
const textGenerationProviders = getTextGenerationProviders(serverProviders);
const textGenerationModelSelection = resolveAppModelSelectionState(
settings,
textGenerationProviders,
Expand All @@ -837,7 +836,7 @@ export function GeneralSettingsPanel() {
);
const textGenProvider: ProviderDriverKind =
textGenInstanceEntry?.driverKind ?? DEFAULT_DRIVER_KIND;
const textGenerationModelOptionsByInstance = getCustomModelOptionsByInstance(
const textGenerationModelOptionsByInstance = getTextGenerationModelOptionsByInstance(
settings,
textGenerationProviders,
textGenInstanceId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@ import {
sortProviderInstanceEntries,
} from "../../providerInstances";
import {
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
getTextGenerationProviders,
resolveAppModelSelectionState,
selectsPlanAgent,
} from "../../modelSelection";
Expand Down Expand Up @@ -107,9 +108,7 @@ export function SourceControlWritingSettingsSection() {
style.mode !== defaults.mode ||
style.customInstructions !== defaults.customInstructions;

const textGenerationProviders = serverProviders.filter(
(provider) => provider.supportsTextGeneration !== false,
);
const textGenerationProviders = getTextGenerationProviders(serverProviders);
const instanceEntries = sortProviderInstanceEntries(
applyProviderInstanceSettings(
deriveProviderInstanceEntries(textGenerationProviders),
Expand Down Expand Up @@ -145,7 +144,7 @@ export function SourceControlWritingSettingsSection() {
(entry) =>
entry.instanceId === defaultModelSelection.instanceId && entry.enabled && entry.isAvailable,
);
const modelOptionsByInstance = getCustomModelOptionsByInstance(
const modelOptionsByInstance = getTextGenerationModelOptionsByInstance(
settings,
projectEffectiveTextGenerationProviders,
activeSelection.instanceId,
Expand All @@ -167,7 +166,7 @@ export function SourceControlWritingSettingsSection() {
},
textGenerationProviders,
);
const reviewGuideModelOptionsByInstance = getCustomModelOptionsByInstance(
const reviewGuideModelOptionsByInstance = getTextGenerationModelOptionsByInstance(
settings,
textGenerationProviders,
reviewGuideSelection.instanceId,
Expand Down
77 changes: 77 additions & 0 deletions apps/web/src/modelSelection.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ import { getComposerProviderState } from "./components/chat/composerProviderStat
import { deriveProviderInstanceEntries, NO_PROVIDER_MODEL_SELECTION } from "./providerInstances";
import {
getAppModelOptionsForInstance,
getCustomModelOptionsByInstance,
getTextGenerationModelOptionsByInstance,
getTextGenerationProviders,
resolveAppModelSelectionForInstance,
resolveAppModelSelectionState,
resolveEffectiveDefaultModelSelection,
Expand Down Expand Up @@ -670,6 +673,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 = getTextGenerationProviders(providers);
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
57 changes: 46 additions & 11 deletions apps/web/src/modelSelection.ts
Original file line number Diff line number Diff line change
Expand Up @@ -317,6 +317,47 @@ export function getCustomModelOptionsByInstance(
return out;
}

/** An absent `supportsTextGeneration` means supported. */
export function getTextGenerationProviders(
providers: ReadonlyArray<ServerProvider>,
): ReadonlyArray<ServerProvider> {
return providers.filter((provider) => provider.supportsTextGeneration !== false);
}

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(getTextGenerationProviders(providers)).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 @@ -329,9 +370,7 @@ export function resolveAppModelSelectionState(
instanceId: DEFAULT_TEXT_GENERATION_INSTANCE_ID,
model: DEFAULT_TEXT_GENERATION_MODEL,
};
const supportedProviders = providers.filter(
(provider) => provider.supportsTextGeneration !== false,
);
const supportedProviders = getTextGenerationProviders(providers);
const entries = deriveProviderInstanceEntries(supportedProviders);
const selectedEntry = entries.find(
(entry) => entry.instanceId === selection.instanceId && entry.enabled && entry.isAvailable,
Expand All @@ -343,21 +382,17 @@ export function resolveAppModelSelectionState(
// A saved model the catalog no longer lists, or a new fallback instance,
// gets the driver's cheap text generation default. The catalog's own
// default is the flagship chat model, which is too expensive here.
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 @@ -81,6 +81,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
103 changes: 89 additions & 14 deletions packages/provider-pi/src/server/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import * as NodeServices from "@effect/platform-node/NodeServices";
import { assert, describe, it } from "@effect/vitest";
import * as Cause from "effect/Cause";
import * as Effect from "effect/Effect";
import * as Deferred from "effect/Deferred";
import * as Fiber from "effect/Fiber";
import * as TestClock from "effect/testing/TestClock";
import * as Queue from "effect/Queue";
import * as Schema from "effect/Schema";
import * as Sink from "effect/Sink";
Expand Down Expand Up @@ -52,7 +55,7 @@ function piProbeSpawner(version: string) {
});
}

const piDiscoverySpawner = (inventory: unknown) =>
const piRpcProcess = (inventory: unknown, onModelRequest: Effect.Effect<void> = Effect.void) =>
Effect.gen(function* () {
const stdout = yield* Queue.unbounded<Uint8Array, Cause.Done>();
let stdinBuffer = "";
Expand All @@ -66,6 +69,10 @@ const piDiscoverySpawner = (inventory: unknown) =>
stdinBuffer = stdinBuffer.slice(newline + 1);
if (line.length === 0) continue;
const request = decodeRpcRequest(line);
if (request.type === "get_available_models") {
yield* onModelRequest;
if (inventory === null) continue;
}
const data =
request.type === "get_available_models"
? inventory
Expand All @@ -81,22 +88,33 @@ const piDiscoverySpawner = (inventory: unknown) =>
}
}),
);
return ChildProcessSpawner.make((command) => {
const args = ChildProcess.isStandardCommand(command) ? command.args : [];
return Effect.succeed(
args.includes("--version")
? processHandle({ stdout: "pi 0.84.3\n" })
: ChildProcessSpawner.makeHandle({
...processHandle({}),
exitCode: Effect.never,
isRunning: Effect.succeed(true),
stdin,
stdout: Stream.fromQueue(stdout),
}),
);
return ChildProcessSpawner.makeHandle({
...processHandle({}),
exitCode: Effect.never,
isRunning: Effect.succeed(true),
stdin,
stdout: Stream.fromQueue(stdout),
});
});

// Text generation lists models with --no-extensions, so its inventory can differ.
const piDiscoverySpawner = (
inventory: unknown,
extensionFreeInventory: unknown = inventory,
onExtensionFreeModelRequest: Effect.Effect<void> = Effect.void,
) =>
Effect.succeed(
ChildProcessSpawner.make((command) => {
const args = ChildProcess.isStandardCommand(command) ? command.args : [];
return args.includes("--version")
? Effect.succeed(processHandle({ stdout: "pi 0.84.3\n" }))
: piRpcProcess(
args.includes("--no-extensions") ? extensionFreeInventory : inventory,
args.includes("--no-extensions") ? onExtensionFreeModelRequest : Effect.void,
);
}),
);

const settings = {
enabled: true,
binaryPath: "pi",
Expand Down Expand Up @@ -146,6 +164,63 @@ describe("PiProvider", () => {
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("marks models that only load with Pi extensions as unusable for text generation", () =>
Effect.gen(function* () {
const spawner = yield* piDiscoverySpawner(
{
models: [
{ provider: "anthropic", id: "claude-sonnet-4" },
{ provider: "demo", id: "demo-model" },
],
},
{ models: [{ provider: "anthropic", id: "claude-sonnet-4" }] },
);
const snapshot = yield* checkPiProviderStatus(settings).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
);
assert.deepEqual(
snapshot.models.map((model) => [model.slug, model.supportsTextGeneration]),
[
["default", undefined],
["anthropic/claude-sonnet-4", undefined],
["demo/demo-model", false],
],
);
}).pipe(Effect.provide(NodeServices.layer)),
);

it.effect("preserves normal discovery when the extension-free model listing times out", () =>
Effect.gen(function* () {
const modelRequested = yield* Deferred.make<void>();
const spawner = yield* piDiscoverySpawner(
{ models: [{ provider: "demo", id: "demo-model" }] },
null,
Deferred.succeed(modelRequested, undefined).pipe(Effect.asVoid),
);
const fiber = yield* checkPiProviderStatus(settings).pipe(
Effect.provideService(ChildProcessSpawner.ChildProcessSpawner, spawner),
Effect.forkChild,
);
yield* Deferred.await(modelRequested);
yield* TestClock.adjust("5 seconds");
yield* TestClock.adjust("25 seconds");
const snapshot = yield* Fiber.join(fiber);
assert.equal(snapshot.status, "ready");
assert.equal(snapshot.auth.status, "authenticated");
assert.deepEqual(
snapshot.models.map((model) => [model.slug, model.supportsTextGeneration]),
[
["default", undefined],
["demo/demo-model", undefined],
],
);
assert.include(
snapshot.slashCommands.map((command) => command.name),
"compact",
);
}).pipe(Effect.provide(NodeServices.layer)),
);

// regression: #18498 a malformed model inventory must not restore stale Pi models
it.effect.each([
["missing models", {}],
Expand Down
Loading
Loading