my-durable 0.0.12 → 0.0.14

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.
@@ -170,6 +170,11 @@ server.listen(config.port, config.host, () => {
170
170
  if (a.family === "IPv4" && !a.internal)
171
171
  console.log(`LAN: http://${a.address}:${config.port}`);
172
172
  console.log(`background: dpi device start | stop | status | install (service)`);
173
+ {
174
+ const providers = [...new Set(store.modelList.map((m) => m.provider))];
175
+ if (providers.length) console.log(`providers: ${providers.join(", ")} (pi auth/models.json + env)`);
176
+ for (const s of store.pi.skipped) console.log(` custom provider ${s.id} skipped: ${s.reason}`);
177
+ }
173
178
  if (store.keyless)
174
179
  console.log(
175
180
  `no provider key: the model list is the built-in catalog; set OPENROUTER_API_KEY in ${config.envFile} to chat`,
@@ -0,0 +1,206 @@
1
+ /**
2
+ * The device is pi: it reuses the SAME credentials and custom providers the pi
3
+ * CLI already has on this machine — the fix for "pi đầy đủ key, sao durable
4
+ * lại không". Nothing is copied or re-entered:
5
+ *
6
+ * ~/.pi/agent/auth.json provider credentials pi saved via /login
7
+ * (api_key and oauth entries, keyed by provider id)
8
+ * ~/.pi/agent/models.json custom providers ({baseUrl, api, apiKey?, models})
9
+ *
10
+ * PI_AGENT_DIR moves both (same variable pi honors). The device never WRITES
11
+ * pi's files: its credential store is a read-only view, so a headless daemon
12
+ * cannot corrupt an interactive login.
13
+ */
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { homedir } from "node:os";
16
+ import { join } from "node:path";
17
+ import { anthropicMessagesApi } from "@earendil-works/pi-ai/api/anthropic-messages.lazy";
18
+ import { openAICompletionsApi } from "@earendil-works/pi-ai/api/openai-completions.lazy";
19
+ import { openAIResponsesApi } from "@earendil-works/pi-ai/api/openai-responses.lazy";
20
+ import {
21
+ createProvider,
22
+ envApiKeyAuth,
23
+ type ApiKeyCredential,
24
+ type Credential,
25
+ type CredentialInfo,
26
+ type CredentialStore,
27
+ type Provider,
28
+ } from "@earendil-works/pi-ai";
29
+
30
+ /** pi's agent directory on this machine (PI_AGENT_DIR overrides, like pi). */
31
+ export function piAgentDir(env: NodeJS.ProcessEnv = process.env): string {
32
+ return env.PI_AGENT_DIR ?? join(homedir(), ".pi", "agent");
33
+ }
34
+
35
+ /** auth.json: one type-tagged credential per provider id. */
36
+ export function readPiAuth(dir: string): Record<string, Credential> {
37
+ return readJson(join(dir, "auth.json"));
38
+ }
39
+
40
+ /** models.json custom providers: {providers: {name: {baseUrl, api, apiKey?, models[]}}}. */
41
+ export function readPiModelsJson(dir: string): PiModelsJson {
42
+ return readJson(join(dir, "models.json"));
43
+ }
44
+
45
+ function readJson(path: string): Record<string, never> | never {
46
+ try {
47
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
48
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
49
+ } catch {
50
+ return {}; // absent or unreadable: the device simply has no pi credentials
51
+ }
52
+ }
53
+
54
+ export interface PiModelsJson {
55
+ providers?: Record<
56
+ string,
57
+ {
58
+ baseUrl?: string;
59
+ api?: string;
60
+ apiKey?: string;
61
+ name?: string;
62
+ models?: PiCustomModel[];
63
+ }
64
+ >;
65
+ }
66
+
67
+ export interface PiCustomModel {
68
+ id: string;
69
+ name?: string;
70
+ contextWindow?: number;
71
+ reasoning?: boolean;
72
+ cost?: { input?: number; output?: number };
73
+ [key: string]: unknown;
74
+ }
75
+
76
+ /**
77
+ * Read-only credential view over pi's files, honoring pi's precedence
78
+ * (auth.json credential, then the models.json apiKey, then provider env vars —
79
+ * env resolution stays inside pi-ai). Writes are refused by returning the
80
+ * current value: the daemon must not touch an interactive login.
81
+ */
82
+ export function piCredentialStore(auth: Record<string, Credential>, modelsJson: PiModelsJson): CredentialStore {
83
+ const of = (providerId: string): Credential | undefined => {
84
+ const fromAuth = auth[providerId];
85
+ if (fromAuth) return fromAuth;
86
+ const key = modelsJson.providers?.[providerId]?.apiKey;
87
+ if (key) {
88
+ const credential: ApiKeyCredential = { type: "api_key", key };
89
+ return credential;
90
+ }
91
+ return undefined;
92
+ };
93
+ return {
94
+ async read(providerId) {
95
+ return of(providerId);
96
+ },
97
+ async list(): Promise<readonly CredentialInfo[]> {
98
+ const ids = new Set([...Object.keys(auth), ...Object.keys(modelsJson.providers ?? {})]);
99
+ return [...ids]
100
+ .map(of)
101
+ .filter((c): c is Credential => !!c)
102
+ .map((c, i) => ({ providerId: [...new Set([...Object.keys(auth), ...Object.keys(modelsJson.providers ?? {})])][i]!, type: c.type }));
103
+ },
104
+ async modify(providerId, fn) {
105
+ const current = of(providerId);
106
+ const next = await fn(current);
107
+ return next === current ? current : current; // read-only: never persist
108
+ },
109
+ async delete() {
110
+ /* read-only */
111
+ },
112
+ };
113
+ }
114
+
115
+ /** Provider ids the device has SOME credential for (auth.json or models.json). */
116
+ export function credentialedProviders(auth: Record<string, Credential>, modelsJson: PiModelsJson): string[] {
117
+ return [...new Set([...Object.keys(auth), ...Object.keys(modelsJson.providers ?? {})])].filter((id) => {
118
+ if (auth[id]) return true;
119
+ return !!modelsJson.providers?.[id]?.apiKey;
120
+ });
121
+ }
122
+
123
+ /** pi models.json api id → pi-ai API implementation. Unknown ids are skipped. */
124
+ const API_IMPLS: Record<string, () => ReturnType<typeof openAICompletionsApi>> = {
125
+ "openai-completions": () => openAICompletionsApi(),
126
+ "openai-responses": () => openAIResponsesApi() as never,
127
+ "anthropic-messages": () => anthropicMessagesApi() as never,
128
+ };
129
+
130
+ /**
131
+ * Build pi-ai providers for the models.json custom entries. An entry with an
132
+ * api we cannot serve (or no models) is skipped and REPORTED, not silently
133
+ * dropped — the operator should learn why a model is missing.
134
+ */
135
+ export function piCustomProviders(
136
+ modelsJson: PiModelsJson,
137
+ onSkip?: (id: string, reason: string) => void,
138
+ ): Provider[] {
139
+ const out: Provider[] = [];
140
+ for (const [id, entry] of Object.entries(modelsJson.providers ?? {})) {
141
+ const reason = !entry.baseUrl
142
+ ? "no baseUrl"
143
+ : !entry.api || !API_IMPLS[entry.api]
144
+ ? `unsupported api ${String(entry.api)}`
145
+ : !entry.models?.length
146
+ ? "no models"
147
+ : "";
148
+ if (reason) {
149
+ onSkip?.(id, reason);
150
+ continue;
151
+ }
152
+ out.push(
153
+ createProvider({
154
+ id,
155
+ name: entry.name ?? id,
156
+ baseUrl: entry.baseUrl,
157
+ auth: { apiKey: envApiKeyAuth(`${id} API key`, [`${id.toUpperCase().replace(/-/g, "_")}_API_KEY`]) },
158
+ models: entry.models!.map((m) => ({
159
+ type: "chat",
160
+ id: m.id,
161
+ name: m.name ?? m.id,
162
+ provider: id,
163
+ api: entry.api!,
164
+ baseUrl: entry.baseUrl,
165
+ contextWindow: m.contextWindow ?? 128000,
166
+ reasoning: m.reasoning ?? false,
167
+ input: ["text"],
168
+ cost: { input: m.cost?.input ?? 0, output: m.cost?.output ?? 0 },
169
+ ...(m as Record<string, unknown>),
170
+ })) as never,
171
+ api: API_IMPLS[entry.api!]!(),
172
+ }),
173
+ );
174
+ }
175
+ return out;
176
+ }
177
+
178
+ /** Everything pi brings on this machine: credentials + custom providers. */
179
+ export interface PiConfig {
180
+ dir: string;
181
+ auth: Record<string, Credential>;
182
+ modelsJson: PiModelsJson;
183
+ credentials: CredentialStore;
184
+ customProviders: Provider[];
185
+ /** Provider ids with a usable credential (auth.json or models.json key). */
186
+ credentialed: string[];
187
+ skipped: Array<{ id: string; reason: string }>;
188
+ }
189
+
190
+ /** Load pi's configuration; absent files mean simply "nothing from pi". */
191
+ export function loadPiConfig(env: NodeJS.ProcessEnv = process.env): PiConfig {
192
+ const dir = piAgentDir(env);
193
+ const auth = existsSync(join(dir, "auth.json")) ? readPiAuth(dir) : {};
194
+ const modelsJson = existsSync(join(dir, "models.json")) ? readPiModelsJson(dir) : {};
195
+ const skipped: Array<{ id: string; reason: string }> = [];
196
+ const customProviders = piCustomProviders(modelsJson, (id, reason) => skipped.push({ id, reason }));
197
+ return {
198
+ dir,
199
+ auth,
200
+ modelsJson,
201
+ credentials: piCredentialStore(auth, modelsJson),
202
+ customProviders,
203
+ credentialed: credentialedProviders(auth, modelsJson),
204
+ skipped,
205
+ };
206
+ }
@@ -41,6 +41,8 @@ import { createRequire } from "node:module";
41
41
  import { BACKGROUND_CONTEXT as ctx, withAbortSignal } from "@earendil-works/chord/context";
42
42
  import { createModels } from "@earendil-works/pi-ai/models";
43
43
  import { openrouterProvider } from "@earendil-works/pi-ai/providers/openrouter";
44
+ import { builtinProviders } from "@earendil-works/pi-ai/providers/all";
45
+ import { loadPiConfig } from "./pi-config.js";
44
46
  import { curatedModels, defaultModelId } from "./model-catalog.js";
45
47
  import { fauxModels, installFaux } from "./faux.js";
46
48
  import {
@@ -161,7 +163,9 @@ const decodeCursor = (cursor?: string) => {
161
163
  };
162
164
 
163
165
  export class Store {
164
- readonly models = createModels();
166
+ /** pi's credentials + custom providers on THIS machine (auth.json / models.json). */
167
+ readonly pi = loadPiConfig();
168
+ readonly models = createModels({ credentials: this.pi.credentials });
165
169
  readonly opened = new Map<string, Opened>();
166
170
  readonly errors = new Map<string, string>();
167
171
  readonly catalog: DatabaseSync;
@@ -199,6 +203,12 @@ export class Store {
199
203
  private constructor(readonly options: StoreOptions) {
200
204
  if (options.provider === "faux") installFaux(this.models);
201
205
  else {
206
+ // The device is pi: register EVERY built-in provider (the same registry
207
+ // the pi CLI uses), then keep OpenRouter narrowed to the curated list.
208
+ // Credentials arrive through pi's own files via the credential store;
209
+ // env keys still work through each provider.
210
+ for (const provider of builtinProviders()) this.models.setProvider(provider);
211
+ for (const provider of this.pi.customProviders) this.models.setProvider(provider);
202
212
  const provider = openrouterProvider();
203
213
  const catalog = provider.getModels();
204
214
  for (const model of curatedModels)
@@ -311,30 +321,82 @@ export class Store {
311
321
  reasoning: false,
312
322
  cost: { input: 0, output: 0 },
313
323
  }));
314
- // getAvailable only lists AUTHENTICATED providers — no OPENROUTER_API_KEY
315
- // means an empty list even though the built-in catalog needs no key. Fall
316
- // back to it (static metadata) so picking a model is never blocked; the
317
- // keyless flag tells the client chat will need the key.
318
- const hasChatFields = (
319
- m: unknown,
320
- ): m is { id: string; contextWindow: number; reasoning: boolean; cost: { input: number; output: number } } =>
321
- typeof m === "object" && m !== null && "contextWindow" in m && "reasoning" in m;
322
- let available: ReadonlyArray<{ id: string; contextWindow: number; reasoning: boolean; cost: { input: number; output: number } }> =
323
- await this.models.getAvailable("openrouter", { signal: AbortSignal.timeout(10000) }).catch(() => []);
324
- if (!available.length) {
325
- this.modelsKeyless = true;
326
- // The provider's static catalog (no key, no network): chat models only —
327
- // image/classifier entries carry no context window.
328
- available = this.models
329
- .getAllModels("openrouter")
330
- .filter((m) => hasChatFields(m))
331
- .map((m) => ({ id: m.id, contextWindow: m.contextWindow, reasoning: m.reasoning, cost: { input: m.cost.input, output: m.cost.output } }));
324
+ // The device serves every provider pi can authenticate on this machine:
325
+ // OpenRouter (the curated default) plus each provider pi holds a credential
326
+ // for (auth.json / models.json). Asking per-provider keeps providers with
327
+ // no credential out of the auth check entirely.
328
+ const ids = new Set(["openrouter", ...this.pi.credentialed]);
329
+ const out: ModelInfo[] = [];
330
+ let authenticated = false;
331
+ for (const id of ids) {
332
+ const available = await this.models
333
+ .getAvailable(id, { signal: AbortSignal.timeout(10_000) })
334
+ .catch(() => [] as ReadonlyArray<{ id: string }>);
335
+ if (!available.length) continue;
336
+ authenticated = true;
337
+ if (id === "openrouter") {
338
+ // The curated list, with live context/pricing when the key allows it.
339
+ out.push(
340
+ ...curatedModels.flatMap((c) => {
341
+ const m = available.find((x) => x.id === c.id) as
342
+ | { id: string; contextWindow?: number; reasoning?: boolean; cost?: { input: number; output: number } }
343
+ | undefined;
344
+ return m
345
+ ? [{
346
+ id: c.id,
347
+ provider: "openrouter",
348
+ name: c.name,
349
+ tier: c.tier,
350
+ icon: c.icon,
351
+ contextWindow: m.contextWindow ?? 128000,
352
+ reasoning: m.reasoning ?? false,
353
+ cost: { input: m.cost?.input ?? 0, output: m.cost?.output ?? 0 },
354
+ }]
355
+ : [];
356
+ }),
357
+ );
358
+ } else {
359
+ for (const m of available as ReadonlyArray<{
360
+ id: string;
361
+ name?: string;
362
+ contextWindow?: number;
363
+ reasoning?: boolean;
364
+ cost?: { input: number; output: number };
365
+ }>)
366
+ out.push({
367
+ id: m.id,
368
+ provider: id,
369
+ name: m.name ?? m.id,
370
+ tier: "·",
371
+ icon: "lab.png",
372
+ contextWindow: m.contextWindow ?? 128000,
373
+ reasoning: m.reasoning ?? false,
374
+ cost: { input: m.cost?.input ?? 0, output: m.cost?.output ?? 0 },
375
+ });
376
+ }
332
377
  }
333
- return curatedModels.flatMap((c) => {
334
- const m = available.find((m) => m.id === c.id);
335
- return m
336
- ? [
337
- {
378
+ if (!authenticated) {
379
+ this.modelsKeyless = true;
380
+ // No credential anywhere: the built-in curated catalog (static metadata)
381
+ // so picking a model is never blocked; chat needs a key.
382
+ const hasChatFields = (m: unknown): m is { id: string; contextWindow: number; reasoning: boolean; cost: { input: number; output: number } } =>
383
+ typeof m === "object" && m !== null && "contextWindow" in m && "reasoning" in m;
384
+ out.push(
385
+ ...this.models
386
+ .getAllModels("openrouter")
387
+ .filter((m) => hasChatFields(m) && curatedModels.some((c) => c.id === m.id))
388
+ .map(
389
+ (m) =>
390
+ hasChatFields(m)
391
+ ? { id: m.id, contextWindow: m.contextWindow, reasoning: m.reasoning, cost: { input: m.cost.input, output: m.cost.output } }
392
+ : undefined,
393
+ )
394
+ .filter(
395
+ (m): m is { id: string; contextWindow: number; reasoning: boolean; cost: { input: number; output: number } } => !!m,
396
+ )
397
+ .map((m) => {
398
+ const c = curatedModels.find((x) => x.id === m.id)!;
399
+ return {
338
400
  id: m.id,
339
401
  provider: "openrouter",
340
402
  name: c.name,
@@ -343,10 +405,11 @@ export class Store {
343
405
  contextWindow: m.contextWindow,
344
406
  reasoning: m.reasoning,
345
407
  cost: { input: m.cost.input, output: m.cost.output },
346
- },
347
- ]
348
- : [];
349
- });
408
+ } satisfies ModelInfo;
409
+ }),
410
+ );
411
+ }
412
+ return out;
350
413
  }
351
414
 
352
415
  routedModel(model?: ModelRef): ModelRef {
@@ -354,6 +417,10 @@ export class Store {
354
417
  const id = fauxModels.some((m) => m.id === model?.modelId) ? model!.modelId : fauxModels[0].id;
355
418
  return { provider: "faux", modelId: id };
356
419
  }
420
+ // A model the device actually serves (pi provider or curated openrouter)
421
+ // keeps its provider; everything else routes to the OpenRouter default.
422
+ if (model && this.modelList.some((m) => m.provider === model.provider && m.id === model.modelId))
423
+ return { provider: model.provider, modelId: model.modelId };
357
424
  const exact = model?.provider === "openrouter" ? model.modelId : `${model?.provider}/${model?.modelId}`;
358
425
  return {
359
426
  provider: "openrouter",
@@ -365,12 +432,9 @@ export class Store {
365
432
  const m = model as ModelRef | undefined;
366
433
  if (!m || typeof m.provider !== "string" || typeof m.modelId !== "string")
367
434
  throw invalid("model must be { provider, modelId }");
368
- const known =
369
- this.options.provider === "faux"
370
- ? m.provider === "faux" && fauxModels.some((f) => f.id === m.modelId)
371
- : m.provider === "openrouter" &&
372
- curatedModels.some((c) => c.id === m.modelId) &&
373
- !!this.models.getModel(m.provider, m.modelId);
435
+ // Known = listed in this device's catalog (faux models, curated OpenRouter,
436
+ // or any provider pi authenticated here). The list IS the contract.
437
+ const known = this.modelList.some((x) => x.provider === m.provider && x.id === m.modelId);
374
438
  if (!known) throw invalid(`Unknown model ${m.provider}/${m.modelId}. Choose one from GET /v1/device models`);
375
439
  return { provider: m.provider, modelId: m.modelId };
376
440
  }
package/bin/update.d.mts CHANGED
@@ -22,3 +22,5 @@ export function daemonControl(
22
22
  dpiPath: string,
23
23
  port: number,
24
24
  ): { running: () => Promise<boolean>; stop: () => Promise<number>; start: () => Promise<number> };
25
+ /** Below this the app cannot run at all (node:sqlite). The only hard block. */
26
+ export const REQUIRED_NODE_FLOOR: string;
package/bin/update.mjs CHANGED
@@ -31,6 +31,12 @@ export function compareVersions(a, b) {
31
31
  return 0;
32
32
  }
33
33
 
34
+ /**
35
+ * The floor we KNOW breaks: below 22.5.0 there is no node:sqlite, and the
36
+ * device dies at boot. This — and only this — is a hard block.
37
+ */
38
+ export const REQUIRED_NODE_FLOOR = "22.5.0";
39
+
34
40
  /**
35
41
  * Does `current` satisfy `required` when required looks like ">=22.19.0" /
36
42
  * ">=22" / "22.x"? Only the forms our own package.json can carry are supported;
@@ -94,15 +100,22 @@ export async function runUpdate(deps) {
94
100
  return 0;
95
101
  }
96
102
 
97
- // Refuse before touching npm: a warned-about engine is a broken install later.
103
+ // pi's philosophy, which we follow: engines metadata is ADVICE. pi declares
104
+ // node >=22.19.0 too, npm only warns, and nothing enforces it at runtime —
105
+ // so an update proceeds with a visible warning. We hard-block ONLY below the
106
+ // floor we know is broken (no node:sqlite ⇒ the device cannot even boot).
98
107
  const required = pkg.engines?.node;
99
- if (required && !satisfiesEngine(deps.nodeVersion, required)) {
108
+ if (compareVersions(deps.nodeVersion, REQUIRED_NODE_FLOOR) < 0) {
100
109
  printErr(
101
- `dpi update: this release needs Node ${required}, you have ${deps.nodeVersion}.`,
110
+ `dpi update: Node ${deps.nodeVersion} is too old — durable needs Node >= ${REQUIRED_NODE_FLOOR} (node:sqlite).`,
102
111
  );
103
- printErr(" Upgrade Node first (nvm install --lts / winget upgrade OpenJS.NodeJS.LTS), then retry.");
112
+ printErr(" Upgrade Node (nvm install --lts / winget upgrade OpenJS.NodeJS.LTS) and retry.");
104
113
  return 1;
105
114
  }
115
+ if (required && !satisfiesEngine(deps.nodeVersion, required)) {
116
+ print(`note: this release declares Node ${required}, you have ${deps.nodeVersion}.`);
117
+ print(` like pi, it installs and runs anyway; upgrade Node if anything misbehaves.`);
118
+ }
106
119
 
107
120
  // Windows: the daemon (and a TUI) hold files inside the package folder, so npm
108
121
  // cannot rename it. Stop the daemon, install, then bring it back.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "my-durable",
3
- "version": "0.0.12",
3
+ "version": "0.0.14",
4
4
  "description": "Durable Pi \u2014 the durable agent network: hub web UI, device backend daemon, TUI admin and the shared SDK, packaged as one runnable monorepo install",
5
5
  "license": "MIT",
6
6
  "repository": {