okengine 0.11.1 → 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.
- package/package.json +1 -1
- package/site/content/docs/elements/ai.mdx +22 -1
- package/site/content/docs/elements/store.mdx +3 -1
- package/site/content/docs/elements/vault.mdx +19 -11
- package/site/content/docs/get-started/installation.mdx +7 -1
- package/site/content/docs/recipes/llama-cpp.mdx +10 -9
- package/site/content/docs/reference/cli.md +6 -2
- package/site/content/docs/reference/environment-variables.mdx +9 -9
- package/src/cli/ai-setup/ai-setup.test.ts +3 -1
- package/src/cli/ai-setup/apply.ts +61 -1
- package/src/cli/ask-seed.test.ts +4 -3
- package/src/cli/ask-seed.ts +5 -6
- package/src/cli/client-add.test.ts +2 -1
- package/src/cli/dev.test.ts +116 -0
- package/src/cli/dev.ts +107 -18
- package/src/cli/project-state.test.ts +50 -0
- package/src/cli/project-state.ts +123 -0
- package/src/cli/vault-cmd.test.ts +47 -18
- package/src/cli/vault-cmd.ts +2 -1
- package/src/compiler/extract.ts +12 -1
- package/src/console/server/console.test.ts +3 -1
- package/src/console/server/operator-db.test.ts +48 -17
- package/src/console/server/operator-db.ts +5 -1
- package/src/docker/derive.ts +24 -3
- package/src/docker/docker.test.ts +4 -2
- package/src/docker/index.ts +1 -0
- package/src/docker/recipes/index.ts +1 -0
- package/src/docker/recipes/llama-cpp.ts +20 -4
- package/src/drivers/ai-openai-compatible.ts +15 -3
- package/src/drivers/vault-builtin.test.ts +50 -42
- package/src/elements/ai/declare.ts +73 -3
- package/src/elements/ai/errors.test.ts +35 -0
- package/src/elements/ai/errors.ts +139 -0
- package/src/elements/ai/eval.ts +26 -1
- package/src/elements/ai/runtime.ts +140 -80
- package/src/elements/ai/tools.test.ts +1 -1
- package/src/elements/ai.test.ts +99 -2
- package/src/elements/ai.ts +11 -1
- package/src/elements/index.ts +2 -0
- package/src/elements/store/index-boot.test.ts +23 -6
- package/src/elements/store/resource.test.ts +38 -19
- package/src/elements/store/sql-session.test.ts +55 -58
- package/src/elements/vault/builtin-adapter.test.ts +115 -58
- package/src/elements/vault/builtin-adapter.ts +241 -47
- package/src/elements/vault/chaos-child.ts +424 -0
- package/src/elements/vault/chaos.test.ts +651 -0
- package/src/elements/vault/resilience.ts +6 -1
- package/src/elements/vault/security-checklist.test.ts +10 -8
- package/src/elements/vault/storage.ts +130 -27
- package/src/elements/vault/test-helpers.ts +368 -0
- package/src/elements/vault.ts +6 -0
- package/src/index.ts +2 -0
- package/src/kernel/app.ts +83 -10
- package/src/kernel/auto-registry.test.ts +52 -1
- package/src/kernel/element-registries.ts +19 -4
- package/src/kernel/errors.ts +3 -3
- package/src/kernel/fx.test.ts +25 -0
- package/src/kernel/fx.ts +4 -1
- package/src/manifest/types.ts +4 -0
- package/src/test/create-test-app.ts +16 -11
- 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
|
|
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
|
|
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
|
|
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
|
-
|
|
241
|
-
await shared.close();
|
|
272
|
+
await opened.close();
|
|
242
273
|
}
|
|
243
|
-
}
|
|
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
|
-
/**
|
|
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";
|
package/src/docker/derive.ts
CHANGED
|
@@ -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
|
|
88
|
-
* Hub-pull then serve single-model
|
|
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:
|
|
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");
|
package/src/docker/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
|
36
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
};
|