@sigrun/sigrun 0.0.0-stage → 0.1.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/CHANGELOG.md +204 -0
- package/CONTEXT.md +163 -0
- package/LICENSE +21 -0
- package/README.md +691 -2
- package/THIRD_PARTY_NOTICES.md +28 -0
- package/dist/backup/marker.js +80 -0
- package/dist/backup/run-backup.js +339 -0
- package/dist/cli.js +952 -0
- package/dist/commands/expand.js +245 -0
- package/dist/commands/templates.js +272 -0
- package/dist/config.js +1146 -0
- package/dist/extension.d.ts +431 -0
- package/dist/harness/activity.js +42 -0
- package/dist/harness/ambient.js +261 -0
- package/dist/harness/auth.js +130 -0
- package/dist/harness/boot.js +865 -0
- package/dist/harness/builtins.js +191 -0
- package/dist/harness/event-bus.js +1203 -0
- package/dist/harness/health.js +30 -0
- package/dist/harness/identity/index.js +37 -0
- package/dist/harness/identity/invites.js +94 -0
- package/dist/harness/identity/json-store.js +108 -0
- package/dist/harness/identity/setup-code.js +81 -0
- package/dist/harness/identity/tokens.js +101 -0
- package/dist/harness/identity/users.js +93 -0
- package/dist/harness/interactions.js +283 -0
- package/dist/harness/loader.js +1236 -0
- package/dist/harness/memory-default.js +35 -0
- package/dist/harness/naming.js +193 -0
- package/dist/harness/packages.js +1002 -0
- package/dist/harness/providers/auth-bridge.js +60 -0
- package/dist/harness/providers/index.js +277 -0
- package/dist/harness/providers/models-store.js +105 -0
- package/dist/harness/providers/refresh.js +161 -0
- package/dist/harness/providers/scheduler.js +89 -0
- package/dist/harness/registry.js +9 -0
- package/dist/harness/reserved-sections.js +24 -0
- package/dist/harness/seam-host.js +239 -0
- package/dist/harness/sections/context-files.js +78 -0
- package/dist/harness/sections/model-guidance.js +77 -0
- package/dist/harness/settings.js +1053 -0
- package/dist/harness/stores.js +450 -0
- package/dist/harness/tools.js +500 -0
- package/dist/harness/workspace.js +73 -0
- package/dist/index.js +39 -0
- package/dist/jobs/docs.js +257 -0
- package/dist/jobs/fire.js +342 -0
- package/dist/jobs/index.js +228 -0
- package/dist/jobs/observe.js +165 -0
- package/dist/jobs/reconcile.js +245 -0
- package/dist/jobs/schedule.js +77 -0
- package/dist/jobs/spec.js +248 -0
- package/dist/jobs/task.js +190 -0
- package/dist/jobs/transfer.js +53 -0
- package/dist/serve.js +164 -0
- package/dist/server/auth/claim-lock.js +28 -0
- package/dist/server/auth/ip.js +51 -0
- package/dist/server/auth/login-limiter.js +52 -0
- package/dist/server/auth/loopback.js +47 -0
- package/dist/server/auth/pipeline.js +330 -0
- package/dist/server/auth/principal.js +26 -0
- package/dist/server/commands.js +198 -0
- package/dist/server/content-parts.js +142 -0
- package/dist/server/context.js +13 -0
- package/dist/server/conversation-handler.js +61 -0
- package/dist/server/conversation-ops.js +310 -0
- package/dist/server/events/hub.js +39 -0
- package/dist/server/events/types.js +8 -0
- package/dist/server/helpers.js +351 -0
- package/dist/server/host-bridge.js +131 -0
- package/dist/server/index.js +232 -0
- package/dist/server/notifications.js +185 -0
- package/dist/server/ownership.js +67 -0
- package/dist/server/readiness.js +36 -0
- package/dist/server/routes/auth.js +172 -0
- package/dist/server/routes/conversations.js +341 -0
- package/dist/server/routes/events.js +161 -0
- package/dist/server/routes/extension-routes.js +127 -0
- package/dist/server/routes/extensions.js +168 -0
- package/dist/server/routes/files.js +308 -0
- package/dist/server/routes/interactions.js +260 -0
- package/dist/server/routes/jobs.js +271 -0
- package/dist/server/routes/meta.js +146 -0
- package/dist/server/routes/metrics.js +219 -0
- package/dist/server/routes/onboarding.js +268 -0
- package/dist/server/routes/probes.js +53 -0
- package/dist/server/routes/providers.js +198 -0
- package/dist/server/routes/settings.js +48 -0
- package/dist/server/routes/stream.js +131 -0
- package/dist/server/routes/submissions.js +317 -0
- package/dist/server/routes/users.js +169 -0
- package/dist/server/schema-fixture.js +950 -0
- package/dist/server/session-meta.js +247 -0
- package/dist/server/static.js +169 -0
- package/dist/server/views.js +71 -0
- package/dist/skills/index.js +418 -0
- package/node_modules/@sigrun/shared/CHANGELOG.md +16 -0
- package/node_modules/@sigrun/shared/LICENSE +21 -0
- package/node_modules/@sigrun/shared/README.md +10 -0
- package/node_modules/@sigrun/shared/dist/access.d.ts +55 -0
- package/node_modules/@sigrun/shared/dist/access.js +72 -0
- package/node_modules/@sigrun/shared/dist/ambient.d.ts +111 -0
- package/node_modules/@sigrun/shared/dist/ambient.js +23 -0
- package/node_modules/@sigrun/shared/dist/commands.d.ts +75 -0
- package/node_modules/@sigrun/shared/dist/commands.js +25 -0
- package/node_modules/@sigrun/shared/dist/conversations.d.ts +31 -0
- package/node_modules/@sigrun/shared/dist/conversations.js +94 -0
- package/node_modules/@sigrun/shared/dist/interactions.d.ts +91 -0
- package/node_modules/@sigrun/shared/dist/interactions.js +21 -0
- package/node_modules/@sigrun/shared/dist/jobs.d.ts +100 -0
- package/node_modules/@sigrun/shared/dist/jobs.js +19 -0
- package/node_modules/@sigrun/shared/dist/json.d.ts +20 -0
- package/node_modules/@sigrun/shared/dist/json.js +13 -0
- package/node_modules/@sigrun/shared/dist/session-meta.d.ts +103 -0
- package/node_modules/@sigrun/shared/dist/session-meta.js +13 -0
- package/node_modules/@sigrun/shared/dist/settings.d.ts +401 -0
- package/node_modules/@sigrun/shared/dist/settings.js +19 -0
- package/node_modules/@sigrun/shared/dist/themes.d.ts +50 -0
- package/node_modules/@sigrun/shared/dist/themes.js +156 -0
- package/node_modules/@sigrun/shared/dist/turns.d.ts +102 -0
- package/node_modules/@sigrun/shared/dist/turns.js +166 -0
- package/node_modules/@sigrun/shared/dist/view-delta.d.ts +54 -0
- package/node_modules/@sigrun/shared/dist/view-delta.js +135 -0
- package/node_modules/@sigrun/shared/dist/views.d.ts +742 -0
- package/node_modules/@sigrun/shared/dist/views.js +38 -0
- package/node_modules/@sigrun/shared/package.json +83 -0
- package/package.json +90 -5
- package/webui/dist/assets/index-Rl0KhCLa.css +1 -0
- package/webui/dist/assets/index-rOiARZLF.js +50 -0
- package/webui/dist/index.html +37 -0
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The auth.json → pi-ai credential bridge (provider plugins Task 2).
|
|
3
|
+
*
|
|
4
|
+
* pi-ai's `Models` resolves auth through a `CredentialStore` (stored
|
|
5
|
+
* credentials own the provider; ambient env is consulted only when nothing
|
|
6
|
+
* is stored) plus an `AuthContext` (ambient `env`/`fileExists` access).
|
|
7
|
+
* Sigrun's credentials are operator-provisioned through auth.json (the
|
|
8
|
+
* 2026-10-13 config-surface ruling), so the bridge is READ-ONLY: reads
|
|
9
|
+
* project the file into `api_key` credentials, with the `"default"`
|
|
10
|
+
* entry scoped to the default model's provider only (one key must never
|
|
11
|
+
* leak across plugin providers), and every write path refuses loudly
|
|
12
|
+
* rather than pretending the harness owns credential lifecycle.
|
|
13
|
+
*
|
|
14
|
+
* The `CredentialStore`/`AuthContext`/credential shapes below are
|
|
15
|
+
* structural re-declarations of pi-ai's, narrowed to the `api_key` subset
|
|
16
|
+
* this bridge produces (sigrun stores no OAuth credentials). The narrowed
|
|
17
|
+
* shapes stay assignable to pi-ai's full interfaces at the `createModels`
|
|
18
|
+
* call site in `collectProviders`.
|
|
19
|
+
*/
|
|
20
|
+
import { DEFAULT_AUTH_KEY } from "../auth.js";
|
|
21
|
+
/** The refusal every write attempt gets: auth.json is operator-owned. */
|
|
22
|
+
export function operatorProvisionedError() {
|
|
23
|
+
return new Error("sigrun credentials are operator-provisioned; edit auth.json");
|
|
24
|
+
}
|
|
25
|
+
/**
|
|
26
|
+
* Adapt an {@link AuthStore} into a pi-ai `CredentialStore`.
|
|
27
|
+
*
|
|
28
|
+
* `read(providerId)` returns `{type:"api_key", key}` from auth.json.
|
|
29
|
+
* The `"default"` entry applies ONLY when `providerId` is the resolved
|
|
30
|
+
* default model's provider (`defaultProviderId()`, consulted per read,
|
|
31
|
+
* never cached); for it the order is `"default"` → own entry. Every
|
|
32
|
+
* other provider reads only its own entry; `undefined` when nothing
|
|
33
|
+
* matches. `list()` maps the file's provider keys. `modify`/`delete`
|
|
34
|
+
* throw: credentials are operator-provisioned.
|
|
35
|
+
*/
|
|
36
|
+
export function authStoreCredentialStore(auth, defaultProviderId) {
|
|
37
|
+
return {
|
|
38
|
+
read: async (providerId) => {
|
|
39
|
+
const file = auth.read();
|
|
40
|
+
if (providerId === defaultProviderId()) {
|
|
41
|
+
const fromDefault = file[DEFAULT_AUTH_KEY]?.apiKey;
|
|
42
|
+
if (fromDefault !== undefined) {
|
|
43
|
+
return { type: "api_key", key: fromDefault };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const fromProvider = file[providerId]?.apiKey;
|
|
47
|
+
if (fromProvider !== undefined) {
|
|
48
|
+
return { type: "api_key", key: fromProvider };
|
|
49
|
+
}
|
|
50
|
+
return undefined;
|
|
51
|
+
},
|
|
52
|
+
list: async () => Object.keys(auth.read()).map((providerId) => ({ providerId, type: "api_key" })),
|
|
53
|
+
modify: async () => {
|
|
54
|
+
throw operatorProvisionedError();
|
|
55
|
+
},
|
|
56
|
+
delete: async () => {
|
|
57
|
+
throw operatorProvisionedError();
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
}
|
|
@@ -0,0 +1,277 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider-contributor contract (spec §3 "Model providers").
|
|
3
|
+
*
|
|
4
|
+
* Extensions may contribute pi-ai `createProvider()` results plus a default
|
|
5
|
+
* model at boot; the core merges all contributions into one `Models` store
|
|
6
|
+
* before `Harness.open()`. Merge semantics are deterministic,
|
|
7
|
+
* **last-registered-wins** on provider-id/model-id collision, and every
|
|
8
|
+
* collision is recorded in a {@link ProviderMergeSink} — never silent — so
|
|
9
|
+
* `GET /v1/meta` (Task 9) can surface it.
|
|
10
|
+
*
|
|
11
|
+
* `SIGRUN_DEFAULT_MODEL` (or the file's `defaultModel`) overrides any
|
|
12
|
+
* contributor default. When no extension contributes a provider for the
|
|
13
|
+
* pinned namespace, the env-driven OpenAI-compat degenerate provider
|
|
14
|
+
* serves it — its credentials resolve from `auth.json` (the `"default"`
|
|
15
|
+
* key, or the resolved provider's own entry) with `OPENAI_API_KEY` as
|
|
16
|
+
* the compat fallback. Each contributor's §3 `health()` producer is
|
|
17
|
+
* retained on the collected result for the §5 meta `health.providers`
|
|
18
|
+
* aggregation (Task 9e); the degenerate provider reports `"ok"` by
|
|
19
|
+
* absence.
|
|
20
|
+
*/
|
|
21
|
+
import { existsSync } from "node:fs";
|
|
22
|
+
import { homedir } from "node:os";
|
|
23
|
+
import { createModels, createProvider, } from "@earendil-works/pi-ai/models";
|
|
24
|
+
import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
|
|
25
|
+
import { parseModelSpec } from "../../config.js";
|
|
26
|
+
import { DEFAULT_AUTH_KEY } from "../auth.js";
|
|
27
|
+
import { authStoreCredentialStore, } from "./auth-bridge.js";
|
|
28
|
+
export function createProviderMergeSink() {
|
|
29
|
+
const collisions = [];
|
|
30
|
+
return {
|
|
31
|
+
record: (collision) => {
|
|
32
|
+
collisions.push(collision);
|
|
33
|
+
console.warn(`provider collision: ${collision.contributor} replaces ${collision.previous} ` +
|
|
34
|
+
`provider "${collision.provider}"` +
|
|
35
|
+
(collision.modelIds.length > 0
|
|
36
|
+
? ` (models: ${collision.modelIds.join(", ")})`
|
|
37
|
+
: ""));
|
|
38
|
+
},
|
|
39
|
+
get collisions() {
|
|
40
|
+
return collisions;
|
|
41
|
+
},
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
/** Environment keys the degenerate env provider reads. */
|
|
45
|
+
export const OPENAI_API_KEY_ENV = "OPENAI_API_KEY";
|
|
46
|
+
export const OPENAI_BASE_URL_ENV = "OPENAI_BASE_URL";
|
|
47
|
+
/** Fallback when `OPENAI_BASE_URL` is unset: OpenAI's public API. */
|
|
48
|
+
export const DEFAULT_OPENAI_BASE_URL = "https://api.openai.com/v1";
|
|
49
|
+
/**
|
|
50
|
+
* Conservative defaults for an unknown OpenAI-compat model. Context
|
|
51
|
+
* management (Task 8) tunes its policy against the default model's window;
|
|
52
|
+
* these placeholders keep that deterministic before tuning.
|
|
53
|
+
*/
|
|
54
|
+
export const DEFAULT_OPENAI_CONTEXT_WINDOW = 128_000;
|
|
55
|
+
export const DEFAULT_OPENAI_MAX_TOKENS = 16_384;
|
|
56
|
+
/**
|
|
57
|
+
* Merge contributor providers into one `Models` store, then install the
|
|
58
|
+
* env-driven degenerate `openai` provider when no contributor contributed
|
|
59
|
+
* one. Deterministic order: contributors in array order, each provider
|
|
60
|
+
* upserting by id (last-registered-wins), then the env default — a designed
|
|
61
|
+
* fallback, never a collision.
|
|
62
|
+
*
|
|
63
|
+
* @param env raw environment (`process.env`); a set `SIGRUN_DEFAULT_MODEL`
|
|
64
|
+
* overrides every contributor default and names the model the env provider
|
|
65
|
+
* serves.
|
|
66
|
+
* @param sink receives collisions; defaults to a fresh sink. Boot passes the
|
|
67
|
+
* sink it returns so `GET /v1/meta` reads the same list.
|
|
68
|
+
*
|
|
69
|
+
* @throws Error naming `SIGRUN_DEFAULT_MODEL` when no default model is
|
|
70
|
+
* configured and no contributor proposed one — there would be no boot
|
|
71
|
+
* default at all.
|
|
72
|
+
*/
|
|
73
|
+
export function collectProviders(extensions, env, sink = createProviderMergeSink(), options) {
|
|
74
|
+
// The auth bridge: with a credential store present, auth.json owns
|
|
75
|
+
// stored credentials for every contributed + degenerate provider —
|
|
76
|
+
// except `"default"`, which applies only to the provider of the
|
|
77
|
+
// resolved default model (the closure reads `defaultModel` as it
|
|
78
|
+
// stands at each `read()`, so the post-loop resolution governs); the
|
|
79
|
+
// auth context keeps ambient env/file lookups on the operator-provided
|
|
80
|
+
// environment (the options.env seam first, then the raw env).
|
|
81
|
+
const authContext = {
|
|
82
|
+
env: async (name) => options?.env?.[name] ?? env[name],
|
|
83
|
+
fileExists: (path) => Promise.resolve(existsSync(path.startsWith("~") ? homedir() + path.slice(1) : path)),
|
|
84
|
+
};
|
|
85
|
+
const models = createModels({
|
|
86
|
+
...(options?.auth === undefined
|
|
87
|
+
? {}
|
|
88
|
+
: {
|
|
89
|
+
credentials: authStoreCredentialStore(options.auth, () => defaultModel?.provider),
|
|
90
|
+
}),
|
|
91
|
+
...(options?.modelsStore === undefined
|
|
92
|
+
? {}
|
|
93
|
+
: { modelsStore: options.modelsStore }),
|
|
94
|
+
authContext,
|
|
95
|
+
});
|
|
96
|
+
let defaultModel;
|
|
97
|
+
const ownership = new Map();
|
|
98
|
+
const readinessTrackers = new Map();
|
|
99
|
+
const healthProducers = [];
|
|
100
|
+
for (const extension of extensions) {
|
|
101
|
+
// §3 producers are collected from every contributor that defines one,
|
|
102
|
+
// independent of its provider contributions (a health-only contributor
|
|
103
|
+
// still reports its surface's health).
|
|
104
|
+
if (extension.health !== undefined) {
|
|
105
|
+
healthProducers.push({
|
|
106
|
+
name: extension.name,
|
|
107
|
+
health: extension.health,
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
for (const contribution of extension.providers ?? []) {
|
|
111
|
+
const provider = contribution.provider;
|
|
112
|
+
const previous = ownership.get(provider.id);
|
|
113
|
+
if (previous !== undefined) {
|
|
114
|
+
const replaced = modelIdsOf(models, provider.id);
|
|
115
|
+
sink.record({
|
|
116
|
+
kind: "provider",
|
|
117
|
+
provider: provider.id,
|
|
118
|
+
// Record every model the replaced provider served, not just the ids the
|
|
119
|
+
// incoming one re-declares — a contributor replacing `{m1,m2}` with `{m2}`
|
|
120
|
+
// silently drops `m1`; meta must surface the full set that changed.
|
|
121
|
+
modelIds: replaced,
|
|
122
|
+
previous,
|
|
123
|
+
contributor: extension.name,
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
ownership.set(provider.id, extension.name);
|
|
127
|
+
// Task 6: the OWNING contributor's tracker is authoritative — a
|
|
128
|
+
// replacing contribution without one clears a replaced
|
|
129
|
+
// contributor's tracker (last-registered-wins, like ownership).
|
|
130
|
+
if (contribution.readiness !== undefined) {
|
|
131
|
+
readinessTrackers.set(provider.id, contribution.readiness);
|
|
132
|
+
}
|
|
133
|
+
else {
|
|
134
|
+
readinessTrackers.delete(provider.id);
|
|
135
|
+
}
|
|
136
|
+
models.setProvider(provider);
|
|
137
|
+
if (contribution.defaultModel !== undefined) {
|
|
138
|
+
defaultModel = contribution.defaultModel;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// A configured default model (env `SIGRUN_DEFAULT_MODEL` / deprecated
|
|
143
|
+
// alias `SIGRUN_MODEL`, or the file's `defaultModel` — the caller
|
|
144
|
+
// restates the resolution into the env seam) overrides any contributor
|
|
145
|
+
// default.
|
|
146
|
+
const envSpec = env.SIGRUN_DEFAULT_MODEL?.trim() || env.SIGRUN_MODEL?.trim();
|
|
147
|
+
if (envSpec) {
|
|
148
|
+
defaultModel = parseModelSpec(envSpec);
|
|
149
|
+
}
|
|
150
|
+
if (defaultModel === undefined) {
|
|
151
|
+
throw new Error("SIGRUN_DEFAULT_MODEL or settings.json defaultModel is required when no provider contributor proposes a default model " +
|
|
152
|
+
"(format: provider/model-id, e.g. openai/gpt-4o)");
|
|
153
|
+
}
|
|
154
|
+
// Degenerate fallback: when no extension contributed a provider for the
|
|
155
|
+
// pinned default model's namespace, register the env-driven OpenAI-compat
|
|
156
|
+
// provider there (any namespace), so a zero-extension boot still serves its
|
|
157
|
+
// pin instead of leaving the catalog empty and failing loud at submit.
|
|
158
|
+
if (!models.getProvider(defaultModel.provider)) {
|
|
159
|
+
models.setProvider(envDegenerateProvider(env, defaultModel.provider, defaultModel.modelId, options));
|
|
160
|
+
}
|
|
161
|
+
return {
|
|
162
|
+
models,
|
|
163
|
+
defaultModel,
|
|
164
|
+
healthProducers,
|
|
165
|
+
providerOwnership: ownership,
|
|
166
|
+
readinessTrackers,
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* Resolve the env-driven degenerate provider's API key (ruling 6). Order:
|
|
171
|
+
* auth.json `"default".apiKey`, then the resolved provider's own auth.json
|
|
172
|
+
* entry, then the `OPENAI_API_KEY` env var (compat fallback, still
|
|
173
|
+
* honored). Key material is never logged — this function returns it
|
|
174
|
+
* opaquely.
|
|
175
|
+
*/
|
|
176
|
+
function resolveDegenerateApiKey(env, providerId, auth) {
|
|
177
|
+
if (auth !== undefined) {
|
|
178
|
+
const file = auth.read();
|
|
179
|
+
const fromDefault = file[DEFAULT_AUTH_KEY]?.apiKey;
|
|
180
|
+
if (fromDefault !== undefined)
|
|
181
|
+
return fromDefault;
|
|
182
|
+
const fromProvider = file[providerId]?.apiKey;
|
|
183
|
+
if (fromProvider !== undefined)
|
|
184
|
+
return fromProvider;
|
|
185
|
+
}
|
|
186
|
+
return env[OPENAI_API_KEY_ENV]?.trim() || undefined;
|
|
187
|
+
}
|
|
188
|
+
function modelIdsOf(models, providerId) {
|
|
189
|
+
return models.getModels(providerId).map((model) => model.id);
|
|
190
|
+
}
|
|
191
|
+
function modelIdsOfProvider(provider) {
|
|
192
|
+
try {
|
|
193
|
+
return provider.getModels().map((model) => model.id);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
// Best-effort collision detail (matches `Models.getModels` semantics).
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
/**
|
|
201
|
+
* The degenerate env-driven OpenAI-compat provider: exactly one model — the
|
|
202
|
+
* id the default-model spec selected — streamed over `openai-completions`,
|
|
203
|
+
* with auth resolved from `auth.json` (`"default"` key or the provider's
|
|
204
|
+
* own entry) falling back to `OPENAI_API_KEY`, and the endpoint from
|
|
205
|
+
* `OPENAI_BASE_URL` over `options.baseUrl` (the file's
|
|
206
|
+
* `defaultProvider.baseUrl`, projected by `openHarness`) over the OpenAI
|
|
207
|
+
* public default (verified shape, App C: `{ auth: { apiKey, baseUrl } }`).
|
|
208
|
+
* Credentials resolve to `undefined` when no key exists anywhere, so the
|
|
209
|
+
* provider registers unconfigured and requests fail loud instead of boot
|
|
210
|
+
* failing.
|
|
211
|
+
*
|
|
212
|
+
* `cost` is required by the `Model` shape but unknown for an arbitrary
|
|
213
|
+
* gateway model, so it is zeroed; usage tracking stays structural.
|
|
214
|
+
*/
|
|
215
|
+
export function envDegenerateProvider(env, providerId, modelId, options) {
|
|
216
|
+
const apiKey = resolveDegenerateApiKey(env, providerId, options?.auth);
|
|
217
|
+
const baseUrl = env[OPENAI_BASE_URL_ENV]?.trim() ||
|
|
218
|
+
options?.baseUrl ||
|
|
219
|
+
DEFAULT_OPENAI_BASE_URL;
|
|
220
|
+
const model = {
|
|
221
|
+
id: modelId,
|
|
222
|
+
name: modelId,
|
|
223
|
+
api: "openai-completions",
|
|
224
|
+
provider: providerId,
|
|
225
|
+
baseUrl,
|
|
226
|
+
input: ["text", "image"],
|
|
227
|
+
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
|
228
|
+
reasoning: false,
|
|
229
|
+
contextWindow: DEFAULT_OPENAI_CONTEXT_WINDOW,
|
|
230
|
+
maxTokens: DEFAULT_OPENAI_MAX_TOKENS,
|
|
231
|
+
};
|
|
232
|
+
return createProvider({
|
|
233
|
+
id: providerId,
|
|
234
|
+
name: `OpenAI-compatible (env, ${providerId})`,
|
|
235
|
+
baseUrl,
|
|
236
|
+
auth: {
|
|
237
|
+
apiKey: {
|
|
238
|
+
name: "OpenAI-compatible API key (auth.json or OPENAI_API_KEY)",
|
|
239
|
+
resolve: async () => apiKey === undefined
|
|
240
|
+
? undefined
|
|
241
|
+
: { auth: { apiKey, baseUrl }, source: OPENAI_API_KEY_ENV },
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
models: [model],
|
|
245
|
+
api: openAICompletionsApi(),
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
/** Error thrown when a referenced model is served by no contributed provider. */
|
|
249
|
+
export class UnservedModelError extends Error {
|
|
250
|
+
ref;
|
|
251
|
+
/** Every served `provider/model-id` at the time of the check. */
|
|
252
|
+
available;
|
|
253
|
+
constructor(ref, available) {
|
|
254
|
+
super(`Model ${ref.provider}/${ref.modelId} is not available. Available models: ` +
|
|
255
|
+
`${available.length > 0 ? available.join(", ") : "(none)"}.`);
|
|
256
|
+
this.name = "UnservedModelError";
|
|
257
|
+
this.ref = ref;
|
|
258
|
+
this.available = available;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
/**
|
|
262
|
+
* Resolve a model reference against the merged catalog, failing loud with
|
|
263
|
+
* the available ids when no contributor serves it — never a silent
|
|
264
|
+
* fallback. Task 9 maps this to the submit-time 4xx; the harness's own
|
|
265
|
+
* generation path fails independently (`no_model`, run unanswered).
|
|
266
|
+
*/
|
|
267
|
+
export function requireServedModel(models, ref) {
|
|
268
|
+
const model = models.getModel(ref.provider, ref.modelId);
|
|
269
|
+
if (model === undefined) {
|
|
270
|
+
throw new UnservedModelError(ref, listServedModelIds(models));
|
|
271
|
+
}
|
|
272
|
+
return model;
|
|
273
|
+
}
|
|
274
|
+
/** Every served chat model as `provider/model-id`, in catalog order. */
|
|
275
|
+
export function listServedModelIds(models) {
|
|
276
|
+
return models.getModels().map((model) => `${model.provider}/${model.id}`);
|
|
277
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The file-backed pi-ai `ModelsStore` (provider plugins Task 3).
|
|
3
|
+
*
|
|
4
|
+
* Dynamic providers (`refreshModels`) publish their catalogs through the
|
|
5
|
+
* store `createModels` was built with; pi-ai writes every publication via
|
|
6
|
+
* `write` and restores it via `read` at the next boot's offline init. This
|
|
7
|
+
* implementation persists one JSON file per provider under
|
|
8
|
+
* `<dir>/models/<sanitized-provider-id>.json`, so a restart serves the
|
|
9
|
+
* last known catalog without network access.
|
|
10
|
+
*
|
|
11
|
+
* Poison resistance: a catalog file that fails to parse (or parses without
|
|
12
|
+
* a `models` array) is logged — the path, never the content — deleted, and
|
|
13
|
+
* reported as absent. A poisoned catalog degrades to "fetch it again",
|
|
14
|
+
* never a boot crash.
|
|
15
|
+
*/
|
|
16
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
/** Characters a flattened provider id keeps; everything else becomes `_`. */
|
|
19
|
+
const SAFE_FILENAME = /[^A-Za-z0-9-]/g;
|
|
20
|
+
/** Monotonic scratch-file counter: concurrent writes never share a tmp name. */
|
|
21
|
+
let scratchCounter = 0;
|
|
22
|
+
/**
|
|
23
|
+
* Flatten a provider id into a safe filename component. Dots are NOT in the
|
|
24
|
+
* safe set, so no flattened id can form `.`, `..`, or a hidden file, and a
|
|
25
|
+
* `/`-shaped id (e.g. `acme/gw`) stays inside the models dir as `acme_gw`.
|
|
26
|
+
* Flattening is many-to-one: provider ids differing only in unsafe
|
|
27
|
+
* characters share a file — sigrun provider ids are slugs, so this never
|
|
28
|
+
* happens in practice, and the collision costs one re-fetch, not data loss.
|
|
29
|
+
*/
|
|
30
|
+
function flattenProviderId(providerId) {
|
|
31
|
+
const flat = providerId.replace(SAFE_FILENAME, "_");
|
|
32
|
+
return flat.length > 0 ? flat : "_";
|
|
33
|
+
}
|
|
34
|
+
/** Is a parsed catalog file shaped like a `ModelsStoreEntry`? */
|
|
35
|
+
function isEntryShape(parsed) {
|
|
36
|
+
return (typeof parsed === "object" &&
|
|
37
|
+
parsed !== null &&
|
|
38
|
+
Array.isArray(parsed.models));
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Build a `ModelsStore` rooted at `dir`: one JSON file per provider under
|
|
42
|
+
* `<dir>/models/`.
|
|
43
|
+
*
|
|
44
|
+
* Writes are atomic — the serialized entry lands in a uniquely named
|
|
45
|
+
* `<file>.<pid>-<n>.tmp` first, then `rename` swaps it in — so a crash
|
|
46
|
+
* mid-write can never leave a half-written catalog, and failed writes
|
|
47
|
+
* unlink their scratch file. The `AbortSignal` parameters of pi-ai's store
|
|
48
|
+
* contract are accepted but unused: operations are tiny local-file writes
|
|
49
|
+
* and reads, not cancellable network work.
|
|
50
|
+
*/
|
|
51
|
+
export function fileModelsStore(dir) {
|
|
52
|
+
const modelsDir = join(dir, "models");
|
|
53
|
+
const providerPath = (providerId) => join(modelsDir, `${flattenProviderId(providerId)}.json`);
|
|
54
|
+
/** Warn (path, never content), delete the poisoned file, report absent. */
|
|
55
|
+
const discardCorrupt = async (path, why) => {
|
|
56
|
+
console.warn(`models-store: corrupt catalog file ${path} (${why}); deleting — it will be re-fetched`);
|
|
57
|
+
await rm(path, { force: true });
|
|
58
|
+
};
|
|
59
|
+
return {
|
|
60
|
+
read: async (providerId) => {
|
|
61
|
+
const path = providerPath(providerId);
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
raw = await readFile(path, "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch (error) {
|
|
67
|
+
if (typeof error === "object" &&
|
|
68
|
+
error !== null &&
|
|
69
|
+
error.code === "ENOENT") {
|
|
70
|
+
return undefined;
|
|
71
|
+
}
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
let parsed;
|
|
75
|
+
try {
|
|
76
|
+
parsed = JSON.parse(raw);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
await discardCorrupt(path, "not valid JSON");
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
if (!isEntryShape(parsed)) {
|
|
83
|
+
await discardCorrupt(path, "models must be an array");
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
return parsed;
|
|
87
|
+
},
|
|
88
|
+
write: async (providerId, entry) => {
|
|
89
|
+
await mkdir(modelsDir, { recursive: true });
|
|
90
|
+
const path = providerPath(providerId);
|
|
91
|
+
const scratch = `${path}.${process.pid}-${scratchCounter++}.tmp`;
|
|
92
|
+
try {
|
|
93
|
+
await writeFile(scratch, JSON.stringify(entry), "utf8");
|
|
94
|
+
await rename(scratch, path);
|
|
95
|
+
}
|
|
96
|
+
catch (error) {
|
|
97
|
+
await rm(scratch, { force: true });
|
|
98
|
+
throw error;
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
delete: async (providerId) => {
|
|
102
|
+
await rm(providerPath(providerId), { force: true });
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Core-owned refresh policy (spec §"Core: refresh policy"): WHEN pi-ai's
|
|
3
|
+
* refresh runs. The protocol is pi-ai's — `Models.refresh({providers,
|
|
4
|
+
* allowNetwork, signal})` drives every dynamic provider, per-provider
|
|
5
|
+
* failures land in `ModelsRefreshResult.errors` without rejecting, and a
|
|
6
|
+
* failed refresh retains the previous catalog by contract. This module
|
|
7
|
+
* owns the two policy call sites:
|
|
8
|
+
*
|
|
9
|
+
* 1. {@link bootRefresh} — one network-allowed refresh started by boot
|
|
10
|
+
* fire-and-tracked (never awaited on the boot path — it runs
|
|
11
|
+
* concurrently with `Harness.open` and listen), bounded by a deadline
|
|
12
|
+
* (default {@link DEFAULT_REFRESH_TIMEOUT_MS}) so a wedged upstream
|
|
13
|
+
* cannot stall deploy readiness. Timeout/failure only warns
|
|
14
|
+
* (`console.warn` — repo convention, no shared logger): the server
|
|
15
|
+
* starts, readiness reports the provider's state, the interval
|
|
16
|
+
* refresh heals.
|
|
17
|
+
* 2. {@link resolveServedModelRefreshing} — refresh-on-miss at the submit
|
|
18
|
+
* call site: ONE bounded refresh of the named provider + re-resolve
|
|
19
|
+
* when `requireServedModel` reports a miss, so a model added upstream
|
|
20
|
+
* five minutes after the last interval tick is immediately usable
|
|
21
|
+
* instead of a 4xx. Still absent → the ORIGINAL
|
|
22
|
+
* {@link UnservedModelError} rethrows (the available-ids snapshot the
|
|
23
|
+
* caller surfaces is the one from the first check). The auto-namer's
|
|
24
|
+
* model resolution deliberately does NOT go through this wrapper —
|
|
25
|
+
* naming is a background lane (spec ruling).
|
|
26
|
+
*/
|
|
27
|
+
import { requireServedModel, UnservedModelError } from "./index.js";
|
|
28
|
+
/**
|
|
29
|
+
* The refresh deadline default (boot and refresh-on-miss share it): long
|
|
30
|
+
* enough for a real catalog fetch, short enough that a wedged upstream
|
|
31
|
+
* cannot stall deploy readiness or hang a submit.
|
|
32
|
+
*/
|
|
33
|
+
export const DEFAULT_REFRESH_TIMEOUT_MS = 30_000;
|
|
34
|
+
/**
|
|
35
|
+
* The fallback readiness snapshot (no derivation wired): provider id →
|
|
36
|
+
* `"ready"` for every catalog provider. Boot always wires
|
|
37
|
+
* `snapshotReadiness`; this keeps the option structurally optional.
|
|
38
|
+
*/
|
|
39
|
+
export function refreshReadinessSnapshot(models) {
|
|
40
|
+
const snapshot = new Map();
|
|
41
|
+
for (const provider of models.getProviders()) {
|
|
42
|
+
snapshot.set(provider.id, "ready");
|
|
43
|
+
}
|
|
44
|
+
return snapshot;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* One network-allowed refresh of every dynamic provider, bounded by
|
|
48
|
+
* `opts.timeoutMs`. Never rejects and never blocks boot beyond the
|
|
49
|
+
* deadline: an aborted (deadline/cancelled) or failed refresh logs ONE
|
|
50
|
+
* `console.warn` naming what did not complete — the catalogs simply stay
|
|
51
|
+
* at their last-known state. Boot starts this fire-and-tracked; the
|
|
52
|
+
* tracked promise rides on `OpenedHarness.bootRefresh`.
|
|
53
|
+
*/
|
|
54
|
+
export async function bootRefresh(models, opts) {
|
|
55
|
+
await refreshBounded(models, { label: "boot refresh", ...opts });
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* One bounded single-provider refresh (core hardening W3 Task 6, spec
|
|
59
|
+
* §7.6): the exact refresh {@link resolveServedModelRefreshing} runs on
|
|
60
|
+
* a miss, exposed for the provider ROUTES (`POST
|
|
61
|
+
* /v1/providers/:id/refresh` and the credential write paths) — never a
|
|
62
|
+
* second policy. Accepts the same {@link RefreshBound} fields the boot
|
|
63
|
+
* and interval refreshes take (boot wiring passes its readiness
|
|
64
|
+
* observer so a flipped provider publishes `providers.changed`); every
|
|
65
|
+
* field is optional with the policy defaults applying. Like every
|
|
66
|
+
* policy refresh: never rejects — failures/aborts warn once and the
|
|
67
|
+
* catalog holds its last-known state.
|
|
68
|
+
*/
|
|
69
|
+
export async function refreshProvider(models, providerId, opts = {}) {
|
|
70
|
+
await refreshBounded(models, {
|
|
71
|
+
label: `provider refresh (${providerId})`,
|
|
72
|
+
...opts,
|
|
73
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS,
|
|
74
|
+
providers: [providerId],
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* `requireServedModel` with ONE refresh+retry: on an
|
|
79
|
+
* {@link UnservedModelError}, refresh the named provider (bounded; its
|
|
80
|
+
* failures only warn — a failed refresh is not a submit error) and
|
|
81
|
+
* re-resolve. Still absent after the retry → the ORIGINAL miss rethrows,
|
|
82
|
+
* available ids attached. The submit call site wraps its resolution in
|
|
83
|
+
* this; the auto-namer's resolution stays on bare `requireServedModel`.
|
|
84
|
+
*/
|
|
85
|
+
export async function resolveServedModelRefreshing(models, ref, opts = {}) {
|
|
86
|
+
let miss;
|
|
87
|
+
try {
|
|
88
|
+
return requireServedModel(models, ref);
|
|
89
|
+
}
|
|
90
|
+
catch (error) {
|
|
91
|
+
if (!(error instanceof UnservedModelError))
|
|
92
|
+
throw error;
|
|
93
|
+
miss = error;
|
|
94
|
+
}
|
|
95
|
+
await refreshBounded(models, {
|
|
96
|
+
label: `refresh-on-miss (${ref.provider})`,
|
|
97
|
+
timeoutMs: opts.timeoutMs ?? DEFAULT_REFRESH_TIMEOUT_MS,
|
|
98
|
+
...(opts.signal === undefined ? {} : { signal: opts.signal }),
|
|
99
|
+
providers: [ref.provider],
|
|
100
|
+
});
|
|
101
|
+
try {
|
|
102
|
+
return requireServedModel(models, ref);
|
|
103
|
+
}
|
|
104
|
+
catch {
|
|
105
|
+
// Still absent: rethrow the ORIGINAL miss. `requireServedModel`
|
|
106
|
+
// only ever throws UnservedModelError, and the available-ids list
|
|
107
|
+
// the caller surfaces is deliberately the first check's snapshot.
|
|
108
|
+
throw miss;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The shared bounded refresh: `allowNetwork: true`, an abort controller
|
|
113
|
+
* armed at `timeoutMs` (unref'd — a bare boot never holds the process
|
|
114
|
+
* open) and OR-ed with any caller signal, errors/aborts collapsed into
|
|
115
|
+
* one `console.warn`. `refresh()` itself reports per-provider failures
|
|
116
|
+
* through its result rather than rejecting; the catch is the belt for the
|
|
117
|
+
* unexpected throw — either way this resolves, never rejects.
|
|
118
|
+
*/
|
|
119
|
+
async function refreshBounded(models, opts) {
|
|
120
|
+
const controller = new AbortController();
|
|
121
|
+
const signal = opts.signal === undefined
|
|
122
|
+
? controller.signal
|
|
123
|
+
: AbortSignal.any([opts.signal, controller.signal]);
|
|
124
|
+
const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
|
|
125
|
+
timer.unref?.();
|
|
126
|
+
try {
|
|
127
|
+
const result = await models.refresh({
|
|
128
|
+
allowNetwork: true,
|
|
129
|
+
...(opts.providers === undefined ? {} : { providers: opts.providers }),
|
|
130
|
+
signal,
|
|
131
|
+
});
|
|
132
|
+
if (result.aborted) {
|
|
133
|
+
console.warn(`${opts.label} aborted before completing (deadline ${opts.timeoutMs}ms) — ` +
|
|
134
|
+
`provider catalogs stay at their last-known state`);
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
if (result.errors.size > 0) {
|
|
138
|
+
const failed = [...result.errors]
|
|
139
|
+
.map(([id, error]) => `${id} (${error instanceof Error ? error.message : String(error)})`)
|
|
140
|
+
.join("; ");
|
|
141
|
+
console.warn(`${opts.label} failed for ${result.errors.size} provider(s): ${failed}`);
|
|
142
|
+
}
|
|
143
|
+
// The readiness observation runs for EVERY completed refresh
|
|
144
|
+
// (success or partial failure) so a provider that flips either way
|
|
145
|
+
// is observed; a refresh that threw before completing holds the
|
|
146
|
+
// previous snapshot (the catch below skips the observer).
|
|
147
|
+
try {
|
|
148
|
+
const snapshot = (opts.snapshotReadiness ?? refreshReadinessSnapshot)(models);
|
|
149
|
+
opts.onReadinessChange?.(snapshot);
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
console.warn(`${opts.label}: readiness observation failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
catch (error) {
|
|
156
|
+
console.warn(`${opts.label} failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
157
|
+
}
|
|
158
|
+
finally {
|
|
159
|
+
clearTimeout(timer);
|
|
160
|
+
}
|
|
161
|
+
}
|