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
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -101,6 +101,7 @@ lib/
global-settings-file.ts locked read-modify-write of global settings.json (SettingsManager's lock)
regular-file.ts readRegularFileText(): non-blocking read of a regular file only, optional size cap
default-preferences.ts write defaultModel/defaultThinkingLevel; detect project shadowing
deferred-provider-models.ts models of providers an extension registers only at session_start, re-added to listings
enabled-models.ts pure minimal-edit engine for the enabledModels pattern list
enabled-models-runtime.ts SDK adapter for enabledModels: pattern resolution, provider kinds, settings IO
subagent-settings.ts read/write ~/.pi/agent/agents/settings.json
Expand Down Expand Up @@ -193,7 +194,7 @@ Design decisions and traps live in `docs/agents/`, one note per area. Read every
- [mcp-settings.md](docs/agents/mcp-settings.md): Settings › MCP reads without running anything, masking, the trust dialog's server list, row states, notices, Code mode choice, trust from Settings, Escape stacking, every `mcp.json` write and undo. Files: `app/api/mcp/route.ts`, `app/api/project-trust/route.ts`, `lib/mcp-config-read.ts`, `lib/mcp-config-file.ts`, `lib/mcp-undo.ts`, `lib/mcp-secrets.ts`, `lib/mcp-server-display.ts`, `lib/mcp-json-error.ts`, `lib/project-trust.ts`, `lib/regular-file.ts`, `lib/stacked-dialog.ts`, `lib/settings-navigation.ts`, `components/McpConfig.tsx`, `components/mcp-config-helpers.ts`, `components/ProjectTrustDialog.tsx`, `components/SettingsPanel.tsx`.
- [mcp-test-sign-in.md](docs/agents/mcp-test-sign-in.md): Settings › MCP Test (route checks, bounded connection, `!command` queue, redaction, status store) and OAuth sign-in / sign-out. Files: `app/api/mcp/test/**`, `app/api/mcp/sign-in/**`, `lib/mcp-test.ts`, `lib/mcp-entry-request.ts`, `lib/mcp-status.ts`, `lib/mcp-sign-in.ts`, `lib/mcp-sign-out.ts`, `components/McpSignIn.tsx`, `components/mcp-sign-in-helpers.ts`, `components/OAuthPastePanel.tsx`.
- [mcp-add.md](docs/agents/mcp-add.md): Settings › MCP add (paste re-parsed on the server, host-variable confirmation, literal secrets kept global, fresh-folder trust, the add pane) and the paste importer's escaping and grammars. Files: `lib/mcp-add.ts`, `lib/mcp-import*.ts`, `lib/shell-words.ts`, fresh-folder trust in `lib/project-trust.ts`, `components/McpAddServer.tsx`, `components/mcp-add-helpers.ts`, the `add` action of `app/api/mcp/route.ts`.
- [models.md](docs/agents/models.md): default model and reasoning level, mid-run reasoning changes, remote provider catalogs, `enabledModels` scoping and minimal edits, provider auth listing and credentials. Files: `app/api/models/**`, `app/api/models-config/**`, `app/api/auth/**`, `lib/default-preferences.ts`, `lib/model-scope.ts`, `lib/enabled-models*.ts`, `lib/model-catalog-refresh.ts`, `lib/provider-listing*.ts`, `components/ModelsConfig.tsx`, `components/EnabledModelsSection.tsx`, `components/ModelSelector.tsx`, `components/SelectorRow.tsx`.
- [models.md](docs/agents/models.md): default model and reasoning level, providers registered at session_start, mid-run reasoning changes, remote provider catalogs, `enabledModels` scoping and minimal edits, provider auth listing and credentials. Files: `app/api/models/**`, `app/api/models-config/**`, `app/api/auth/**`, `lib/default-preferences.ts`, `lib/model-scope.ts`, `lib/enabled-models*.ts`, `lib/model-catalog-refresh.ts`, `lib/deferred-provider-models.ts`, `lib/provider-listing*.ts`, `components/ModelsConfig.tsx`, `components/EnabledModelsSection.tsx`, `components/ModelSelector.tsx`, `components/SelectorRow.tsx`.
- [files-and-access.md](docs/agents/files-and-access.md): worktrees and project grouping, the file access allow-list (the `/api/files` security boundary), file tree visibility, web password throttling. Files: `app/api/files/**`, `app/api/cwd/**`, `app/api/worktrees/**`, `app/api/file-index/**`, `app/api/web-auth/**`, `proxy.ts`, `lib/path-security.ts`, `lib/file-access.ts`, `lib/linked-directory.ts`, `lib/session-file-references*.ts`, `lib/file-tree-visibility.ts`, `lib/worktree.ts`, `lib/paths.ts`, `lib/auth-throttle.ts`, `components/FileExplorer.tsx`.
- [settings-ui.md](docs/agents/settings-ui.md): Plugins and Skills routes, sidebar group switches, the shared `SettingsUi` blocks every settings panel and add pane uses. Files: `app/api/plugins/**`, `app/api/skills/**`, `components/SettingsUi.tsx`, `components/settings-ui-helpers.ts`, `components/SkillsConfig.tsx`, `components/PluginsConfig.tsx`; also before adding a settings section or add pane.
- [subagents.md](docs/agents/subagents.md): the built-in subagent setting, profiles and their files, run status, completion notifications. Files: `lib/subagent*.ts`, `app/api/subagents/**`, `components/AgentsConfig.tsx`.
Expand Down
3 changes: 2 additions & 1 deletion app/api/models/default/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
} from "@/lib/default-preferences";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { resolveVisibleModels } from "@/lib/model-scope";
import { withDeferredProviderModels } from "@/lib/deferred-provider-models";
import { invalidateModelsCache } from "@/lib/models-cache";
import { projectTrustReloadOptions } from "@/lib/project-trust";

