@fastagent-sh/fastagent 0.23.0 → 0.24.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/dist/agent.d.ts +10 -0
  2. package/dist/channels/kit/preview-kit.d.ts +6 -1
  3. package/dist/channels/kit/preview-kit.js +21 -4
  4. package/dist/channels/slack/preview.js +2 -1
  5. package/dist/cli/commands/info.js +7 -7
  6. package/dist/cli/commands/login.js +2 -2
  7. package/dist/cli/commands/models.js +3 -3
  8. package/dist/cli/invoke-stream.js +14 -0
  9. package/dist/cli/shared.d.ts +2 -1
  10. package/dist/cli/shared.js +6 -7
  11. package/dist/deploy/preflight.js +19 -1
  12. package/dist/dev-supervisor.js +2 -2
  13. package/dist/engines/pi/agent-models.d.ts +14 -1
  14. package/dist/engines/pi/agent-models.js +35 -8
  15. package/dist/engines/pi/agent-session-factory.d.ts +13 -13
  16. package/dist/engines/pi/agent-session-factory.js +137 -159
  17. package/dist/engines/pi/auth.js +6 -94
  18. package/dist/engines/pi/create.d.ts +16 -9
  19. package/dist/engines/pi/create.js +71 -38
  20. package/dist/engines/pi/invoke-session.js +82 -16
  21. package/dist/engines/pi/locked-file.d.ts +9 -0
  22. package/dist/engines/pi/locked-file.js +102 -0
  23. package/dist/engines/pi/login.d.ts +7 -1
  24. package/dist/engines/pi/login.js +22 -6
  25. package/dist/engines/pi/machine.d.ts +23 -0
  26. package/dist/engines/pi/machine.js +24 -2
  27. package/dist/engines/pi/models.d.ts +47 -42
  28. package/dist/engines/pi/models.js +154 -67
  29. package/dist/engines/pi/open.d.ts +29 -12
  30. package/dist/engines/pi/open.js +52 -21
  31. package/dist/engines/pi/openai-account-models.d.ts +35 -0
  32. package/dist/engines/pi/openai-account-models.js +116 -0
  33. package/dist/engines/pi/report.d.ts +3 -0
  34. package/dist/engines/pi/report.js +11 -0
  35. package/dist/engines/pi/session-builder.js +6 -22
  36. package/dist/engines/pi/session-control.d.ts +4 -4
  37. package/dist/engines/pi/session-control.js +72 -40
  38. package/dist/engines/pi/session-settings.d.ts +9 -0
  39. package/dist/engines/pi/session-settings.js +17 -0
  40. package/dist/engines/pi/session-store.d.ts +2 -2
  41. package/dist/engines/pi/tool-context.d.ts +3 -5
  42. package/dist/engines/pi/tool-context.js +4 -16
  43. package/dist/engines/pi/tool.d.ts +32 -21
  44. package/dist/engines/pi/tool.js +21 -25
  45. package/dist/engines/pi/turn-kit.d.ts +18 -9
  46. package/dist/engines/pi/turn-kit.js +104 -27
  47. package/dist/pi.d.ts +1 -1
  48. package/dist/pi.js +1 -1
  49. package/dist/session.d.ts +60 -8
  50. package/dist/session.js +5 -1
  51. package/package.json +5 -5
  52. package/dist/engines/pi/search-tools.d.ts +0 -4
  53. package/dist/engines/pi/search-tools.js +0 -109
@@ -13,10 +13,33 @@ import { DefaultResourceLoader, SettingsManager } from "@earendil-works/pi-codin
13
13
  /** pi's own shapes, taken from its loader rather than re-declared. */
14
14
  export type MachineSkill = ReturnType<DefaultResourceLoader["getSkills"]>["skills"][number];
15
15
  type MachinePrompt = ReturnType<DefaultResourceLoader["getPrompts"]>["prompts"][number];
16
+ /** The Pi built-in extensions a definition loads, each unless the machine's settings disable it. */
17
+ export declare const BUILTIN_EXTENSIONS: readonly ["codemode", "tool-search"];
18
+ /**
19
+ * The way in for an authored tool the model is not given up front, by its exposure: the built-in extension that
20
+ * provides it and the tool that extension registers. A `codemode` tool is called from codemode scripts, a `deferred`
21
+ * one is loaded by tool_search — the rule Pi's MCP extension applies to its own tools (`ensureDiscoveryActive`).
22
+ * Without that extension the tool has no way in at all.
23
+ */
24
+ export declare const DISCOVERY: {
25
+ readonly codemode: {
26
+ readonly extension: "codemode";
27
+ readonly tool: "codemode";
28
+ };
29
+ readonly deferred: {
30
+ readonly extension: "tool-search";
31
+ readonly tool: "tool_search";
32
+ };
33
+ };
16
34
  /** What this box lends an agent. */
