okengine 0.11.0 → 0.11.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/package.json +1 -1
  2. package/site/content/docs/elements/ai.mdx +22 -1
  3. package/site/content/docs/elements/store.mdx +3 -1
  4. package/site/content/docs/elements/vault.mdx +19 -11
  5. package/site/content/docs/get-started/installation.mdx +7 -1
  6. package/site/content/docs/recipes/llama-cpp.mdx +10 -9
  7. package/site/content/docs/reference/cli.md +6 -2
  8. package/site/content/docs/reference/environment-variables.mdx +9 -9
  9. package/src/cli/ai-setup/ai-setup.test.ts +3 -1
  10. package/src/cli/ai-setup/apply.ts +61 -1
  11. package/src/cli/ask-seed.test.ts +4 -3
  12. package/src/cli/ask-seed.ts +5 -6
  13. package/src/cli/client-add.test.ts +2 -1
  14. package/src/cli/dev.test.ts +116 -0
  15. package/src/cli/dev.ts +107 -18
  16. package/src/cli/project-state.test.ts +50 -0
  17. package/src/cli/project-state.ts +123 -0
  18. package/src/cli/vault-cmd.test.ts +47 -18
  19. package/src/cli/vault-cmd.ts +2 -1
  20. package/src/compiler/extract.ts +12 -1
  21. package/src/console/server/console.test.ts +3 -1
  22. package/src/console/server/operator-db.test.ts +48 -17
  23. package/src/console/server/operator-db.ts +5 -1
  24. package/src/docker/derive.ts +24 -3
  25. package/src/docker/docker.test.ts +4 -2
  26. package/src/docker/index.ts +1 -0
  27. package/src/docker/recipes/index.ts +1 -0
  28. package/src/docker/recipes/llama-cpp.ts +20 -4
  29. package/src/drivers/ai-openai-compatible.ts +15 -3
  30. package/src/drivers/vault-builtin.test.ts +50 -42
  31. package/src/elements/ai/declare.ts +73 -3
  32. package/src/elements/ai/errors.test.ts +35 -0
  33. package/src/elements/ai/errors.ts +139 -0
  34. package/src/elements/ai/eval.ts +26 -1
  35. package/src/elements/ai/runtime.ts +140 -80
  36. package/src/elements/ai/tools.test.ts +1 -1
  37. package/src/elements/ai.test.ts +99 -2
  38. package/src/elements/ai.ts +11 -1
  39. package/src/elements/gate/config.ts +13 -3
  40. package/src/elements/gate/declare.ts +1 -1
  41. package/src/elements/gate/strategies.ts +2 -12
  42. package/src/elements/index.ts +2 -0
  43. package/src/elements/store/index-boot.test.ts +23 -6
  44. package/src/elements/store/resource.test.ts +38 -19
  45. package/src/elements/store/sql-session.test.ts +55 -58
  46. package/src/elements/vault/builtin-adapter.test.ts +115 -58
  47. package/src/elements/vault/builtin-adapter.ts +241 -47
  48. package/src/elements/vault/chaos-child.ts +424 -0
  49. package/src/elements/vault/chaos.test.ts +651 -0
  50. package/src/elements/vault/resilience.ts +6 -1
  51. package/src/elements/vault/security-checklist.test.ts +10 -8
  52. package/src/elements/vault/storage.ts +130 -27
  53. package/src/elements/vault/test-helpers.ts +368 -0
  54. package/src/elements/vault.ts +6 -0
  55. package/src/index.ts +2 -0
  56. package/src/kernel/app-auth.ts +98 -0
  57. package/src/kernel/app.ts +120 -78
  58. package/src/kernel/auto-registry.test.ts +52 -1
  59. package/src/kernel/boot.test.ts +4 -18
  60. package/src/kernel/element-registries.ts +19 -4
  61. package/src/kernel/errors.ts +3 -3
  62. package/src/kernel/fx.test.ts +25 -0
  63. package/src/kernel/fx.ts +4 -1
  64. package/src/manifest/types.ts +4 -0
  65. package/src/release/build-lib.ts +3 -0
  66. package/src/shared/lazy-src.ts +79 -0
  67. package/src/test/create-test-app.ts +16 -11
  68. package/src/test/reset-element-registries.ts +17 -9
@@ -1,9 +1,14 @@
1
1
  /**
2
2
  * Durable Console operators + sessions in Postgres schema `oke_console`
3
3
  * (PGlite under `.oke/console-pg` when no DATABASE_URL).
4
+ *
5
+ * One warmed in-memory PGlite is shared for the whole file (cold WASM once)
6
+ * and injected into {@link openConsolePersistence}. Each test still gets a
7
+ * unique cwd for the signing-secret file; SQL rows are truncated between
8
+ * tests so reopen/hydration assertions stay isolated.
4
9
  */
5
10
 
6
- import { afterEach, describe, expect, test } from "bun:test";
11
+ import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "bun:test";
7
12
  import { mkdtemp, rm } from "node:fs/promises";