Expand Down Expand Up @@ -95,7 +96,7 @@ export async function PUT(req: Request) {
if (edit.model) {
// Only a model the selector can offer is a default that actually takes
// effect: startup falls back to the first scoped model otherwise.
const scope = await resolveVisibleModels(services.modelRuntime, settingsManager.getEnabledModels());
const scope = await resolveVisibleModels(withDeferredProviderModels(services.modelRuntime), settingsManager.getEnabledModels());
const { provider, modelId } = edit.model;
if (!scope.visible.some((model) => model.provider === provider && model.id === modelId)) {
return Response.json({ error: `Model not available: ${provider}/${modelId}` }, { status: 404 });
Expand Down
3 changes: 2 additions & 1 deletion app/api/models/enabled/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import {
} from "@/lib/enabled-models-runtime";
import type { EnabledModelsInput } from "@/lib/enabled-models";
import { createModelRuntimeWithExtensions } from "@/lib/model-runtime";
import { withDeferredProviderModels } from "@/lib/deferred-provider-models";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { invalidateModelsCache } from "@/lib/models-cache";

Expand All @@ -37,7 +38,7 @@ interface RequestContext {
}

async function loadContext(cwd: string): Promise<RequestContext> {
const modelRuntime = await createModelRuntimeWithExtensions();
const modelRuntime = withDeferredProviderModels(await createModelRuntimeWithExtensions());
const agentDir = getAgentDir();
return {
modelRuntime,
Expand Down
4 changes: 3 additions & 1 deletion app/api/models/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
import { resolveVisibleModels, selectInitialModelScope } from "@/lib/model-scope";
import { getAllowedFileRoots, isExistingFilePathAllowed } from "@/lib/file-access";
import { projectTrustReloadOptions } from "@/lib/project-trust";
import { rememberProviderModels, withDeferredProviderModels } from "@/lib/deferred-provider-models";

export const dynamic = "force-dynamic";

Expand Down Expand Up @@ -44,10 +45,11 @@ async function loadModels(cwd: string): Promise<ModelsData> {
});
const modelError = services.modelRuntime.getError();
const settings: SettingsManager = services.settingsManager;
await rememberProviderModels(services.modelRuntime);
// `enabledModels` supports globs and fuzzy patterns, so resolve it the same
// way the CLI does instead of comparing pattern strings literally (#307).
const scope = await resolveVisibleModels(
services.modelRuntime,
withDeferredProviderModels(services.modelRuntime),
settings.getEnabledModels(),
);
const { visible, thinkingLevelPins, warnings } = scope;
Expand Down
3 changes: 3 additions & 0 deletions docs/agents/models.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ The reasoning control stays usable while a run streams: pi-agent-core snapshots
## Remote provider catalogs
Built-in model lists are frozen at the pinned SDK version; newer models come from the SDK's pi.dev catalog overlay, which `ModelRuntime.refresh()` persists to `~/.pi/agent/models-store.json` (the pi CLI fills it too) and which restores offline. pi-web's own runtimes only restore it (`allowNetwork: false` / `refreshOnCreate: false`, listed in the header of `lib/model-catalog-refresh.ts`). That module's network pass runs **only when the user asks** (the "Refresh catalog" button in `EnabledModelsSection` → `/api/models/refresh`), never on a timer or on another request's path, which must not wait on a slow catalog. It calls `refresh()` with `force: true` and never `allowNetwork`, so pi's `PI_OFFLINE` rule holds (`reason: "offline"`), and `shareModelCatalogRefresh()` joins concurrent presses for the same providers. The route returns no model list: a change runs `invalidateModelsCache()` and reloads the panel, whose ordinary `/api/models` and `/api/models/enabled` loads build a fresh runtime that restores the store.

## Providers registered at `session_start`
Every model listing builds a fresh `ModelRuntime`, and none of them starts a session. Some extensions register their provider only in the first runtime of a process and defer later registrations to `session_start` (pi-claude-bridge, so a subagent sharing its parent's registry keeps the parent's stream function). Without help their models showed only on the first listing after startup, vanished on a folder switch, and a session could not start on them. `lib/deferred-provider-models.ts` remembers the available models of every runtime pi-web builds (listings, `createModelRuntimeWithExtensions()`, sessions once their extensions are bound) and adds back the models of providers a runtime **does not register at all**; a provider it registered but cannot use (signed out) is never added. `startRpcSession()` builds a session whose requested or restored model is such a deferred one on the default model, then switches to it with `set_model` once `session_start` has run. A project-only extension that defers its provider would also appear in other folders' listings; picking it there leaves the session on its default model.

## `enabledModels` scoping
`enabledModels` uses pi's `--models` syntax: minimatch globs against `provider/modelId` or a bare `modelId`, fuzzy matching for non-glob patterns, an optional `:thinkingLevel` suffix. Never compare patterns as strings: `lib/model-scope.ts` delegates to the SDK's `resolveModelScopeWithDiagnostics()` so pi-web and the TUI agree, and falls back to every available model when the patterns resolve to nothing. `startRpcSession()` resolves the scope before creating an AgentSession and passes the initial model, thinking pin and SDK-native `scopedModels` atomically; `GET /api/models` uses the helper only for selector data, `thinkingLevelPins` and `modelScopeWarnings`.

Expand Down
51 changes: 51 additions & 0 deletions lib/deferred-provider-models.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import assert from "node:assert/strict";
import test, { beforeEach } from "node:test";
import { createJiti } from "jiti";

const {
findDeferredModel,
rememberProviderModels,
withDeferredProviderModels,
} = await createJiti(import.meta.url).import("./deferred-provider-models.ts");

const glm = { provider: "zai", id: "glm-4.7" };
const haiku = { provider: "claude-bridge", id: "claude-haiku-4-5" };
const opus = { provider: "claude-bridge", id: "claude-opus-5-5" };

/** `registered` is what the runtime knows; `available` the subset with working auth. */
function runtime(registered, available = registered) {
return {
getModels: () => registered,
getAvailable: async (providerId) => available.filter((model) => !providerId || model.provider === providerId),
getModel: (provider, id) => registered.find((model) => model.provider === provider && model.id === id),
marker: "kept",
};
}

beforeEach(() => {
globalThis.__piWebProviderModelCatalog = undefined;
});

test("a runtime whose extension deferred its provider still lists the models seen earlier", async () => {
// The first runtime of the process got the bridge's registration; later ones defer it to session_start.
await rememberProviderModels(runtime([glm, haiku, opus]));
const later = withDeferredProviderModels(runtime([glm]));
assert.deepEqual(await later.getAvailable(), [glm, haiku, opus]);
assert.deepEqual(await later.getAvailable("claude-bridge"), [haiku, opus]);
assert.deepEqual(await later.getAvailable("zai"), [glm]);
assert.equal(later.marker, "kept");
});

test("a provider the runtime registered but cannot use is not added back", async () => {
await rememberProviderModels(runtime([glm, haiku]));
// Signed out of claude-bridge: registered, not available. The picker must not offer it.
const signedOut = withDeferredProviderModels(runtime([glm, haiku], [glm]));
assert.deepEqual(await signedOut.getAvailable(), [glm]);
});

test("a deferred model is found only while this runtime lacks its provider", async () => {
await rememberProviderModels(runtime([glm, haiku]));
assert.deepEqual(findDeferredModel(runtime([glm]), "claude-bridge", "claude-haiku-4-5"), haiku);
assert.equal(findDeferredModel(runtime([glm, haiku]), "claude-bridge", "claude-haiku-4-5"), undefined);
assert.equal(findDeferredModel(runtime([glm]), "claude-bridge", "unknown"), undefined);
});
66 changes: 66 additions & 0 deletions lib/deferred-provider-models.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import type { ModelRuntime } from "@earendil-works/pi-coding-agent";
import { invalidateModelsCache } from "./models-cache";

type RuntimeModel = Awaited<ReturnType<ModelRuntime["getAvailable"]>>[number];

/**
* Models of every provider seen in any ModelRuntime of this process, by provider.
*
* pi-web builds a fresh runtime for each model listing, and some extensions register their
* provider only in the first runtime of a process, deferring later ones to `session_start`
* (pi-claude-bridge does, so a subagent sharing its parent's registry does not overwrite the
* parent's stream function). A listing runtime never starts a session, so after the first one
* those providers vanished from the model picker, and a session could not start on them.
*/
declare global {
var __piWebProviderModelCatalog: Map<string, RuntimeModel[]> | undefined;
}

function catalog(): Map<string, RuntimeModel[]> {
globalThis.__piWebProviderModelCatalog ??= new Map();
return globalThis.__piWebProviderModelCatalog;
}

/** Record what a runtime offers, so later runtimes can show a provider they have not registered yet. */
export async function rememberProviderModels(runtime: ModelRuntime): Promise<void> {
const byProvider = new Map<string, RuntimeModel[]>();
for (const model of await runtime.getAvailable()) {
byProvider.set(model.provider, [...(byProvider.get(model.provider) ?? []), model]);
}
const added = [...byProvider.keys()].some((provider) => !catalog().has(provider));
for (const [provider, models] of byProvider) catalog().set(provider, models);
// A listing cached before this provider was known would hide it for the cache lifetime.
if (added) invalidateModelsCache();
}

/**
* Remembered models of providers this runtime does not know at all. A provider the runtime
* registered but cannot use (signed out, no key) is never added back: only a registration that
* has not happened yet in this runtime is filled in.
*/
export function deferredProviderModels(runtime: ModelRuntime): RuntimeModel[] {
const registered = new Set(runtime.getModels().map((model) => model.provider));
return [...catalog()].flatMap(([provider, models]) => (registered.has(provider) ? [] : models));
}

/** The runtime as a model listing should see it: its own available models plus deferred ones. */
export function withDeferredProviderModels(runtime: ModelRuntime): ModelRuntime {
return new Proxy(runtime, {
get(target, property, receiver) {
if (property === "getAvailable") {
return async (...args: Parameters<ModelRuntime["getAvailable"]>) => {
const [providerId] = args;
const deferred = deferredProviderModels(target).filter((model) => !providerId || model.provider === providerId);
return [...await target.getAvailable(...args), ...deferred];
};
}
const value = Reflect.get(target, property, receiver);
return typeof value === "function" ? value.bind(target) : value;
},
});
}

/** A remembered model whose provider this runtime has not registered yet. */
export function findDeferredModel(runtime: ModelRuntime, provider: string, modelId: string): RuntimeModel | undefined {
return deferredProviderModels(runtime).find((model) => model.provider === provider && model.id === modelId);
}
2 changes: 2 additions & 0 deletions lib/model-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
getAgentDir,
type ModelRuntime,
} from "@earendil-works/pi-coding-agent";
import { rememberProviderModels } from "./deferred-provider-models";

/**
* ModelRuntime that also includes providers registered by extensions (an
Expand All @@ -18,5 +19,6 @@ import {
export async function createModelRuntimeWithExtensions(): Promise<ModelRuntime> {
const agentDir = getAgentDir();
const services = await createAgentSessionServices({ cwd: agentDir, agentDir });
await rememberProviderModels(services.modelRuntime);
return services.modelRuntime;
}
Loading