17
35
  export interface Machine {
18
36
  skills: MachineSkill[];
19
37
  prompts: MachinePrompt[];
38
+ /**
39
+ * The {@link BUILTIN_EXTENSIONS} left enabled, by Pi's own rule: `"extensions": ["-builtin:codemode"]` in the user
40
+ * settings disables one, and the workspace's `.pi/settings.json` overrides that either way.
41
+ */
42
+ builtinExtensions: string[];
20
43
  /** pi's engine settings (retry, compaction, cache warming, …) as read at boot — a fresh manager per caller. */
21
44
  settingsManager(): SettingsManager;
22
45
  }
@@ -11,6 +11,20 @@
11
11
  */
12
12
  import { DefaultPackageManager, DefaultResourceLoader, SettingsManager, getAgentDir, } from "@earendil-works/pi-coding-agent";
13
13
  import { log } from "../../log.js";
14
+ /** The Pi built-in extensions a definition loads, each unless the machine's settings disable it. */
15
+ // Not `mcp`: its server connections live as long as a session, and a served session lives one turn, so every turn
16
+ // would start every configured server.
17
+ export const BUILTIN_EXTENSIONS = ["codemode", "tool-search"];
18
+ /**
19
+ * The way in for an authored tool the model is not given up front, by its exposure: the built-in extension that
20
+ * provides it and the tool that extension registers. A `codemode` tool is called from codemode scripts, a `deferred`
21
+ * one is loaded by tool_search — the rule Pi's MCP extension applies to its own tools (`ensureDiscoveryActive`).
22
+ * Without that extension the tool has no way in at all.
23
+ */
24
+ export const DISCOVERY = {
25
+ codemode: { extension: "codemode", tool: "codemode" },
26
+ deferred: { extension: "tool-search", tool: "tool_search" },
27
+ };
14
28
  /** Keyed by the two places it reads: the workspace (project scope) and pi's own directory (user scope). */
15
29
  const reads = new Map();
16
30
  /** This process's one read of the machine, shared by the run plane and `commands()` so the two cannot disagree. */
@@ -37,7 +51,12 @@ async function read(workspace, agentDir) {
37
51
  // (`npm install`, `git clone`), throwing out of `reload()` when that fails — measured. So fastagent resolves them
38
52
  // here, telling pi to skip what is absent, and hands the loader a packageless copy of the settings so its own
39
53
  // resolve has nothing to do.
40
- const manager = new DefaultPackageManager({ cwd: workspace, agentDir, settingsManager: files });
54
+ const manager = new DefaultPackageManager({
55
+ cwd: workspace,
56
+ agentDir,
57
+ settingsManager: files,
58
+ builtinExtensions: [...BUILTIN_EXTENSIONS],
59
+ });
41
60
  const packages = await manager.resolve(async () => "skip");
42
61
  // A package skipped is said, once: its skills would otherwise just not be there. Asked of the configured list, not
43
62
  // of `onMissing` — pi never calls that under `PI_OFFLINE`, so the warning would vanish exactly when offline.
@@ -72,7 +91,10 @@ async function read(workspace, agentDir) {
72
91
  log.warn(`[fastagent] ${kind} ${diagnostic.type}: ${diagnostic.message}${diagnostic.path ? ` (${diagnostic.path})` : ""}`);
73
92
  }
74
93
  }
75
- return { skills, prompts, settingsManager: () => scopedSettings(settings) };
94
+ const builtinExtensions = packages.extensions
95
+ .filter((resource) => resource.enabled && resource.metadata.source === "builtin")
96
+ .map((resource) => resource.path.slice("builtin:".length));
97
+ return { skills, prompts, builtinExtensions, settingsManager: () => scopedSettings(settings) };
76
98
  }
77
99
  /** A package's enabled resources; the loader discovers the top-level ones itself. */
