@fastagent-sh/fastagent 0.23.0 → 0.24.0
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.
- package/dist/agent.d.ts +10 -0
- package/dist/channels/kit/preview-kit.d.ts +6 -1
- package/dist/channels/kit/preview-kit.js +21 -4
- package/dist/channels/slack/preview.js +2 -1
- package/dist/cli/commands/info.js +7 -7
- package/dist/cli/commands/login.js +2 -2
- package/dist/cli/commands/models.js +3 -3
- package/dist/cli/invoke-stream.js +14 -0
- package/dist/cli/shared.d.ts +2 -1
- package/dist/cli/shared.js +6 -7
- package/dist/deploy/preflight.js +19 -1
- package/dist/dev-supervisor.js +2 -2
- package/dist/engines/pi/agent-models.d.ts +14 -1
- package/dist/engines/pi/agent-models.js +35 -8
- package/dist/engines/pi/agent-session-factory.d.ts +13 -13
- package/dist/engines/pi/agent-session-factory.js +132 -159
- package/dist/engines/pi/auth.js +6 -94
- package/dist/engines/pi/create.d.ts +16 -9
- package/dist/engines/pi/create.js +71 -38
- package/dist/engines/pi/invoke-session.js +82 -16
- package/dist/engines/pi/locked-file.d.ts +9 -0
- package/dist/engines/pi/locked-file.js +102 -0
- package/dist/engines/pi/login.d.ts +7 -1
- package/dist/engines/pi/login.js +22 -6
- package/dist/engines/pi/machine.d.ts +23 -0
- package/dist/engines/pi/machine.js +24 -2
- package/dist/engines/pi/models.d.ts +47 -42
- package/dist/engines/pi/models.js +147 -67
- package/dist/engines/pi/open.d.ts +25 -11
- package/dist/engines/pi/open.js +42 -20
- package/dist/engines/pi/report.d.ts +3 -0
- package/dist/engines/pi/report.js +11 -0
- package/dist/engines/pi/session-builder.js +6 -22
- package/dist/engines/pi/session-control.d.ts +4 -4
- package/dist/engines/pi/session-control.js +72 -40
- package/dist/engines/pi/session-settings.d.ts +9 -0
- package/dist/engines/pi/session-settings.js +17 -0
- package/dist/engines/pi/session-store.d.ts +2 -2
- package/dist/engines/pi/tool-context.d.ts +3 -5
- package/dist/engines/pi/tool-context.js +4 -16
- package/dist/engines/pi/tool.d.ts +32 -21
- package/dist/engines/pi/tool.js +21 -25
- package/dist/engines/pi/turn-kit.d.ts +18 -9
- package/dist/engines/pi/turn-kit.js +104 -27
- package/dist/pi.d.ts +1 -1
- package/dist/pi.js +1 -1
- package/dist/session.d.ts +60 -8
- package/dist/session.js +5 -1
- package/package.json +5 -5
- package/dist/engines/pi/search-tools.d.ts +0 -4
- 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({
|
|
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
|
-
|
|
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,
|
|
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
|
-
/**
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
*
|
|
92
|
-
*
|
|
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
|
|
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
|
|
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,9 +10,9 @@ 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
16
|
/** The built-ins plus `providers` (a same id replaces a built-in), over any credential store. */
|
|
17
17
|
export function piModelsOver(credentials, providers = []) {
|
|
18
18
|
const models = builtinModels({ credentials, authContext: defaultProviderAuthContext() });
|
|
@@ -70,30 +70,45 @@ async function modelLayers(agentDir) {
|
|
|
70
70
|
return { machinePath, machine, own: (await readProviders(join(agentDir, AGENT_MODELS_FILE))) ?? {} };
|
|
71
71
|
}
|
|
72
72
|
/**
|
|
73
|
-
* The models.json pi loads for an agent: its own file, layered over the machine's. The agent's
|
|
74
|
-
* outright, as a definition's skill wins a name: an agent that pins an endpoint keeps it.
|
|
73
|
+
* The models.json pi loads for an agent: its own file, layered over the machine's when `machine` is on. The agent's
|
|
74
|
+
* file wins a provider id outright, as a definition's skill wins a name: an agent that pins an endpoint keeps it.
|
|
75
75
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
76
|
+
* Always a SNAPSHOT named by its content, in fastagent's own home: pi reloads `modelsPath` on every refresh, and every
|
|
77
|
+
* session builds its own runtime, so a live file would let turns run on an edit the control plane never read. Not in
|
|
78
|
+
* the agent (so `info` writes nothing there), and not in a shared temp dir, which the OS clears while a long-running
|
|
79
|
+
* process still re-reads it. Content addressing is what lets every process share the directory: a snapshot is never
|
|
80
|
+
* rewritten, so no process can change what another reads. A running process keeps the content it started with; an
|
|
81
|
+
* edit to either file takes effect on the next start.
|
|
81
82
|
*
|
|
82
83
|
* ponytail: snapshots are never pruned (one per distinct content, a few KB each) because a running process may still
|
|
83
84
|
* read an old one; delete the directory while no fastagent process runs if it ever matters.
|
|
84
85
|
*/
|
|
85
|
-
async function modelsFileFor(agentDir) {
|
|
86
|
+
async function modelsFileFor(agentDir, machine) {
|
|
86
87
|
const definition = join(agentDir, AGENT_MODELS_FILE);
|
|
87
|
-
const layers = await modelLayers(agentDir);
|
|
88
|
-
if (
|
|
89
|
-
|
|
90
|
-
|
|
88
|
+
const layers = machine ? await modelLayers(agentDir) : undefined;
|
|
89
|
+
if (layers) {
|
|
90
|
+
const content = JSON.stringify({ providers: { ...layers.machine, ...layers.own } }, null, 2);
|
|
91
|
+
return { path: snapshotFile(content), merged: { machine: layers.machinePath, definition } };
|
|
92
|
+
}
|
|
93
|
+
let own;
|
|
94
|
+
try {
|
|
95
|
+
own = await readFile(definition, "utf8");
|
|
96
|
+
}
|
|
97
|
+
catch (error) {
|
|
98
|
+
if (error.code === "ENOENT")
|
|
99
|
+
return { path: null };
|
|
100
|
+
throw new Error(`could not read ${definition}: ${error.message}`);
|
|
101
|
+
}
|
|
102
|
+
// Verbatim, so pi parses it exactly as it would the file itself.
|
|
103
|
+
return { path: snapshotFile(own), snapshotOf: definition };
|
|
104
|
+
}
|
|
105
|
+
function snapshotFile(content) {
|
|
91
106
|
const hash = createHash("sha256").update(content).digest("hex").slice(0, 32);
|
|
92
107
|
const path = join(globalHome(), ".cache", "models", `${hash}.json`);
|
|
93
108
|
// 0600: it may carry literal keys. Several processes may create the same snapshot at once.
|
|
94
109
|
if (!existsSync(path))
|
|
95
110
|
writeFileAtomic(path, content, 0o600, true);
|
|
96
|
-
return
|
|
111
|
+
return path;
|
|
97
112
|
}
|
|
98
113
|
/**
|
|
99
114
|
* Where an agent's custom endpoints come from, for a report: the machine file, the providers the agent inherits from
|
|
@@ -122,19 +137,63 @@ export function isBuiltinProvider(providerId) {
|
|
|
122
137
|
export function globalCatalogPath() {
|
|
123
138
|
return join(globalHome(), AGENT_MODEL_CATALOG_FILE);
|
|
124
139
|
}
|
|
125
|
-
/** A catalog file as pi writes it (entries by provider), or `{}` when there is none.
|
|
140
|
+
/** A catalog file as pi writes it (entries by provider), or `{}` when there is none. Read without a lock: its one
|
|
141
|
+
* writer ({@link catalogFileStore}) replaces the file whole, so a read sees the old catalog or the new one. */
|
|
126
142
|
async function readCatalog(path) {
|
|
127
143
|
if (!existsSync(path))
|
|
128
144
|
return {};
|
|
145
|
+
return parseCatalog(await readFile(path, "utf8"), path);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* The one reading of a catalog file's content, for reads and writes alike. Empty is an empty catalog, as pi reads it:
|
|
149
|
+
* the writer creates the file exclusively an instant before its first content, and pi's own store, which wrote these
|
|
150
|
+
* files before, left one behind whenever a refresh it began wrote no entry. That is safe only because nothing writes
|
|
151
|
+
* these files in place any more: a rewrite in place is read mostly as empty while it runs. Anything else that does not
|
|
152
|
+
* parse is a corrupt file, and a WRITE refuses it too: serializing over it would drop every other provider's entries.
|
|
153
|
+
*/
|
|
154
|
+
function parseCatalog(content, path) {
|
|
155
|
+
if (content === "")
|
|
156
|
+
return {};
|
|
129
157
|
try {
|
|
130
|
-
return JSON.parse(
|
|
158
|
+
return JSON.parse(content);
|
|
131
159
|
}
|
|
132
160
|
catch (error) {
|
|
133
161
|
throw new Error(`model catalog ${path} is not valid JSON (${error.message}): refresh or delete it`);
|
|
134
162
|
}
|
|
135
163
|
}
|
|
136
164
|
/**
|
|
137
|
-
* The catalog
|
|
165
|
+
* The catalog file a refresh writes, as pi's store. It replaces pi's own file store, which rewrites the file in place
|
|
166
|
+
* under its lock: a reader that takes no lock (every catalog read here, and anything else reading the file) could open
|
|
167
|
+
* it cut short mid-write and blame a healthy file. Same lock as pi's, so concurrent refreshes still queue; reading
|
|
168
|
+
* creates nothing, so a refresh that is refused writes no file.
|
|
169
|
+
*/
|
|
170
|
+
export function catalogFileStore(path) {
|
|
171
|
+
const change = (update, signal) => withLockedFile(path, async (current) => {
|
|
172
|
+
const catalog = parseCatalog(current ?? "", path);
|
|
173
|
+
update(catalog);
|
|
174
|
+
return { result: undefined, next: JSON.stringify(catalog, null, 2) };
|
|
175
|
+
}, signal ? { signal } : {});
|
|
176
|
+
return {
|
|
177
|
+
async read(providerId, options) {
|
|
178
|
+
options?.signal?.throwIfAborted();
|
|
179
|
+
return (await readCatalog(path))[providerId];
|
|
180
|
+
},
|
|
181
|
+
async write(providerId, entry, options) {
|
|
182
|
+
options?.signal?.throwIfAborted();
|
|
183
|
+
await change((catalog) => {
|
|
184
|
+
catalog[providerId] = structuredClone(entry);
|
|
185
|
+
}, options?.signal);
|
|
186
|
+
},
|
|
187
|
+
async delete(providerId, options) {
|
|
188
|
+
options?.signal?.throwIfAborted();
|
|
189
|
+
await change((catalog) => {
|
|
190
|
+
delete catalog[providerId];
|
|
191
|
+
}, options?.signal);
|
|
192
|
+
},
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The catalog files layered into one read-only store, later files winning a (type, model id) pair. An entry pi would ignore (no
|
|
138
197
|
* newer than the catalog bundled with it) is dropped before the merge, so a stale layer cannot ride a newer one's
|
|
139
198
|
* date past pi's rule.
|
|
140
199
|
*
|
|
@@ -142,6 +201,16 @@ async function readCatalog(path) {
|
|
|
142
201
|
* on read, which would drop an empty catalog into every agent dir that was never refreshed.
|
|
143
202
|
*/
|
|
144
203
|
async function layeredCatalog(paths) {
|
|
204
|
+
return catalogStore(await readLayeredCatalog(paths));
|
|
205
|
+
}
|
|
206
|
+
/** A fresh store over a catalog read once: pi may write into the store it is given, so runtimes never share one. */
|
|
207
|
+
async function catalogStore(entries) {
|
|
208
|
+
const store = new InMemoryModelsStore();
|
|
209
|
+
for (const [provider, entry] of entries)
|
|
210
|
+
await store.write(provider, structuredClone(entry));
|
|
211
|
+
return store;
|
|
212
|
+
}
|
|
213
|
+
async function readLayeredCatalog(paths) {
|
|
145
214
|
const bundledAt = getBuiltinModelDataGeneratedAt();
|
|
146
215
|
const merged = new Map();
|
|
147
216
|
for (const path of paths) {
|
|
@@ -149,19 +218,17 @@ async function layeredCatalog(paths) {
|
|
|
149
218
|
if (bundledAt !== undefined && (entry.lastModified === undefined || entry.lastModified <= bundledAt))
|
|
150
219
|
continue;
|
|
151
220
|
const held = merged.get(provider);
|
|
152
|
-
const
|
|
221
|
+
const key = (model) => `${model.type ?? "chat"}\u0000${model.id}`;
|
|
222
|
+
const ids = new Set(entry.models.map(key));
|
|
153
223
|
merged.set(provider, held
|
|
154
224
|
? {
|
|
155
|
-
models: [...held.models.filter((model) => !ids.has(model
|
|
225
|
+
models: [...held.models.filter((model) => !ids.has(key(model))), ...entry.models],
|
|
156
226
|
lastModified: Math.max(held.lastModified ?? 0, entry.lastModified ?? 0),
|
|
157
227
|
}
|
|
158
228
|
: entry);
|
|
159
229
|
}
|
|
160
230
|
}
|
|
161
|
-
|
|
162
|
-
for (const [provider, entry] of merged)
|
|
163
|
-
await store.write(provider, entry);
|
|
164
|
-
return store;
|
|
231
|
+
return merged;
|
|
165
232
|
}
|
|
166
233
|
/**
|
|
167
234
|
* Whether the machine's model catalog supplies this model, by the same rule every registry reads it with
|
|
@@ -169,35 +236,32 @@ async function layeredCatalog(paths) {
|
|
|
169
236
|
*/
|
|
170
237
|
export async function inGlobalCatalog(provider, id) {
|
|
171
238
|
const entry = await (await layeredCatalog([globalCatalogPath()])).read(provider);
|
|
172
|
-
return entry?.models.some((model) => model.id === id) ?? false;
|
|
239
|
+
return entry?.models.some((model) => (model.type ?? "chat") === "chat" && model.id === id) ?? false;
|
|
173
240
|
}
|
|
174
241
|
/** The models.json a runtime for these options loads, and which model catalog it reads. */
|
|
175
242
|
export async function modelRuntimeFiles(options) {
|
|
176
243
|
const { agentDir } = options;
|
|
177
244
|
const machine = options.machineLayer !== false;
|
|
178
|
-
const models =
|
|
179
|
-
? undefined
|
|
180
|
-
: machine
|
|
181
|
-
? await modelsFileFor(agentDir)
|
|
182
|
-
: { path: join(agentDir, AGENT_MODELS_FILE) };
|
|
245
|
+
const models = agentDir ? await modelsFileFor(agentDir, machine) : undefined;
|
|
183
246
|
const catalogs = !agentDir
|
|
184
247
|
? []
|
|
185
248
|
: [...(machine ? [globalCatalogPath()] : []), join(agentDir, AGENT_MODEL_CATALOG_FILE)];
|
|
249
|
+
const catalog = !options.catalogFile && agentDir ? await readLayeredCatalog(catalogs) : undefined;
|
|
186
250
|
return {
|
|
187
|
-
models,
|
|
188
|
-
create: {
|
|
251
|
+
...(models ? { models } : {}),
|
|
252
|
+
create: async () => ({
|
|
189
253
|
modelsPath: models?.path ?? null,
|
|
190
254
|
// Always a store of our own whenever modelsPath is set: pi's default is a file at
|
|
191
255
|
// `<dirname(modelsPath)>/models-store.json`. Without a directory, pi keeps an empty one in memory.
|
|
192
256
|
...(options.catalogFile
|
|
193
|
-
? {
|
|
194
|
-
:
|
|
195
|
-
? { modelsStore: await
|
|
257
|
+
? { modelsStore: catalogFileStore(options.catalogFile) }
|
|
258
|
+
: catalog
|
|
259
|
+
? { modelsStore: await catalogStore(catalog) }
|
|
196
260
|
: {}),
|
|
197
261
|
// Never fetched while a runtime is built: serving stays offline and reproducible. A refresh is asked for.
|
|
198
262
|
allowModelNetwork: false,
|
|
199
263
|
...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
|
|
200
|
-
},
|
|
264
|
+
}),
|
|
201
265
|
};
|
|
202
266
|
}
|
|
203
267
|
/**
|
|
@@ -208,9 +272,9 @@ export async function machineModelRuntime(options = {}) {
|
|
|
208
272
|
const runtime = await ModelRuntime.create({
|
|
209
273
|
credentials: options.credentials ?? new InMemoryCredentialStore(),
|
|
210
274
|
modelsPath: machineModelsPath(),
|
|
211
|
-
|
|
212
|
-
?
|
|
213
|
-
:
|
|
275
|
+
modelsStore: options.catalogFile
|
|
276
|
+
? catalogFileStore(options.catalogFile)
|
|
277
|
+
: await layeredCatalog([globalCatalogPath()]),
|
|
214
278
|
allowModelNetwork: false,
|
|
215
279
|
...(options.catalogBaseUrl ? { catalogBaseUrl: options.catalogBaseUrl } : {}),
|
|
216
280
|
});
|
|
@@ -219,45 +283,36 @@ export async function machineModelRuntime(options = {}) {
|
|
|
219
283
|
throw new Error(error);
|
|
220
284
|
return runtime;
|
|
221
285
|
}
|
|
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
286
|
/** How long a catalog refresh may take, as `pi update --models` allows. */
|
|
235
287
|
const CATALOG_REFRESH_TIMEOUT_MS = 15_000;
|
|
236
288
|
/**
|
|
237
|
-
* Fetch the model catalog of every provider the runtime can authenticate into
|
|
238
|
-
* `pi update --models` runs. `
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
289
|
+
* Fetch the model catalog of every provider the runtime can authenticate into `catalogFile` ({@link
|
|
290
|
+
* catalogFileStore}): the refresh `pi update --models` runs. `build` makes the runtime over that file, and runs only
|
|
291
|
+
* once the refresh is known to go ahead. A refusal writes nothing: the store creates the file only when pi writes an
|
|
292
|
+
* entry. pi asks pi.dev only for a provider with a usable credential, and may refresh an expired OAuth token of the
|
|
293
|
+
* runtime's store to get one. Rejects, naming each provider that failed, when any part fails, when it outlasts 15
|
|
294
|
+
* seconds, when `PI_OFFLINE` is set, when the catalog file is corrupt, and when no provider has a usable credential (the
|
|
295
|
+
* refresh would ask for nothing).
|
|
244
296
|
*/
|
|
245
|
-
export async function refreshCatalog(
|
|
297
|
+
export async function refreshCatalog(catalogFile, build, options = {}) {
|
|
246
298
|
// pi skips its own background refresh under PI_OFFLINE, but an explicit `allowNetwork: true` overrides that.
|
|
247
299
|
if (process.env.PI_OFFLINE !== undefined)
|
|
248
300
|
throw new Error("PI_OFFLINE is set, so the model catalog is not refreshed");
|
|
301
|
+
// A corrupt file fails here, once, by its own message: past this point every provider's refresh reads the store and
|
|
302
|
+
// would repeat it, worded as that provider's failure.
|
|
303
|
+
await readCatalog(catalogFile);
|
|
304
|
+
const runtime = await build(catalogFile);
|
|
249
305
|
// pi skips a provider it cannot authenticate without recording anything, so with no usable credential at all the
|
|
250
306
|
// refresh would "succeed" having asked for nothing.
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
const usable = await Promise.all(refreshable.map(async (provider) => (await probe.checkAuth(provider.id)) !== undefined));
|
|
307
|
+
const refreshable = runtime.getProviders().filter((provider) => provider.refreshModels !== undefined);
|
|
308
|
+
const usable = await Promise.all(refreshable.map(async (provider) => (await runtime.checkAuth(provider.id)) !== undefined));
|
|
254
309
|
if (!usable.includes(true)) {
|
|
255
310
|
throw new Error("no provider has a usable credential here, so there is no model catalog to fetch — log in " +
|
|
256
311
|
"(`fastagent login`) or set a provider's API key first");
|
|
257
312
|
}
|
|
258
313
|
const timeout = AbortSignal.timeout(CATALOG_REFRESH_TIMEOUT_MS);
|
|
259
314
|
const signal = options.signal ? AbortSignal.any([options.signal, timeout]) : timeout;
|
|
260
|
-
const result = await
|
|
315
|
+
const result = await runtime.refresh({ allowNetwork: true, force: true, signal });
|
|
261
316
|
if (result.aborted) {
|
|
262
317
|
throw new Error(options.signal?.aborted
|
|
263
318
|
? "model catalog refresh cancelled"
|
|
@@ -290,25 +345,47 @@ export async function environmentAuthSource(providerId, env, fileExists = defaul
|
|
|
290
345
|
});
|
|
291
346
|
return (await models.checkAuth(providerId))?.source;
|
|
292
347
|
}
|
|
348
|
+
/** Pi registration starts background refreshes; wait for all of them before publishing this fresh runtime. */
|
|
349
|
+
export async function withModelRegistration(runtime, register) {
|
|
350
|
+
const refresh = runtime.refresh;
|
|
351
|
+
const refreshes = [];
|
|
352
|
+
runtime.refresh = (...args) => {
|
|
353
|
+
const pending = refresh.apply(runtime, args);
|
|
354
|
+
refreshes.push(pending);
|
|
355
|
+
return pending;
|
|
356
|
+
};
|
|
357
|
+
try {
|
|
358
|
+
const result = await register();
|
|
359
|
+
await Promise.all(refreshes);
|
|
360
|
+
return result;
|
|
361
|
+
}
|
|
362
|
+
finally {
|
|
363
|
+
runtime.refresh = refresh;
|
|
364
|
+
}
|
|
365
|
+
}
|
|
293
366
|
/**
|
|
294
367
|
* A registry over the given credential store. What an agent runs on is built through `agentModels`
|
|
295
368
|
* (agent-models.ts); this is for a registry over some OTHER store: none at all (`models`, a deploy check of what
|
|
296
369
|
* ships), a trial key under test (login), or the catalog file a refresh writes.
|
|
297
370
|
*/
|
|
298
371
|
export async function createPiModelRuntime(options) {
|
|
299
|
-
const { models, create } = await modelRuntimeFiles(options);
|
|
300
|
-
const runtime = await ModelRuntime.create({ credentials: options.credentials, ...create });
|
|
372
|
+
const { models, create } = options.files ?? (await modelRuntimeFiles(options));
|
|
373
|
+
const runtime = await ModelRuntime.create({ credentials: options.credentials, ...(await create()) });
|
|
301
374
|
// A malformed models.json does NOT throw upstream — `create` resolves with the built-ins and parks the reason in
|
|
302
375
|
// getError().
|
|
303
376
|
const error = runtime.getError();
|
|
304
377
|
if (error) {
|
|
305
378
|
const origin = models?.merged
|
|
306
379
|
? `\n\nThat file merges ${models.merged.machine} with ${models.merged.definition} (the agent's own wins a provider id).`
|
|
307
|
-
:
|
|
380
|
+
: models?.snapshotOf
|
|
381
|
+
? `\n\nThat file is ${models.snapshotOf} as read at startup.`
|
|
382
|
+
: "";
|
|
308
383
|
throw new Error(`${error}${origin}`);
|
|
309
384
|
}
|
|
310
|
-
|
|
311
|
-
|
|
385
|
+
await withModelRegistration(runtime, async () => {
|
|
386
|
+
for (const provider of options.providers ?? [])
|
|
387
|
+
runtime.registerNativeProvider(provider);
|
|
388
|
+
});
|
|
312
389
|
return runtime;
|
|
313
390
|
}
|
|
314
391
|
/**
|
|
@@ -417,6 +494,9 @@ export async function probeAuthSource(models, spec) {
|
|
|
417
494
|
const model = models.getModel(spec.slice(0, slash), spec.slice(slash + 1));
|
|
418
495
|
if (!model)
|
|
419
496
|
return undefined;
|
|
497
|
+
// A virtual selection authenticates the physical model after routing, not its catalog entry.
|
|
498
|
+
if (model.api === "pi-virtual")
|
|
499
|
+
return "virtual";
|
|
420
500
|
return (await models.getAuth(model))?.source;
|
|
421
501
|
}
|
|
422
502
|
/** Quick-fail probe for a just-stored API key. */
|