@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.
- 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 +137 -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 +154 -67
- package/dist/engines/pi/open.d.ts +29 -12
- package/dist/engines/pi/open.js +52 -21
- package/dist/engines/pi/openai-account-models.d.ts +35 -0
- package/dist/engines/pi/openai-account-models.js +116 -0
- 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,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
|
|
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
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
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 (
|
|
89
|
-
|
|
90
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
? {
|
|
194
|
-
:
|
|
195
|
-
? { modelsStore: await
|
|
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
|
-
|
|
212
|
-
?
|
|
213
|
-
:
|
|
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
|
|
238
|
-
* `pi update --models` runs. `
|
|
239
|
-
*
|
|
240
|
-
*
|
|
241
|
-
*
|
|
242
|
-
*
|
|
243
|
-
*
|
|
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(
|
|
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
|
|
252
|
-
const
|
|
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
|
|
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
|
-
|
|
311
|
-
runtime
|
|
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. */
|