78
100
  function fromPackages(resources) {
@@ -1,5 +1,5 @@
1
1
  import type { ThinkingLevel } from "@earendil-works/pi-agent-core";
2
- import { type Api, type CredentialStore, InMemoryModelsStore, type Model, type Models, type Provider } from "@earendil-works/pi-ai";
2
+ import { type Api, type CredentialStore, type Model, type Models, type ModelsStore, type Provider } from "@earendil-works/pi-ai";
3
3
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
4
4
  /** The built-ins plus `providers` (a same id replaces a built-in), over any credential store. */
5
5
  export declare function piModelsOver(credentials: CredentialStore, providers?: readonly Provider[]): Models;
@@ -10,6 +10,17 @@ export declare function piModelsOver(credentials: CredentialStore, providers?: r
10
10
  export type AnyModel = Model<any>;
11
11
  /** The serving default for reasoning effort, pinned to what pi's TUI defaults to (its own DEFAULT_THINKING_LEVEL). */
12
12
  export declare const DEFAULT_THINKING_LEVEL: ThinkingLevel;
13
+ /** The models.json pi loads for an agent, and where its content came from (for a load error). */
14
+ export interface ModelsFile {
15
+ /** A snapshot pi loads; null when the agent has no models.json. */
16
+ path: string | null;
17
+ /** The agent's own file, when the snapshot is a copy of it alone. */
18
+ snapshotOf?: string;
19
+ merged?: {
20
+ machine: string;
21
+ definition: string;
22
+ };
23
+ }
13
24
  /**
14
25
  * Where an agent's custom endpoints come from, for a report: the machine file, the providers the agent inherits from
15
26
  * it, and the ones its own file overrides. Undefined when the machine has no file.
@@ -48,36 +59,34 @@ export interface PiModelRuntimeOptions {
48
59
  * never ships.
49
60
  */
50
61
  export declare function globalCatalogPath(): string;
62
+ /**
63
+ * The catalog file a refresh writes, as pi's store. It replaces pi's own file store, which rewrites the file in place
64
+ * under its lock: a reader that takes no lock (every catalog read here, and anything else reading the file) could open
65
+ * it cut short mid-write and blame a healthy file. Same lock as pi's, so concurrent refreshes still queue; reading
66
+ * creates nothing, so a refresh that is refused writes no file.
67
+ */
68
+ export declare function catalogFileStore(path: string): ModelsStore;
51
69
  /**
52
70
  * Whether the machine's model catalog supplies this model, by the same rule every registry reads it with
53
71
  * ({@link layeredCatalog}): an entry pi would ignore supplies nothing. The one layer a deployed agent does not have.
54
72
  */
55
73
  export declare function inGlobalCatalog(provider: string, id: string): Promise<boolean>;
56
- /** The models.json a runtime for these options loads, and which model catalog it reads. */
57
- export declare function modelRuntimeFiles(options: Omit<PiModelRuntimeOptions, "credentials">): Promise<{
58
- models: {
59
- path: string;
60
- merged?: {
61
- machine: string;
62
- definition: string;
63
- };
64
- } | undefined;
65
- create: {
66
- modelsPath: string | null;
67
- modelsStorePath: string;
68
- allowModelNetwork: boolean;
69
- catalogBaseUrl?: string | undefined;
70
- } | {
71
- modelsPath: string | null;
72
- modelsStore: InMemoryModelsStore;
73
- allowModelNetwork: boolean;
74
- catalogBaseUrl?: string | undefined;
75
- } | {
74
+ /**
75
+ * The model files a runtime reads, READ ONCE: `agentModels` keeps one of these per agent, so every session's runtime
76
+ * and the control plane's catalog see the same content however the files change while serving.
77
+ */
78
+ export interface ModelFiles {
79
+ models?: ModelsFile;
80
+ /** `ModelRuntime.create` options over this read; a fresh catalog store each call. */
81
+ create(): Promise<{
76
82
  modelsPath: string | null;
77
- allowModelNetwork: boolean;
78
- catalogBaseUrl?: string | undefined;
79
- };
80
- }>;
83
+ modelsStore?: ModelsStore;
84
+ allowModelNetwork: false;
85
+ catalogBaseUrl?: string;
86
+ }>;
87
+ }
88
+ /** The models.json a runtime for these options loads, and which model catalog it reads. */
89
+ export declare function modelRuntimeFiles(options: Omit<PiModelRuntimeOptions, "credentials">): Promise<ModelFiles>;
81
90
  /**
82
91
  * This machine's registry, without an agent: pi's built-ins, the machine's models.json and its model catalog. What
83
92
  * `fastagent models` lists outside an agent; {@link catalogFile} is the refresh's (it writes the file).
@@ -88,23 +97,15 @@ export declare function machineModelRuntime(options?: {
88
97
  catalogBaseUrl?: string;
89
98
  }): Promise<ModelRuntime>;
90
99
  /**
91
- * Refresh the machine's catalog ({@link globalCatalogPath}) with the machine's credentials: the global credentials
92
- * file and the environment. See {@link refreshCatalog} for what it asks and when it rejects.
100
+ * Fetch the model catalog of every provider the runtime can authenticate into `catalogFile` ({@link
101
+ * catalogFileStore}): the refresh `pi update --models` runs. `build` makes the runtime over that file, and runs only
102
+ * once the refresh is known to go ahead. A refusal writes nothing: the store creates the file only when pi writes an
103
+ * entry. pi asks pi.dev only for a provider with a usable credential, and may refresh an expired OAuth token of the
104
+ * runtime's store to get one. Rejects, naming each provider that failed, when any part fails, when it outlasts 15
105
+ * seconds, when `PI_OFFLINE` is set, when the catalog file is corrupt, and when no provider has a usable credential (the
106
+ * refresh would ask for nothing).
93
107
  */
94
- export declare function refreshGlobalModelCatalog(options?: {
95
- signal?: AbortSignal;
96
- catalogBaseUrl?: string;
97
- }): Promise<void>;
98
- /**
99
- * Fetch the model catalog of every provider the runtime can authenticate into its catalog file: the refresh
100
- * `pi update --models` runs. `runtime(true)` is built over that file, `runtime(false)` over the read-only layers; the
101
- * file-backed one is built only once the refresh is known to run, because pi's file store creates the file on read and
102
- * a refused refresh must leave nothing behind. pi asks pi.dev only for a provider with a usable
103
- * credential, and may refresh an expired OAuth token of the runtime's store to get one. Rejects, naming each provider
104
- * that failed, when any part fails, when it outlasts 15 seconds, when `PI_OFFLINE` is set, and when no provider has a
105
- * usable credential (the refresh would ask for nothing).
106
- */
107
- export declare function refreshCatalog(runtime: (catalogFile: boolean) => Promise<ModelRuntime>, options?: {
108
+ export declare function refreshCatalog(catalogFile: string, build: (catalogFile: string) => Promise<ModelRuntime>, options?: {
108
109
  signal?: AbortSignal;
109
110
  }): Promise<void>;
110
111
  /**
@@ -119,12 +120,16 @@ export declare function refreshCatalog(runtime: (catalogFile: boolean) => Promis
119
120
  * the builder's travels with it.
120
121
  */
121
122
  export declare function environmentAuthSource(providerId: string, env: NodeJS.ProcessEnv, fileExists?: (path: string) => Promise<boolean>): Promise<string | undefined>;
123
+ /** Pi registration starts background refreshes; wait for all of them before publishing this fresh runtime. */
124
+ export declare function withModelRegistration<T>(runtime: ModelRuntime, register: () => Promise<T>): Promise<T>;
122
125
  /**
123
126
  * A registry over the given credential store. What an agent runs on is built through `agentModels`
124
127
  * (agent-models.ts); this is for a registry over some OTHER store: none at all (`models`, a deploy check of what
125
128
  * ships), a trial key under test (login), or the catalog file a refresh writes.
126
129
  */
127
- export declare function createPiModelRuntime(options: PiModelRuntimeOptions): Promise<ModelRuntime>;
130
+ export declare function createPiModelRuntime(options: PiModelRuntimeOptions & {
131
+ files?: ModelFiles;
132
+ }): Promise<ModelRuntime>;
128
133
  /**
129
134
  * EVERY provider whose `models.json` entry writes its key as a LITERAL rather than a `"$NAME"` reference or a
130
135
  * `"!cmd"`. The file ships inside the image, so a literal there is a credential in a readable layer.
@@ -10,12 +10,16 @@ import { join } from "node:path";
10
10
  import { InMemoryCredentialStore, InMemoryModelsStore, defaultProviderAuthContext, } from "@earendil-works/pi-ai";
11
11
  import { builtinModels, builtinProviders, getBuiltinModelDataGeneratedAt } from "@earendil-works/pi-ai/providers/all";
12
12
  import { ModelRuntime } from "@earendil-works/pi-coding-agent";
13
- import { GLOBAL_AUTH_PATH, fastagentCredentialStore } from "./auth.js";
14
13
  import { AGENT_MODEL_CATALOG_FILE, AGENT_MODELS_FILE, globalHome, resolveOverridePath } from "../../paths.js";
15
14
  import { writeFileAtomic } from "../../atomic-write.js";
15
+ import { withLockedFile } from "./locked-file.js";
16
+ import { OPENAI_PROVIDER, registerAccountModels, withAccountModels } from "./openai-account-models.js";
16
17
  /** The built-ins plus `providers` (a same id replaces a built-in), over any credential store. */
17
18
  export function piModelsOver(credentials, providers = []) {
18
19
  const models = builtinModels({ credentials, authContext: defaultProviderAuthContext() });
20
+ const openai = models.getProvider(OPENAI_PROVIDER);
21
+ if (openai)
22
+ models.setProvider(withAccountModels(openai));
19
23
  for (const provider of providers)
20
24
  models.setProvider(provider);
21
25
  return models;
@@ -70,30 +74,45 @@ async function modelLayers(agentDir) {
70
74
  return { machinePath, machine, own: (await readProviders(join(agentDir, AGENT_MODELS_FILE))) ?? {} };
71
75
  }
72
76
  /**
73
- * The models.json pi loads for an agent: its own file, layered over the machine's. The agent's file wins a provider id
74
- * outright, as a definition's skill wins a name: an agent that pins an endpoint keeps it.
77
+ * The models.json pi loads for an agent: its own file, layered over the machine's when `machine` is on. The agent's
78
+ * file wins a provider id outright, as a definition's skill wins a name: an agent that pins an endpoint keeps it.
75
79
  *
76
- * The merge is a SNAPSHOT named by its content, in fastagent's own home: not in the agent (so `info` writes nothing
77
- * there), and not in a shared temp dir, which the OS clears while a long-running process still re-reads it (pi
78
- * reloads `modelsPath` on every refresh). Content addressing is what lets every process share the directory: a
79
- * snapshot is never rewritten, so no process can change what another's refresh reads. The price is that a running
80
- * process keeps the snapshot it started with; an edit to either file takes effect on the next start.
80
+ * Always a SNAPSHOT named by its content, in fastagent's own home: pi reloads `modelsPath` on every refresh, and every
81
+ * session builds its own runtime, so a live file would let turns run on an edit the control plane never read. Not in
82
+ * the agent (so `info` writes nothing there), and not in a shared temp dir, which the OS clears while a long-running
83
+ * process still re-reads it. Content addressing is what lets every process share the directory: a snapshot is never
84
+ * rewritten, so no process can change what another reads. A running process keeps the content it started with; an
85
+ * edit to either file takes effect on the next start.
81
86
  *
82
87
  * ponytail: snapshots are never pruned (one per distinct content, a few KB each) because a running process may still
83
88
  * read an old one; delete the directory while no fastagent process runs if it ever matters.
84
89
  */
85
- async function modelsFileFor(agentDir) {
90
+ async function modelsFileFor(agentDir, machine) {
86
91
  const definition = join(agentDir, AGENT_MODELS_FILE);
87
- const layers = await modelLayers(agentDir);
88
- if (!layers)
89
- return { path: definition };
90
- const content = JSON.stringify({ providers: { ...layers.machine, ...layers.own } }, null, 2);
92
+ const layers = machine ? await modelLayers(agentDir) : undefined;
93
+ if (layers) {
94
+ const content = JSON.stringify({ providers: { ...layers.machine, ...layers.own } }, null, 2);
95
+ return { path: snapshotFile(content), merged: { machine: layers.machinePath, definition } };
96
+ }
97
+ let own;
98
+ try {
99
+ own = await readFile(definition, "utf8");
100
+ }
101
+ catch (error) {
102
+ if (error.code === "ENOENT")
103
+ return { path: null };
104
+ throw new Error(`could not read ${definition}: ${error.message}`);
105
+ }
106
+ // Verbatim, so pi parses it exactly as it would the file itself.
107
+ return { path: snapshotFile(own), snapshotOf: definition };
108
+ }
109
+ function snapshotFile(content) {
91
110
  const hash = createHash("sha256").update(content).digest("hex").slice(0, 32);
92
111
  const path = join(globalHome(), ".cache", "models", `${hash}.json`);
93
112
  // 0600: it may carry literal keys. Several processes may create the same snapshot at once.
94
113
  if (!existsSync(path))
95
114
  writeFileAtomic(path, content, 0o600, true);
96
- return { path, merged: { machine: layers.machinePath, definition } };
115
+ return path;
97
116
  }
98
117
  /**
99
118
  * Where an agent's custom endpoints come from, for a report: the machine file, the providers the agent inherits from
@@ -122,19 +141,63 @@ export function isBuiltinProvider(providerId) {
122
141
  export function globalCatalogPath() {
123
142
  return join(globalHome(), AGENT_MODEL_CATALOG_FILE);
124
143
  }
125
- /** A catalog file as pi writes it (entries by provider), or `{}` when there is none. */
144
+ /** A catalog file as pi writes it (entries by provider), or `{}` when there is none. Read without a lock: its one
145
+ * writer ({@link catalogFileStore}) replaces the file whole, so a read sees the old catalog or the new one. */
126
146
  async function readCatalog(path) {
127
147
  if (!existsSync(path))
128
148
  return {};
149
+ return parseCatalog(await readFile(path, "utf8"), path);
150
+ }
151
+ /**
152
+ * The one reading of a catalog file's content, for reads and writes alike. Empty is an empty catalog, as pi reads it:
153
+ * the writer creates the file exclusively an instant before its first content, and pi's own store, which wrote these
154
+ * files before, left one behind whenever a refresh it began wrote no entry. That is safe only because nothing writes
155
+ * these files in place any more: a rewrite in place is read mostly as empty while it runs. Anything else that does not
156
+ * parse is a corrupt file, and a WRITE refuses it too: serializing over it would drop every other provider's entries.
157
+ */
158
+ function parseCatalog(content, path) {
159
+ if (content === "")
160
+ return {};
129
161
  try {
130
- return JSON.parse(await readFile(path, "utf8"));
162
+ return JSON.parse(content);
131
163
  }
132
164
  catch (error) {
133
165
  throw new Error(`model catalog ${path} is not valid JSON (${error.message}): refresh or delete it`);
134
166
  }
135
167
  }
136
168
  /**
137
- * The catalog files layered into one read-only store, later files winning a model id. An entry pi would ignore (no
169
+ * The catalog file a refresh writes, as pi's store. It replaces pi's own file store, which rewrites the file in place
170
+ * under its lock: a reader that takes no lock (every catalog read here, and anything else reading the file) could open
171
+ * it cut short mid-write and blame a healthy file. Same lock as pi's, so concurrent refreshes still queue; reading
172
+ * creates nothing, so a refresh that is refused writes no file.
173
+ */
174
+ export function catalogFileStore(path) {
175
+ const change = (update, signal) => withLockedFile(path, async (current) => {
176
+ const catalog = parseCatalog(current ?? "", path);
177
+ update(catalog);
178
+ return { result: undefined, next: JSON.stringify(catalog, null, 2) };
179
+ }, signal ? { signal } : {});
180
+ return {
181
+ async read(providerId, options) {
182
+ options?.signal?.throwIfAborted();
183
+ return (await readCatalog(path))[providerId];
184
+ },
185
+ async write(providerId, entry, options) {
186
+ options?.signal?.throwIfAborted();
187
+ await change((catalog) => {
188
+ catalog[providerId] = structuredClone(entry);
189
+ }, options?.signal);
190
+ },
191
+ async delete(providerId, options) {
192
+ options?.signal?.throwIfAborted();
193
+ await change((catalog) => {
194
+ delete catalog[providerId];
195
+ }, options?.signal);
196
+ },
197
+ };
198
+ }
199
+ /**
200
+ * The catalog files layered into one read-only store, later files winning a (type, model id) pair. An entry pi would ignore (no
138
201
  * newer than the catalog bundled with it) is dropped before the merge, so a stale layer cannot ride a newer one's
139
202
  * date past pi's rule.
140
203
  *
@@ -142,6 +205,16 @@ async function readCatalog(path) {
142
205
  * on read, which would drop an empty catalog into every agent dir that was never refreshed.
143
206
  */
144
207
  async function layeredCatalog(paths) {
208
+ return catalogStore(await readLayeredCatalog(paths));
209
+ }
210
+ /** A fresh store over a catalog read once: pi may write into the store it is given, so runtimes never share one. */
211
+ async function catalogStore(entries) {
212
+ const store = new InMemoryModelsStore();
213
+ for (const [provider, entry] of entries)
214
+ await store.write(provider, structuredClone(entry));
215
+ return store;
216
+ }
217
+ async function readLayeredCatalog(paths) {
145
218
  const bundledAt = getBuiltinModelDataGeneratedAt();
146
219
  const merged = new Map();
147
220
  for (const path of paths) {
@@ -149,19 +222,17 @@ async function layeredCatalog(paths) {
149
222
  if (bundledAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= bundledAt))
150
223
  continue;
151
224
  const held = merged.get(provider);
152
- const ids = new Set(entry.models.map((model) => model.id));
225
+ const key = (model) => `${model.type ?? "chat"}\u0000${model.id}`;
226
+ const ids = new Set(entry.models.map(key));
153
227
  merged.set(provider, held
154
228
  ? {
155
- models: [...held.models.filter((model) => !ids.has(model.id)), ...entry.models],
229
+ models: [...held.models.filter((model) => !ids.has(key(model))), ...entry.models],
156
230
  lastModified: Math.max(held.lastModified ?? 0, entry.lastModified ?? 0),
157
231
  }
158
232
  : entry);
159
233
  }
160
234
  }
161
- const store = new InMemoryModelsStore();
162
- for (const [provider, entry] of merged)
163
- await store.write(provider, entry);
164
- return store;
235
+ return merged;
165
236
  }
166
237
  /**
167
238
  * Whether the machine's model catalog supplies this model, by the same rule every registry reads it with
@@ -169,35 +240,32 @@ async function layeredCatalog(paths) {
169
240
  */
170
241
  export async function inGlobalCatalog(provider, id) {
171
242
  const entry = await (await layeredCatalog([globalCatalogPath()])).read(provider);
172
- return entry?.models.some((model) => model.id === id) ?? false;
243
+ return entry?.models.some((model) => (model.type ?? "chat") === "chat" && model.id === id) ?? false;
173
244
  }
174
245
  /** The models.json a runtime for these options loads, and which model catalog it reads. */
175
246
  export async function modelRuntimeFiles(options) {
176
247
  const { agentDir } = options;
177
248
  const machine = options.machineLayer !== false;
178
- const models = !agentDir
179
- ? undefined
180
- : machine
181
- ? await modelsFileFor(agentDir)
182
- : { path: join(agentDir, AGENT_MODELS_FILE) };
249
+ const models = agentDir ? await modelsFileFor(agentDir, machine) : undefined;
183
250
  const catalogs = !agentDir
184
251
  ? []
185
252
  : [...(machine ? [globalCatalogPath()] : []), join(agentDir, AGENT_MODEL_CATALOG_FILE)];
253
+ const catalog = !options.catalogFile && agentDir ? await readLayeredCatalog(catalogs) : undefined;
186
254
  return {
187
- models,
188
- create: {
255
+ ...(models ? { models } : {}),
256
+ create: async () => ({
189
257
  modelsPath: models?.path ?? null,
190
258
  // Always a store of our own whenever modelsPath is set: pi's default is a file at
191
259
  // `<dirname(modelsPath)>/models-store.json`. Without a directory, pi keeps an empty one in memory.
192
260
  ...(options.catalogFile
193
- ? { modelsStorePath: options.catalogFile }
194
- : agentDir
195
- ? { modelsStore: await layeredCatalog(catalogs) }
261
+ ? { modelsStore: catalogFileStore(options.catalogFile) }
262
+ : catalog
263
+ ? { modelsStore: await catalogStore(catalog) }
196
264
  : {}),
197
265
  // Never fetched while a runtime is built: serving stays offline and reproducible. A refresh is asked for.
198
266
  allowModelNetwork: false,
199
267
  ...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
200
- },
268
+ }),
201
269
  };
202
270
  }
203
271
  /**
@@ -208,56 +276,49 @@ export async function machineModelRuntime(options = {}) {
208
276
  const runtime = await ModelRuntime.create({
209
277
  credentials: options.credentials ?? new InMemoryCredentialStore(),
210
278
  modelsPath: machineModelsPath(),
211
- ...(options.catalogFile
212
- ? { modelsStorePath: options.catalogFile }
213
- : { modelsStore: await layeredCatalog([globalCatalogPath()]) }),
279
+ modelsStore: options.catalogFile
280
+ ? catalogFileStore(options.catalogFile)
281
+ : await layeredCatalog([globalCatalogPath()]),
214
282
  allowModelNetwork: false,
215
283
  ...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
216
284
  });
217
285
  const error = runtime.getError();
218
286
  if (error)
219
287
  throw new Error(error);
288
+ // A machine catalog refresh may refresh an OAuth token, which must carry the account's catalog over.
289
+ await withModelRegistration(runtime, async () => registerAccountModels(runtime));
220
290
  return runtime;
221
291
  }
222
- /**
223
- * Refresh the machine's catalog ({@link globalCatalogPath}) with the machine's credentials: the global credentials
224
- * file and the environment. See {@link refreshCatalog} for what it asks and when it rejects.
225
- */
226
- export async function refreshGlobalModelCatalog(options = {}) {
227
- const credentials = fastagentCredentialStore(GLOBAL_AUTH_PATH);
228
- await refreshCatalog((catalogFile) => machineModelRuntime({
229
- credentials,
230
- ...(catalogFile ? { catalogFile: globalCatalogPath() } : {}),
231
- ...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
232
- }), options.signal ? { signal: options.signal } : {});
233
- }
234
292
  /** How long a catalog refresh may take, as `pi update --models` allows. */
235
293
  const CATALOG_REFRESH_TIMEOUT_MS = 15_000;
236
294
  /**
237
- * Fetch the model catalog of every provider the runtime can authenticate into its catalog file: the refresh
238
- * `pi update --models` runs. `runtime(true)` is built over that file, `runtime(false)` over the read-only layers; the
239
- * file-backed one is built only once the refresh is known to run, because pi's file store creates the file on read and
240
- * a refused refresh must leave nothing behind. pi asks pi.dev only for a provider with a usable
241
- * credential, and may refresh an expired OAuth token of the runtime's store to get one. Rejects, naming each provider
242
- * that failed, when any part fails, when it outlasts 15 seconds, when `PI_OFFLINE` is set, and when no provider has a
243
- * usable credential (the refresh would ask for nothing).
295
+ * Fetch the model catalog of every provider the runtime can authenticate into `catalogFile` ({@link
296
+ * catalogFileStore}): the refresh `pi update --models` runs. `build` makes the runtime over that file, and runs only
297
+ * once the refresh is known to go ahead. A refusal writes nothing: the store creates the file only when pi writes an
298
+ * entry. pi asks pi.dev only for a provider with a usable credential, and may refresh an expired OAuth token of the
299
+ * runtime's store to get one. Rejects, naming each provider that failed, when any part fails, when it outlasts 15
300
+ * seconds, when `PI_OFFLINE` is set, when the catalog file is corrupt, and when no provider has a usable credential (the
301
+ * refresh would ask for nothing).
244
302
  */
245
- export async function refreshCatalog(runtime, options = {}) {
303
+ export async function refreshCatalog(catalogFile, build, options = {}) {
246
304
  // pi skips its own background refresh under PI_OFFLINE, but an explicit `allowNetwork: true` overrides that.
247
305
  if (process.env.PI_OFFLINE !== undefined)
248
306
  throw new Error("PI_OFFLINE is set, so the model catalog is not refreshed");
307
+ // A corrupt file fails here, once, by its own message: past this point every provider's refresh reads the store and
308
+ // would repeat it, worded as that provider's failure.
309
+ await readCatalog(catalogFile);
310
+ const runtime = await build(catalogFile);
249
311
  // pi skips a provider it cannot authenticate without recording anything, so with no usable credential at all the
250
312
  // refresh would "succeed" having asked for nothing.
251
- const probe = await runtime(false);
252
- const refreshable = probe.getProviders().filter((provider) => provider.refreshModels !== undefined);
253
- const usable = await Promise.all(refreshable.map(async (provider) => (await probe.checkAuth(provider.id)) !== undefined));
313
+ const refreshable = runtime.getProviders().filter((provider) => provider.refreshModels !== undefined);
314
+ const usable = await Promise.all(refreshable.map(async (provider) => (await runtime.checkAuth(provider.id)) !== undefined));
254
315
  if (!usable.includes(true)) {
255
316
  throw new Error("no provider has a usable credential here, so there is no model catalog to fetch — log in " +
256
317
  "(`fastagent login`) or set a provider's API key first");
257
318
  }
258
319
  const timeout = AbortSignal.timeout(CATALOG_REFRESH_TIMEOUT_MS);
259
320
  const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
260
- const result = await (await runtime(true)).refresh({ allowNetwork: true, force: true, signal });
321
+ const result = await runtime.refresh({ allowNetwork: true, force: true, signal });
261
322
  if (result.aborted) {
262
323
  throw new Error(options.signal?.aborted
263
324
  ? "model catalog refresh cancelled"
@@ -290,25 +351,48 @@ export async function environmentAuthSource(providerId, env, fileExists = defaul
290
351
  });
291
352
  return (await models.checkAuth(providerId))?.source;
292
353
  }
354
+ /** Pi registration starts background refreshes; wait for all of them before publishing this fresh runtime. */
355
+ export async function withModelRegistration(runtime, register) {
356
+ const refresh = runtime.refresh;
357
+ const refreshes = [];
358
+ runtime.refresh = (...args) => {
359
+ const pending = refresh.apply(runtime, args);
360
+ refreshes.push(pending);
361
+ return pending;
362
+ };
363
+ try {
364
+ const result = await register();
365
+ await Promise.all(refreshes);
366
+ return result;
367
+ }
368
+ finally {
369
+ runtime.refresh = refresh;
370
+ }
371
+ }
293
372
  /**
294
373
  * A registry over the given credential store. What an agent runs on is built through `agentModels`
295
374
  * (agent-models.ts); this is for a registry over some OTHER store: none at all (`models`, a deploy check of what
296
375
  * ships), a trial key under test (login), or the catalog file a refresh writes.
297
376
  */
298
377
  export async function createPiModelRuntime(options) {
299
- const { models, create } = await modelRuntimeFiles(options);
300
- const runtime = await ModelRuntime.create({ credentials: options.credentials, ...create });
378
+ const { models, create } = options.files ?? (await modelRuntimeFiles(options));
379
+ const runtime = await ModelRuntime.create({ credentials: options.credentials, ...(await create()) });
301
380
  // A malformed models.json does NOT throw upstream — `create` resolves with the built-ins and parks the reason in
302
381
  // getError().
303
382
  const error = runtime.getError();
304
383
  if (error) {
305
384
  const origin = models?.merged
306
385
  ? `\n\nThat file merges ${models.merged.machine} with ${models.merged.definition} (the agent's own wins a provider id).`
307
- : "";
386
+ : models?.snapshotOf
387
+ ? `\n\nThat file is ${models.snapshotOf} as read at startup.`
388
+ : "";
308
389
  throw new Error(`${error}${origin}`);
309
390
  }
310
- for (const provider of options.providers ?? [])
311
- runtime.registerNativeProvider(provider);
391
+ await withModelRegistration(runtime, async () => {
392
+ registerAccountModels(runtime);
393
+ for (const provider of options.providers ?? [])
394
+ runtime.registerNativeProvider(provider);
395
+ });
312
396
  return runtime;
313
397
  }
314
398
  /**
@@ -417,6 +501,9 @@ export async function probeAuthSource(models, spec) {
417
501
  const model = models.getModel(spec.slice(0, slash), spec.slice(slash + 1));
418
502
  if (!model)
419
503
  return undefined;
504
+ // A virtual selection authenticates the physical model after routing, not its catalog entry.
505
+ if (model.api === "pi-virtual")
506
+ return "virtual";
420
507
  return (await models.getAuth(model))?.source;
421
508
  }
422
509
  /** Quick-fail probe for a just-stored API key. */