8
13
  import { tmpdir } from "node:os";
9
14
  import { join } from "node:path";
@@ -11,6 +16,7 @@ import { createOperator } from "../../auth/operator.ts";
11
16
  import { issueSession, verifyAccess } from "../../auth/sessions.ts";
12
17
  import { AUTH_TABLES } from "../../auth/tables.ts";
13
18
  import { connectPglite } from "../../drivers/pglite.ts";
19
+ import type { SqlConnection } from "../../drivers/types.ts";
14
20
  import { bootConsoleApp, createConsoleApp } from "./app.ts";
15
21
  import {
16
22
  CONSOLE_PG_SCHEMA,
@@ -19,8 +25,37 @@ import {
19
25
  resolveConsoleSecret,
20
26
  } from "./operator-db.ts";
21
27
 
28
+ /** Console operator-plane tables wiped between shared-PGlite tests. */
29
+ const CONSOLE_TABLES = [
30
+ consoleTable(AUTH_TABLES.refreshTokens),
31
+ consoleTable(AUTH_TABLES.sessions),
32
+ consoleTable(AUTH_TABLES.operatorRoles),
33
+ consoleTable(AUTH_TABLES.operatorSsoLinks),
34
+ consoleTable(AUTH_TABLES.operatorCredentials),
35
+ consoleTable(AUTH_TABLES.operators),
36
+ ] as const;
37
+
22
38
  describe("console operator persistence", () => {
23
39
  const dirs: string[] = [];
40
+ let sharedSql: SqlConnection;
41
+
42
+ // Bun reports beforeAll timeouts as "beforeEach/afterEach hook timed out".
43
+ // Cold WASM under suite contention can exceed the default 5s hook budget.
44
+ beforeAll(async () => {
45
+ sharedSql = await connectPglite({ url: "memory://console-operator-shared" });
46
+ }, 15_000);
47
+
48
+ afterAll(async () => {
49
+ await sharedSql.close();
50
+ });
51
+
52
+ beforeEach(async () => {
53
+ try {
54
+ await sharedSql.exec(`TRUNCATE ${CONSOLE_TABLES.join(", ")} RESTART IDENTITY CASCADE`);
55
+ } catch {
56
+ // Schema not migrated yet — first openConsolePersistence creates it.
57
+ }
58
+ });
24
59
 
25
60
  afterEach(async () => {
26
61
  await Promise.all(dirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
@@ -39,7 +74,7 @@ describe("console operator persistence", () => {
39
74
  const cwd = await mkdtemp(join(tmpdir(), "oke-console-ops-"));
40
75
  dirs.push(cwd);
41
76
 
42
- const first = await openConsolePersistence(cwd);
77
+ const first = await openConsolePersistence(cwd, { connection: sharedSql });
43
78
  expect(first.operators.operators.size).toBe(0);
44
79
  const op = await createOperator(first.operators, {
45
80
  email: "ops@example.com",
@@ -49,7 +84,7 @@ describe("console operator persistence", () => {
49
84
  await first.persistOperator(op.id);
50
85
  await first.close();
51
86
 
52
- const second = await openConsolePersistence(cwd);
87
+ const second = await openConsolePersistence(cwd, { connection: sharedSql });
53
88
  expect(second.operators.operators.size).toBe(1);
54
89
  expect(second.operators.operators.get(op.id)?.email).toBe("ops@example.com");
55
90
  expect(second.operators.credentials.has(op.id)).toBe(true);
@@ -81,7 +116,7 @@ describe("console operator persistence", () => {
81
116
  const cwd = await mkdtemp(join(tmpdir(), "oke-console-sess-"));
82
117
  dirs.push(cwd);
83
118
 
84
- const first = await openConsolePersistence(cwd);
119
+ const first = await openConsolePersistence(cwd, { connection: sharedSql });
85
120
  const op = await createOperator(first.operators, {
86
121
  email: "ops@example.com",
87
122
  name: "Ops",
@@ -96,7 +131,7 @@ describe("console operator persistence", () => {
96
131
  await first.persistSessions();
97
132
  await first.close();
98
133
 
99
- const second = await openConsolePersistence(cwd);
134
+ const second = await openConsolePersistence(cwd, { connection: sharedSql });
100
135
  expect(second.sessions.sessions.size).toBe(1);
101
136
  const claims = await verifyAccess(second.sessions, second.secret, issued.accessToken);
102
137
  expect(claims.sub).toBe(op.id);
@@ -107,7 +142,7 @@ describe("console operator persistence", () => {
107
142
  const cwd = await mkdtemp(join(tmpdir(), "oke-console-roundtrip-"));
108
143
  dirs.push(cwd);
109
144
 
110
- const firstPersist = await openConsolePersistence(cwd);
145
+ const firstPersist = await openConsolePersistence(cwd, { connection: sharedSql });
111
146
  const first = createConsoleApp({
112
147
  cwd,
113
148
  secret: firstPersist.secret,
@@ -142,7 +177,7 @@ describe("console operator persistence", () => {
142
177
  await firstPersist.close();
143
178
  }
144
179
 
145
- const secondPersist = await openConsolePersistence(cwd);
180
+ const secondPersist = await openConsolePersistence(cwd, { connection: sharedSql });
146
181
  const second = createConsoleApp({
147
182
  cwd,
148
183
  secret: secondPersist.secret,
@@ -173,7 +208,7 @@ describe("console operator persistence", () => {
173
208
  test("stale Bearer on setup.status does not 401", async () => {
174
209
  const cwd = await mkdtemp(join(tmpdir(), "oke-console-stale-"));
175
210
  dirs.push(cwd);
176
- const persistence = await openConsolePersistence(cwd);
211
+ const persistence = await openConsolePersistence(cwd, { connection: sharedSql });
177
212
  const op = await createOperator(persistence.operators, {
178
213
  email: "ops@example.com",
179
214
  name: "Ops",
@@ -211,10 +246,7 @@ describe("console operator persistence", () => {
211
246
  test("tables live in oke_console schema, not public", async () => {
212
247
  const cwd = await mkdtemp(join(tmpdir(), "oke-console-schema-"));
213
248
  dirs.push(cwd);
214
- const shared = await connectPglite({
215
- url: `memory://console-schema-${crypto.randomUUID()}`,
216
- });
217
- const opened = await openConsolePersistence(cwd, { connection: shared });
249
+ const opened = await openConsolePersistence(cwd, { connection: sharedSql });
218
250
  try {
219
251
  const op = await createOperator(opened.operators, {
220
252
  email: "schema@example.com",
@@ -223,13 +255,13 @@ describe("console operator persistence", () => {
223
255
  });
224
256
  await opened.persistOperator(op.id);
225
257
 
226
- const inConsole = await shared.query(
258
+ const inConsole = await sharedSql.query(
227
259
  `SELECT email FROM ${consoleTable(AUTH_TABLES.operators)} WHERE id = ?`,
228
260
  [op.id],
229
261
  );
230
262
  expect(inConsole[0]?.["email"]).toBe("schema@example.com");
231
263
 
232
- const schemas = await shared.query(
264
+ const schemas = await sharedSql.query(
233
265
  `SELECT table_schema
234
266
  FROM information_schema.tables
235
267
  WHERE table_name = ?`,
@@ -237,8 +269,7 @@ describe("console operator persistence", () => {
237
269
  );
238
270
  expect(schemas.map((r) => r["table_schema"])).toEqual([CONSOLE_PG_SCHEMA]);
239
271
  } finally {
240
- // connection was injected — close the shared handle ourselves
241
- await shared.close();
272
+ await opened.close();
242
273
  }
243
- }, 15_000);
274
+ });
244
275
  });
@@ -29,7 +29,11 @@ import type { SqlConnection } from "../../drivers/types.ts";
29
29
 
30
30
  /** Relative paths under project cwd. */
31
31
  export const CONSOLE_OKE_DIR = ".oke";
32
- /** Signing secret file (still local — not a DB row). */
32
+ /**
33
+ * Console session HMAC signing secret (local file under `.oke/`).
34
+ * Not a Vault contract — operators/sessions must sign even when Vault is sealed.
35
+ * Override with `OKE_CONSOLE_SECRET` when set.
36
+ */
33
37
  export const CONSOLE_SECRET_NAME = "console.secret";
34
38
  /** PGlite datadir when no Postgres URL is configured. */
35
39
  export const CONSOLE_PGLITE_DIR = "console-pg";
@@ -18,6 +18,7 @@ import { buildCaddyfile } from "./recipes/caddy.ts";
18
18
  import {
19
19
  buildLlamaCppEntrypoint,
20
20
  LLAMA_CPP_ENTRYPOINT_FILE,
21
+ LLAMA_CPP_ENTRYPOINT_HOST_PATH,
21
22
  llamaCpp,
22
23
  } from "./recipes/llama-cpp.ts";
23
24
  import { buildPgDogToml, buildPgDogUsersToml, PGDOG_CONFIG_DIR } from "./recipes/pgdog.ts";
@@ -84,8 +85,10 @@ export function deriveInfrastructure(options: DeriveOptions): DeriveResult {
84
85
  }
85
86
 
86
87
  /**
87
- * Emit `llama-entrypoint.sh` when the AI role is llama.cpp so first boot can
88
- * Hub-pull then serve single-model (router `--docker-repo` hangs on b10290+).
88
+ * Emit `.oke/llama-entrypoint.py` (path relative to compose dir) when the AI
89
+ * role is llama.cpp so first boot can Hub-pull then serve single-model
90
+ * (router `--docker-repo` hangs on b10290+). Kept out of `docker/` so app
91
+ * trees stay TypeScript-only.
89
92
  *
90
93
  * @param specs - Normalised services
91
94
  */
@@ -94,7 +97,7 @@ function llamaCppEntrypointFiles(specs: DeriveResult["specs"]): GeneratedFile[]
94
97
  if (!ai || !llamaCpp.match(ai.image)) return [];
95
98
  return [
96
99
  {
97
- path: LLAMA_CPP_ENTRYPOINT_FILE,
100
+ path: LLAMA_CPP_ENTRYPOINT_HOST_PATH,
98
101
  content: buildLlamaCppEntrypoint(),
99
102
  },
100
103
  ];
@@ -173,6 +176,7 @@ export async function writeDerivedFiles(
173
176
  mkdirSync(root, { recursive: true });
174
177
  const keep = new Set(result.files.map((f) => f.path));
175
178
  pruneStaleGenerated(root, keep);
179
+ pruneLlamaEntrypoint(root, keep);
176
180
  for (const file of result.files) {
177
181
  const path = join(root, file.path);
178
182
  mkdirSync(dirname(path), { recursive: true });
@@ -196,11 +200,28 @@ const PRUNE_ROOT_FILES = new Set([
196
200
  "compose.all.yml",
197
201
  "compose.prod.yml",
198
202
  "Caddyfile",
203
+ // Legacy: entrypoint used to land in `docker/`; now `.oke/` only.
199
204
  LLAMA_CPP_ENTRYPOINT_FILE,
200
205
  "pgdog.toml",
201
206
  "users.toml",
202
207
  ]);
203
208
 
209
+ /**
210
+ * Drop `.oke/llama-entrypoint.py` when the stack no longer uses llama.cpp.
211
+ *
212
+ * @param root - Compose directory (`docker/`)
213
+ * @param keep - Relative paths that will be rewritten
214
+ */
215
+ function pruneLlamaEntrypoint(root: string, keep: ReadonlySet<string>): void {
216
+ if (keep.has(LLAMA_CPP_ENTRYPOINT_HOST_PATH)) return;
217
+ const abs = join(root, LLAMA_CPP_ENTRYPOINT_HOST_PATH);
218
+ try {
219
+ unlinkSync(abs);
220
+ } catch {
221
+ // absent or not a file
222
+ }
223
+ }
224
+
204
225
  /**
205
226
  * Remove previously generated artefacts that the current layout no longer emits.
206
227
  * Never touches user overrides or `.env.docker`.
@@ -376,7 +376,7 @@ describe("image recipes", () => {
376
376
  expect(applied.environment?.OKE_AI_MODEL).toBeUndefined();
377
377
  expect(applied.entrypoint).toEqual(["/usr/bin/python3", "/oke/llama-entrypoint.py"]);
378
378
  expect(applied.command).toBeUndefined();
379
- expect(applied.volumes?.some((v) => v.includes("llama-entrypoint.py"))).toBe(true);
379
+ expect(applied.volumes?.some((v) => v.includes("../.oke/llama-entrypoint.py"))).toBe(true);
380
380
  expect(applied.extraPorts).toBeUndefined();
381
381
  expect(llamaCpp.url(spec, { host: "127.0.0.1", port: 8080, ...spec.credentials })).toBe(
382
382
  "http://127.0.0.1:8080/v1",
@@ -866,11 +866,13 @@ describe("deriveInfrastructure", () => {
866
866
  expect(yml).toContain(LLAMA_CPP_IMAGE);
867
867
  expect(yml).toContain("127.0.0.1:8080:8080");
868
868
  expect(yml).toContain("llama-entrypoint.py");
869
+ expect(yml).toContain("../.oke/llama-entrypoint.py");
869
870
  expect(yml).toContain("/oke/llama-entrypoint.py");
870
871
  expect(yml).not.toMatch(/ports:\s*\n\s*-\s*"?8080:8080"?/);
871
872
  expect(result.stackEnv.OKE_AI_URL).toBe("http://127.0.0.1:8080/v1");
872
873
  expect(result.stackEnv.OKE_AI_MODEL).toBe("gemma4:e4b-q4_K_M");
873
- const entry = result.files.find((f) => f.path === "llama-entrypoint.py")!.content;
874
+ const entry = result.files.find((f) => f.path === "../.oke/llama-entrypoint.py")!.content;
875
+ expect(entry).toContain("Generated by oke");
874
876
  expect(entry).toContain("llama download");
875
877
  expect(entry).toContain("org.cncf.model.filepath");
876
878
  expect(entry).toContain("CNCF / Hub registry pull");
@@ -105,6 +105,7 @@ export {
105
105
  llamaCpp,
106
106
  buildLlamaCppEntrypoint,
107
107
  LLAMA_CPP_ENTRYPOINT_FILE,
108
+ LLAMA_CPP_ENTRYPOINT_HOST_PATH,
108
109
  LLAMA_CPP_ENTRYPOINT_MOUNT,
109
110
  LLAMA_CPP_IMAGE,
110
111
  LLAMA_CPP_MIN_SAFE_BUILD,
@@ -68,6 +68,7 @@ export {
68
68
  export {
69
69
  buildLlamaCppEntrypoint,
70
70
  LLAMA_CPP_ENTRYPOINT_FILE,
71
+ LLAMA_CPP_ENTRYPOINT_HOST_PATH,
71
72
  LLAMA_CPP_ENTRYPOINT_MOUNT,
72
73
  LLAMA_CPP_IMAGE,
73
74
  LLAMA_CPP_MIN_SAFE_BUILD,
@@ -25,18 +25,34 @@ export const LLAMA_CPP_MIN_SAFE_BUILD = 8146;
25
25
  /** Pinned default image — verified ≥ {@link LLAMA_CPP_MIN_SAFE_BUILD}; never `latest`. */
26
26
  export const LLAMA_CPP_IMAGE = "ghcr.io/ggml-org/llama.cpp:server-b10290";
27
27
 
28
- /** Compose-relative entrypoint that downloads then serves one Hub model. */
28
+ /**
29
+ * Basename of the generated entrypoint.
30
+ *
31
+ * Lives under `.oke/` (gitignored generated artefacts), never beside app
32
+ * TypeScript — compose mounts it via {@link LLAMA_CPP_ENTRYPOINT_HOST_PATH}.
33
+ */
29
34
  export const LLAMA_CPP_ENTRYPOINT_FILE = "llama-entrypoint.py";
30
35
 
36
+ /**
37
+ * Host path relative to the compose directory (`docker/`) for the generated
38
+ * entrypoint. Resolves to `<project>/.oke/llama-entrypoint.py`.
39
+ */
40
+ export const LLAMA_CPP_ENTRYPOINT_HOST_PATH = `../.oke/${LLAMA_CPP_ENTRYPOINT_FILE}`;
41
+
31
42
  /** In-container path for {@link LLAMA_CPP_ENTRYPOINT_FILE}. */
32
43
  export const LLAMA_CPP_ENTRYPOINT_MOUNT = "/oke/llama-entrypoint.py";
33
44
 
34
45
  /**
35
- * Python entrypoint: pull curated Docker Hub `ai/` GGUF into the cache volume,
36
- * then `exec` `llama-server -m <gguf> --alias <id>` (single-model, not router).
46
+ * Python entrypoint (runs inside the llama.cpp image, which ships `python3`):
47
+ * pull curated Docker Hub `ai/` GGUF into the cache volume, then `exec`
48
+ * `llama-server -m <gguf> --alias <id>` (single-model, not router).
49
+ *
50
+ * Source of truth is this TypeScript string — the `.py` file is a generated
51
+ * runtime artefact under `.oke/`, not app code.
37
52
  */
38
53
  export function buildLlamaCppEntrypoint(): string {
39
54
  return `#!/usr/bin/env python3
55
+ # Generated by oke — do not edit. Source: okengine docker llama.cpp recipe.
40
56
  """OKE llama.cpp entrypoint — Hub-pull curated ai/ model, then serve single-model."""
41
57
 
42
58
  from __future__ import annotations
@@ -266,7 +282,7 @@ export const llamaCpp: ImageRecipe = {
266
282
  entrypoint: ["/usr/bin/python3", LLAMA_CPP_ENTRYPOINT_MOUNT],
267
283
  volumes: [
268
284
  `${s.serviceName}-models:/root/.cache`,
269
- `./${LLAMA_CPP_ENTRYPOINT_FILE}:${LLAMA_CPP_ENTRYPOINT_MOUNT}:ro`,
285
+ `${LLAMA_CPP_ENTRYPOINT_HOST_PATH}:${LLAMA_CPP_ENTRYPOINT_MOUNT}:ro`,
270
286
  ],
271
287
  publishBind: "127.0.0.1",
272
288
  healthcheck: {
@@ -23,6 +23,18 @@ import type {
23
23
  /** Default OpenAI cloud base — apiKey is required for this origin. */
24
24
  export const OPENAI_COMPAT_DEFAULT_BASE = "https://api.openai.com/v1";
25
25
 
26
+ /**
27
+ * Throw a provider error with an HTTP `status` field for retry classification.
28
+ *
29
+ * @param message - Error message
30
+ * @param status - HTTP status
31
+ */
32
+ function throwHttp(message: string, status: number): never {
33
+ const err = new Error(message) as Error & { status: number };
34
+ err.status = status;
35
+ throw err;
36
+ }
37
+
26
38
  /**
27
39
  * Normalize a chat/embeddings base URL (strip trailing slash).
28
40
  *
@@ -112,7 +124,7 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
112
124
  const raw = (await res.json().catch(() => ({}))) as OpenAiChatResponse;
113
125
  if (!res.ok) {
114
126
  const msg = raw.error?.message ?? `openai-compatible HTTP ${res.status}`;
115
- throw new Error(`openai-compatible: ${msg}`);
127
+ throwHttp(`openai-compatible: ${msg}`, res.status);
116
128
  }
117
129
  const message = raw.choices?.[0]?.message;
118
130
  const text = message?.content ?? "";
@@ -148,7 +160,7 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
148
160
  if (!res.ok) {
149
161
  const raw = (await res.json().catch(() => ({}))) as OpenAiChatResponse;
150
162
  const msg = raw.error?.message ?? `openai-compatible HTTP ${res.status}`;
151
- throw new Error(`openai-compatible: ${msg}`);
163
+ throwHttp(`openai-compatible: ${msg}`, res.status);
152
164
  }
153
165
  if (!res.body) {
154
166
  throw new Error("openai-compatible: stream response has no body");
@@ -166,7 +178,7 @@ export async function openOpenaiCompatible(options: AiOpenOptions = {}): Promise
166
178
  const raw = (await res.json().catch(() => ({}))) as OpenAiEmbedResponse;
167
179
  if (!res.ok) {
168
180
  const msg = raw.error?.message ?? `openai-compatible embed HTTP ${res.status}`;
169
- throw new Error(`openai-compatible: ${msg}`);
181
+ throwHttp(`openai-compatible: ${msg}`, res.status);
170
182
  }
171
183
  const vectors = (raw.data ?? [])
172
184
  .slice()
@@ -1,34 +1,46 @@
1
1
  /**
2
2
  * `vault` driver — adapter wiring and the never-fail-boot degradation path.
3
+ *
4
+ * Dialect cases share one warmed in-memory PGlite (cold WASM once). The
5
+ * on-disk snapshot test still uses a unique tmpdir so reopen-via-URL is real.
3
6
  */
4
7
 
5
- import { describe, expect, test } from "bun:test";
8
+ import { afterAll, beforeAll, describe, expect, test } from "bun:test";
6
9
  import { mkdtemp, rm } from "node:fs/promises";
7
10
  import { tmpdir } from "node:os";
8
11
  import { join } from "node:path";
12
+ import type { BuiltinVaultAdapter } from "../elements/vault/builtin-adapter.ts";
13
+ import { resetVaultTables } from "../elements/vault/test-helpers.ts";
9
14
  import { connectPglite } from "./pglite.ts";
15
+ import type { SqlConnection } from "./types.ts";
10
16
  import { builtinVaultDriver, openBuiltinVaultAdapter, VAULT_BAG_ADAPTER } from "./vault-builtin.ts";
11
- import type { BuiltinVaultAdapter } from "../elements/vault/builtin-adapter.ts";
17
+
18
+ /** File-scoped warmed PGlite for injected-connection cases. */
19
+ let sharedConn: SqlConnection;
20
+
21
+ beforeAll(async () => {
22
+ sharedConn = await connectPglite({ url: "memory://vault-driver-shared" });
23
+ }, 15_000);
24
+
25
+ afterAll(async () => {
26
+ await sharedConn.close();
27
+ });
12
28
 
13
29
  describe("builtin vault driver", () => {
14
30
  test("openBuiltinVaultAdapter unseals an injected connection", async () => {
15
- const connection = await connectPglite({ url: "memory://vault-driver-test" });
16
- try {
17
- const first = await openBuiltinVaultAdapter({ connection, env: {} });
18
- const init = await first.adapter.initialize();
19
- await first.adapter.unseal(init.masterKey);
20
- await first.adapter.set("prod/api/stripe", "sk_live_x");
31
+ await resetVaultTables(sharedConn);
32
+ const first = await openBuiltinVaultAdapter({ connection: sharedConn, env: {} });
33
+ const init = await first.adapter.initialize();
34
+ await first.adapter.unseal(init.masterKey);
35
+ await first.adapter.set("prod/api/stripe", "sk_live_x");
21
36
 
22
- // A second open over the same connection unseals from the master key.
23
- const second = await openBuiltinVaultAdapter({
24
- connection,
25
- env: { OKE_VAULT_MASTER_KEY: init.masterKey },
26
- });
27
- expect(second.adapter.getUnsealer()).not.toBeNull();
28
- expect((await second.adapter.get("prod/api/stripe"))?.value).toBe("sk_live_x");
29
- } finally {
30
- await connection.close();
31
- }
37
+ // A second open over the same connection unseals from the master key.
38
+ const second = await openBuiltinVaultAdapter({
39
+ connection: sharedConn,
40
+ env: { OKE_VAULT_MASTER_KEY: init.masterKey },
41
+ });
42
+ expect(second.adapter.getUnsealer()).not.toBeNull();
43
+ expect((await second.adapter.get("prod/api/stripe"))?.value).toBe("sk_live_x");
32
44
  });
33
45
 
34
46
  test("open with no SQL configured degrades to the seed bag", async () => {
@@ -47,31 +59,27 @@ describe("builtin vault driver", () => {
47
59
  });
48
60
 
49
61
  test("bag close auto-seals the adapter (SIGTERM → bootResult.close path)", async () => {
50
- const connection = await connectPglite({ url: "memory://vault-autoseal" });
51
- try {
52
- const staging = await openBuiltinVaultAdapter({ connection, env: {} });
53
- const init = await staging.adapter.initialize();
54
- await staging.adapter.unseal(init.masterKey);
55
- await staging.adapter.set("prod/api/stripe", "sk_live_z");
56
- await staging.adapter.seal();
62
+ await resetVaultTables(sharedConn);
63
+ const staging = await openBuiltinVaultAdapter({ connection: sharedConn, env: {} });
64
+ const init = await staging.adapter.initialize();
65
+ await staging.adapter.unseal(init.masterKey);
66
+ await staging.adapter.set("prod/api/stripe", "sk_live_z");
67
+ await staging.adapter.seal();
57
68
 
58
- const bag = await builtinVaultDriver.open({
59
- connection,
60
- env: { OKE_VAULT_MASTER_KEY: init.masterKey },
61
- });
62
- const held = (bag as unknown as Record<symbol, BuiltinVaultAdapter | undefined>)[
63
- VAULT_BAG_ADAPTER
64
- ];
65
- expect(held).toBeDefined();
66
- expect(held!.getUnsealer()).not.toBeNull();
67
- expect(bag.get("prod/api/stripe")).toBe("sk_live_z");
69
+ const bag = await builtinVaultDriver.open({
70
+ connection: sharedConn,
71
+ env: { OKE_VAULT_MASTER_KEY: init.masterKey },
72
+ });
73
+ const held = (bag as unknown as Record<symbol, BuiltinVaultAdapter | undefined>)[
74
+ VAULT_BAG_ADAPTER
75
+ ];
76
+ expect(held).toBeDefined();
77
+ expect(held!.getUnsealer()).not.toBeNull();
78
+ expect(bag.get("prod/api/stripe")).toBe("sk_live_z");
68
79
 
69
- await bag.close?.();
70
- expect(held!.getUnsealer()).toBeNull();
71
- await expect(held!.get("prod/api/stripe")).rejects.toThrow(/sealed/i);
72
- } finally {
73
- await connection.close();
74
- }
80
+ await bag.close?.();
81
+ expect(held!.getUnsealer()).toBeNull();
82
+ await expect(held!.get("prod/api/stripe")).rejects.toThrow(/sealed/i);
75
83
  });
76
84
 
77
85
  test("open snapshots live secrets from an initialized vault", async () => {
@@ -106,5 +114,5 @@ describe("builtin vault driver", () => {
106
114
  } finally {
107
115
  await rm(dir, { recursive: true, force: true });
108
116
  }
109
- });
117
+ }, 15_000);
110
118
  });
@@ -4,6 +4,13 @@
4
4
  * Physics: inference · prompts · embeddings · agents.
5
5
  */
6
6
 
7
+ import {
8
+ aiAgentRegistry,
9
+ aiEmbedRegistry,
10
+ aiModelRegistry,
11
+ aiPromptRegistry,
12
+ } from "../../kernel/element-registries.ts";
13
+
7
14
  /** Budget for a prompt or agent. */
8
15
  export interface AiBudgetDecl {
9
16
  readonly maxCostPerCall?: number;
@@ -15,13 +22,34 @@ export interface AiModelOptions {
15
22
  readonly provider?: string;
16
23
  readonly tier?: string;
17
24
  readonly model?: string;
25
+ /**
26
+ * Optional endpoint override for this logical binding (openai-compatible /
27
+ * ollama). Lets `fx.ask(…, { via: ["smart", "local"] })` reach cloud then
28
+ * local without sharing one process-wide base URL.
29
+ */
30
+ readonly baseUrl?: string;
31
+ /** Optional API key override for this binding (cloud providers). */
32
+ readonly apiKey?: string;
18
33
  }
19
34
 
35
+ /**
36
+ * Ask deadline — clock duration string (`"30s"`, `"2m"`) or milliseconds.
37
+ * Time is not a cost budget; keep it off {@link AiBudgetDecl}.
38
+ */
39
+ export type AiTimeout = string | number;
40
+
20
41
  /** Options for {@link AiModelDecl.prompt}. */
21
42
  export interface AiPromptOptions {
22
43
  readonly version?: number;
23
44
  readonly evals?: string;
24
45
  readonly budget?: AiBudgetDecl;
46
+ /**
47
+ * Ordered recovery chain of logical model names for this command.
48
+ * Resolved as `ask.via ?? prompt.via ?? [prompt.model]`.
49
+ */
50
+ readonly via?: readonly string[];
51
+ /** Per-command deadline (overrides only when ask omits `timeout`). */
52
+ readonly timeout?: AiTimeout;
25
53
  readonly in?: unknown;
26
54
  readonly out?: unknown;
27
55
  }
@@ -47,6 +75,8 @@ export interface AiModelDecl {
47
75
  readonly provider?: string;
48
76
  readonly tier?: string;
49
77
  readonly model?: string;
78
+ readonly baseUrl?: string;
79
+ readonly apiKey?: string;
50
80
  /**
51
81
  * Declare a versioned prompt artifact on this model.
52
82
  *
@@ -63,6 +93,8 @@ export interface AiPromptDecl {
63
93
  readonly version?: number;
64
94
  readonly evals?: string;
65
95
  readonly budget?: AiBudgetDecl;
96
+ readonly via?: readonly string[];
97
+ readonly timeout?: AiTimeout;
66
98
  readonly model?: string;
67
99
  readonly in?: unknown;
68
100
  readonly out?: unknown;
@@ -122,6 +154,33 @@ export interface AiNamespace {
122
154
  agent(name: string, options?: AiAgentOptions): AiAgentDecl;
123
155
  }
124
156
 
157
+ /**
158
+ * Snapshot of AI decls registered since the last reset.
159
+ */
160
+ export function listAiDecls(): {
161
+ readonly models: readonly AiModelDecl[];
162
+ readonly prompts: readonly AiPromptDecl[];
163
+ readonly embeds: readonly AiEmbedDecl[];
164
+ readonly agents: readonly AiAgentDecl[];
165
+ } {
166
+ return {
167
+ models: aiModelRegistry.slice(),
168
+ prompts: aiPromptRegistry.slice(),
169
+ embeds: aiEmbedRegistry.slice(),
170
+ agents: aiAgentRegistry.slice(),
171
+ };
172
+ }
173
+
174
+ /**
175
+ * Clear AI declaration registries (tests / fresh app adopt).
176
+ */
177
+ export function resetAiDecls(): void {
178
+ aiModelRegistry.length = 0;
179
+ aiPromptRegistry.length = 0;
180
+ aiEmbedRegistry.length = 0;
181
+ aiAgentRegistry.length = 0;
182
+ }
183
+
125
184
  /**
126
185
  * AI element namespace.
127
186
  */
@@ -140,19 +199,26 @@ export const ai: AiNamespace = {
140
199
  ...(options.provider !== undefined ? { provider: options.provider } : {}),
141
200
  ...(options.tier !== undefined ? { tier: options.tier } : {}),
142
201
  ...(options.model !== undefined ? { model: options.model } : {}),
202
+ ...(options.baseUrl !== undefined ? { baseUrl: options.baseUrl } : {}),
203
+ ...(options.apiKey !== undefined ? { apiKey: options.apiKey } : {}),
143
204
  prompt(promptName, promptOpts = {}) {
144
- return {
205
+ const promptDecl: AiPromptDecl = {
145
206
  kind: "prompt",
146
207
  name: promptName,
147
208
  model: name,
148
209
  ...(promptOpts.version !== undefined ? { version: promptOpts.version } : {}),
149
210
  ...(promptOpts.evals !== undefined ? { evals: promptOpts.evals } : {}),
150
211
  ...(promptOpts.budget !== undefined ? { budget: promptOpts.budget } : {}),
212
+ ...(promptOpts.via !== undefined ? { via: promptOpts.via } : {}),
213
+ ...(promptOpts.timeout !== undefined ? { timeout: promptOpts.timeout } : {}),
151
214
  ...(promptOpts.in !== undefined ? { in: promptOpts.in } : {}),
152
215
  ...(promptOpts.out !== undefined ? { out: promptOpts.out } : {}),
153
216
  };
217
+ aiPromptRegistry.push(promptDecl);
218
+ return promptDecl;
154
219
  },
155
220
  };
221
+ aiModelRegistry.push(decl);
156
222
  return decl;
157
223
  },
158
224
 
@@ -165,12 +231,14 @@ export const ai: AiNamespace = {
165
231
  embed(name: string, options: AiEmbedOptions = {}): AiEmbedDecl {
166
232
  const model = typeof options.model === "string" ? options.model : options.model?.name;
167
233
  const into = typeof options.into === "string" ? options.into : options.into?.name;
168
- return {
234
+ const decl: AiEmbedDecl = {
169
235
  kind: "embed",
170
236
  name,
171
237
  ...(model !== undefined ? { model } : {}),
172
238
  ...(into !== undefined ? { into } : {}),
173
239
  };
240
+ aiEmbedRegistry.push(decl);
241
+ return decl;
174
242
  },
175
243
 
176
244
  /**
@@ -180,7 +248,7 @@ export const ai: AiNamespace = {
180
248
  * @param options - Tools / maxSteps / model / budget
181
249
  */
182
250
  agent(name: string, options: AiAgentOptions = {}): AiAgentDecl {
183
- return {
251
+ const decl: AiAgentDecl = {
184
252
  kind: "agent",
185
253
  name,
186
254
  tools: (options.tools ?? []).map(toolName),
@@ -192,5 +260,7 @@ export const ai: AiNamespace = {
192
260
  ? { model: options.model.name }
193
261
  : {}),
194
262
  };
263
+ aiAgentRegistry.push(decl);
264
+ return decl;
195
265
  },
196
266
  };