@pentatonic-ai/ai-agent-sdk 0.4.9 → 0.5.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.
Files changed (39) hide show
  1. package/README.md +59 -0
  2. package/bin/cli.js +70 -9
  3. package/dist/index.cjs +25 -3
  4. package/dist/index.js +25 -3
  5. package/package.json +4 -2
  6. package/packages/doctor/README.md +106 -0
  7. package/packages/doctor/__tests__/checks.test.js +187 -0
  8. package/packages/doctor/__tests__/detect.test.js +101 -0
  9. package/packages/doctor/__tests__/output.test.js +92 -0
  10. package/packages/doctor/__tests__/plugins.test.js +111 -0
  11. package/packages/doctor/__tests__/runner.test.js +131 -0
  12. package/packages/doctor/package.json +6 -0
  13. package/packages/doctor/src/checks/hosted-tes.js +109 -0
  14. package/packages/doctor/src/checks/local-memory.js +290 -0
  15. package/packages/doctor/src/checks/platform.js +170 -0
  16. package/packages/doctor/src/checks/universal.js +121 -0
  17. package/packages/doctor/src/detect.js +102 -0
  18. package/packages/doctor/src/index.js +33 -0
  19. package/packages/doctor/src/output.js +55 -0
  20. package/packages/doctor/src/plugins.js +81 -0
  21. package/packages/doctor/src/runner.js +136 -0
  22. package/packages/memory/migrations/005-atomic-memories.sql +16 -0
  23. package/packages/memory/migrations/006-fix-vector-dim.sql +97 -0
  24. package/packages/memory/openclaw-plugin/__tests__/chat-turn.test.js +208 -0
  25. package/packages/memory/openclaw-plugin/__tests__/indicator.test.js +142 -0
  26. package/packages/memory/openclaw-plugin/__tests__/version-check.test.js +136 -0
  27. package/packages/memory/openclaw-plugin/index.js +369 -58
  28. package/packages/memory/openclaw-plugin/openclaw.plugin.json +11 -1
  29. package/packages/memory/openclaw-plugin/package.json +1 -1
  30. package/packages/memory/src/__tests__/distill.test.js +175 -0
  31. package/packages/memory/src/__tests__/openclaw-chat-turn.test.js +289 -0
  32. package/packages/memory/src/distill.js +162 -0
  33. package/packages/memory/src/index.js +1 -0
  34. package/packages/memory/src/ingest.js +10 -0
  35. package/packages/memory/src/openclaw/index.js +280 -23
  36. package/packages/memory/src/openclaw/package.json +1 -1
  37. package/packages/memory/src/server.js +27 -5
  38. package/src/normalizer.js +16 -0
  39. package/src/session.js +21 -2
package/README.md CHANGED
@@ -31,6 +31,7 @@
31
31
  - [SDK: Wrap Your LLM Client](#sdk-wrap-your-llm-client)
32
32
  - [Supported Providers](#supported-providers)
33
33
  - [API Reference](#api-reference)
34
+ - [Health Checks (`doctor`)](#health-checks-doctor)
34
35
  - [Architecture](#architecture)
35
36
 
36
37
  ## Overview
@@ -325,6 +326,64 @@ import { normalizeResponse } from "@pentatonic-ai/ai-agent-sdk";
325
326
  const { content, model, usage, toolCalls } = normalizeResponse(openaiResponse);
326
327
  ```
327
328
 
329
+ ## Health Checks (`doctor`)
330
+
331
+ Run a full health check of your SDK install at any time:
332
+
333
+ ```bash
334
+ npx @pentatonic-ai/ai-agent-sdk doctor
335
+ ```
336
+
337
+ `doctor` auto-detects which install path you're on (Local Memory, Hosted
338
+ TES, or self-hosted Pentatonic platform) and runs only the checks that
339
+ apply. Exit code is `0` for all-clear, `1` for warnings, `2` for critical.
340
+
341
+ Common flags:
342
+
343
+ ```bash
344
+ npx @pentatonic-ai/ai-agent-sdk doctor --json # machine-readable
345
+ npx @pentatonic-ai/ai-agent-sdk doctor --alert # silent unless issues
346
+ npx @pentatonic-ai/ai-agent-sdk doctor --no-plugins
347
+ npx @pentatonic-ai/ai-agent-sdk doctor --path local
348
+ ```
349
+
350
+ What gets checked:
351
+
352
+ - **Universal** — Node version, disk space, SDK config-file permissions
353
+ - **Local Memory** — Postgres + pgvector + migrations, embedding/LLM
354
+ endpoints, memory server port
355
+ - **Hosted TES** — endpoint reachable, API key authenticates
356
+ - **Self-hosted platform** — HybridRAG, Qdrant, Neo4j, vLLM (each
357
+ optional, skipped when its env var is unset)
358
+
359
+ ### Plugins
360
+
361
+ Drop a `.mjs` file into `~/.config/pentatonic-ai/doctor-plugins/` to add
362
+ your own checks. Useful for app-specific things — internal APIs, ingest
363
+ freshness, custom infrastructure — without forking the SDK.
364
+
365
+ ```js
366
+ // ~/.config/pentatonic-ai/doctor-plugins/my-app.mjs
367
+ export default {
368
+ name: "my-app",
369
+ checks: [
370
+ {
371
+ name: "internal API",
372
+ severity: "warning",
373
+ run: async () => {
374
+ const res = await fetch("https://internal/health");
375
+ return res.ok
376
+ ? { ok: true, msg: "200 OK" }
377
+ : { ok: false, msg: `HTTP ${res.status}` };
378
+ },
379
+ },
380
+ ],
381
+ };
382
+ ```
383
+
384
+ See [`packages/doctor/README.md`](packages/doctor/README.md) for the full
385
+ plugin contract and programmatic API.
386
+
328
387
  ## Architecture
329
388
 
330
389
  ```
package/bin/cli.js CHANGED
@@ -12,17 +12,58 @@ function parseArgs() {
12
12
  const args = process.argv.slice(2);
13
13
  const flags = {};
14
14
  for (let i = 0; i < args.length; i++) {
15
- if (args[i] === "--endpoint" && args[i + 1]) {
16
- flags.endpoint = args[i + 1];
17
- i++;
18
- } else if (args[i].startsWith("--endpoint=")) {
19
- flags.endpoint = args[i].split("=")[1];
20
- } else if (!args[i].startsWith("--")) {
21
- flags.command = args[i];
15
+ const a = args[i];
16
+ if (a === "--endpoint" && args[i + 1]) {
17
+ flags.endpoint = args[++i];
18
+ } else if (a.startsWith("--endpoint=")) {
19
+ flags.endpoint = a.split("=")[1];
20
+ } else if (a === "--path" && args[i + 1]) {
21
+ flags.path = args[++i];
22
+ } else if (a.startsWith("--path=")) {
23
+ flags.path = a.split("=")[1];
24
+ } else if (a === "--timeout" && args[i + 1]) {
25
+ flags.timeout = parseInt(args[++i], 10);
26
+ } else if (a.startsWith("--timeout=")) {
27
+ flags.timeout = parseInt(a.split("=")[1], 10);
28
+ } else if (a === "--json") {
29
+ flags.json = true;
30
+ } else if (a === "--alert") {
31
+ flags.alert = true;
32
+ } else if (a === "--no-plugins") {
33
+ flags.noPlugins = true;
34
+ } else if (!a.startsWith("--")) {
35
+ flags.command = a;
22
36
  }
23
37
  }
24
38
  return flags;
25
39
  }
40
+
41
+ async function runDoctorCommand(flags) {
42
+ // Lazy-load to keep doctor's pg dep optional for users who only run
43
+ // `npx ai-agent-sdk init` or `memory`.
44
+ const { runDoctor, renderHuman, renderJson } = await import(
45
+ "../packages/doctor/src/index.js"
46
+ );
47
+
48
+ const report = await runDoctor({
49
+ path: flags.path || "auto",
50
+ plugins: !flags.noPlugins,
51
+ timeoutMs: flags.timeout,
52
+ });
53
+
54
+ const hasIssues = report.summary.warning + report.summary.critical > 0;
55
+ if (flags.alert && !hasIssues) return 0;
56
+
57
+ if (flags.json) {
58
+ process.stdout.write(renderJson(report) + "\n");
59
+ } else {
60
+ process.stdout.write(renderHuman(report) + "\n");
61
+ }
62
+
63
+ if (report.summary.critical > 0) return 2;
64
+ if (report.summary.warning > 0) return 1;
65
+ return 0;
66
+ }
26
67
  const POLL_INTERVAL_MS = 3000;
27
68
  const POLL_TIMEOUT_MS = 300000; // 5 minutes
28
69
 
@@ -33,13 +74,19 @@ function ask(question) {
33
74
  }
34
75
 
35
76
  function askSecret(question) {
77
+ // Non-TTY fallback: piped/redirected input can't use raw mode.
78
+ // rl.close() would discard buffered stdin, so use readline directly instead.
79
+ if (!process.stdin.isTTY) {
80
+ return new Promise((resolve) => rl.question(question, resolve));
81
+ }
82
+
36
83
  return new Promise((resolve) => {
37
84
  // Close readline so it stops echoing input
38
85
  rl.close();
39
86
 
40
87
  process.stdout.write(question);
41
88
  const stdin = process.stdin;
42
- if (stdin.isTTY) stdin.setRawMode(true);
89
+ stdin.setRawMode(true);
43
90
  stdin.resume();
44
91
 
45
92
  let input = "";
@@ -47,7 +94,7 @@ function askSecret(question) {
47
94
  const c = ch.toString();
48
95
  if (c === "\n" || c === "\r") {
49
96
  stdin.removeListener("data", onData);
50
- if (stdin.isTTY) stdin.setRawMode(false);
97
+ stdin.setRawMode(false);
51
98
  stdin.pause();
52
99
  process.stdout.write("\n");
53
100
  // Recreate readline for subsequent prompts
@@ -248,6 +295,12 @@ async function main() {
248
295
  return;
249
296
  }
250
297
 
298
+ if (flags.command === "doctor") {
299
+ const code = await runDoctorCommand(flags);
300
+ rl.close();
301
+ process.exit(code);
302
+ }
303
+
251
304
  if (flags.command !== "init") {
252
305
  console.log(`
253
306
  @pentatonic-ai/ai-agent-sdk
@@ -255,8 +308,16 @@ async function main() {
255
308
  Usage:
256
309
  npx @pentatonic-ai/ai-agent-sdk init Set up hosted TES account
257
310
  npx @pentatonic-ai/ai-agent-sdk memory Set up local memory stack
311
+ npx @pentatonic-ai/ai-agent-sdk doctor Run health checks (exit 0/1/2)
258
312
  npx @pentatonic-ai/ai-agent-sdk init --endpoint URL Use a custom TES endpoint
259
313
 
314
+ doctor flags:
315
+ --json Emit a JSON report
316
+ --alert Suppress output when all green
317
+ --no-plugins Skip ~/.config/pentatonic-ai/doctor-plugins/*
318
+ --path local|hosted|platform|auto
319
+ --timeout <ms> Per-check timeout (default 10000)
320
+
260
321
  For docs, see https://api.pentatonic.com
261
322
  `);
262
323
  process.exit(0);
package/dist/index.cjs CHANGED
@@ -53,6 +53,16 @@ function empty() {
53
53
  toolCalls: []
54
54
  };
55
55
  }
56
+ function extractCacheUsage(usage) {
57
+ const out = {};
58
+ if (typeof usage.cache_read_input_tokens === "number") {
59
+ out.cache_read_input_tokens = usage.cache_read_input_tokens;
60
+ }
61
+ if (typeof usage.cache_creation_input_tokens === "number") {
62
+ out.cache_creation_input_tokens = usage.cache_creation_input_tokens;
63
+ }
64
+ return out;
65
+ }
56
66
  function normalizeOpenAI(raw) {
57
67
  const message = raw.choices?.[0]?.message || {};
58
68
  const usage = raw.usage || {};
@@ -87,7 +97,8 @@ function normalizeAnthropic(raw) {
87
97
  model: raw.model || null,
88
98
  usage: {
89
99
  prompt_tokens: usage.input_tokens || 0,
90
- completion_tokens: usage.output_tokens || 0
100
+ completion_tokens: usage.output_tokens || 0,
101
+ ...extractCacheUsage(usage)
91
102
  },
92
103
  toolCalls
93
104
  };
@@ -262,18 +273,27 @@ var Session = class {
262
273
  _reset() {
263
274
  this._promptTokens = 0;
264
275
  this._completionTokens = 0;
276
+ this._cacheReadTokens = 0;
277
+ this._cacheCreateTokens = 0;
265
278
  this._rounds = 0;
266
279
  this._toolCalls = [];
267
280
  this._model = null;
268
281
  this._systemPrompt = null;
269
282
  }
270
283
  get totalUsage() {
271
- return {
284
+ const usage = {
272
285
  prompt_tokens: this._promptTokens,
273
286
  completion_tokens: this._completionTokens,
274
- total_tokens: this._promptTokens + this._completionTokens,
287
+ total_tokens: this._promptTokens + this._completionTokens + this._cacheReadTokens + this._cacheCreateTokens,
275
288
  ai_rounds: this._rounds
276
289
  };
290
+ if (this._cacheReadTokens) {
291
+ usage.cache_read_input_tokens = this._cacheReadTokens;
292
+ }
293
+ if (this._cacheCreateTokens) {
294
+ usage.cache_creation_input_tokens = this._cacheCreateTokens;
295
+ }
296
+ return usage;
277
297
  }
278
298
  get toolCalls() {
279
299
  return this._toolCalls;
@@ -283,6 +303,8 @@ var Session = class {
283
303
  const round = this._rounds;
284
304
  this._promptTokens += normalized.usage.prompt_tokens;
285
305
  this._completionTokens += normalized.usage.completion_tokens;
306
+ this._cacheReadTokens += normalized.usage.cache_read_input_tokens || 0;
307
+ this._cacheCreateTokens += normalized.usage.cache_creation_input_tokens || 0;
286
308
  this._rounds += 1;
287
309
  if (normalized.model) {
288
310
  this._model = normalized.model;
package/dist/index.js CHANGED
@@ -22,6 +22,16 @@ function empty() {
22
22
  toolCalls: []
23
23
  };
24
24
  }
25
+ function extractCacheUsage(usage) {
26
+ const out = {};
27
+ if (typeof usage.cache_read_input_tokens === "number") {
28
+ out.cache_read_input_tokens = usage.cache_read_input_tokens;
29
+ }
30
+ if (typeof usage.cache_creation_input_tokens === "number") {
31
+ out.cache_creation_input_tokens = usage.cache_creation_input_tokens;
32
+ }
33
+ return out;
34
+ }
25
35
  function normalizeOpenAI(raw) {
26
36
  const message = raw.choices?.[0]?.message || {};
27
37
  const usage = raw.usage || {};
@@ -56,7 +66,8 @@ function normalizeAnthropic(raw) {
56
66
  model: raw.model || null,
57
67
  usage: {
58
68
  prompt_tokens: usage.input_tokens || 0,
59
- completion_tokens: usage.output_tokens || 0
69
+ completion_tokens: usage.output_tokens || 0,
70
+ ...extractCacheUsage(usage)
60
71
  },
61
72
  toolCalls
62
73
  };
@@ -231,18 +242,27 @@ var Session = class {
231
242
  _reset() {
232
243
  this._promptTokens = 0;
233
244
  this._completionTokens = 0;
245
+ this._cacheReadTokens = 0;
246
+ this._cacheCreateTokens = 0;
234
247
  this._rounds = 0;
235
248
  this._toolCalls = [];
236
249
  this._model = null;
237
250
  this._systemPrompt = null;
238
251
  }
239
252
  get totalUsage() {
240
- return {
253
+ const usage = {
241
254
  prompt_tokens: this._promptTokens,
242
255
  completion_tokens: this._completionTokens,
243
- total_tokens: this._promptTokens + this._completionTokens,
256
+ total_tokens: this._promptTokens + this._completionTokens + this._cacheReadTokens + this._cacheCreateTokens,
244
257
  ai_rounds: this._rounds
245
258
  };
259
+ if (this._cacheReadTokens) {
260
+ usage.cache_read_input_tokens = this._cacheReadTokens;
261
+ }
262
+ if (this._cacheCreateTokens) {
263
+ usage.cache_creation_input_tokens = this._cacheCreateTokens;
264
+ }
265
+ return usage;
246
266
  }
247
267
  get toolCalls() {
248
268
  return this._toolCalls;
@@ -252,6 +272,8 @@ var Session = class {
252
272
  const round = this._rounds;
253
273
  this._promptTokens += normalized.usage.prompt_tokens;
254
274
  this._completionTokens += normalized.usage.completion_tokens;
275
+ this._cacheReadTokens += normalized.usage.cache_read_input_tokens || 0;
276
+ this._cacheCreateTokens += normalized.usage.cache_creation_input_tokens || 0;
255
277
  this._rounds += 1;
256
278
  if (normalized.model) {
257
279
  this._model = normalized.model;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pentatonic-ai/ai-agent-sdk",
3
- "version": "0.4.9",
3
+ "version": "0.5.0",
4
4
  "description": "TES SDK — LLM observability and lifecycle tracking via Pentatonic Thing Event System. Track token usage, tool calls, and conversations. Manage things through event-sourced lifecycle stages with AI enrichment and vector search.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.cjs",
@@ -12,7 +12,8 @@
12
12
  },
13
13
  "./memory": "./packages/memory/src/index.js",
14
14
  "./memory/server": "./packages/memory/src/server.js",
15
- "./memory/openclaw": "./packages/memory/src/openclaw/index.js"
15
+ "./memory/openclaw": "./packages/memory/src/openclaw/index.js",
16
+ "./doctor": "./packages/doctor/src/index.js"
16
17
  },
17
18
  "bin": {
18
19
  "ai-agent-sdk": "./bin/cli.js"
@@ -22,6 +23,7 @@
22
23
  "src",
23
24
  "bin",
24
25
  "packages/memory",
26
+ "packages/doctor",
25
27
  "build.js",
26
28
  "README.md",
27
29
  "LICENSE"
@@ -0,0 +1,106 @@
1
+ # doctor
2
+
3
+ Health check subsystem for the AI Agent SDK.
4
+
5
+ ## Usage
6
+
7
+ ```bash
8
+ npx @pentatonic-ai/ai-agent-sdk doctor
9
+ ```
10
+
11
+ Auto-detects which install path you're on (Local Memory, Hosted TES, or
12
+ self-hosted Pentatonic platform) and runs the relevant checks. Returns
13
+ exit code `0` for all-clear, `1` for warnings, `2` for critical.
14
+
15
+ ### Flags
16
+
17
+ | Flag | Effect |
18
+ |---|---|
19
+ | `--json` | Emit a JSON report to stdout instead of a human table |
20
+ | `--alert` | Suppress output unless something is non-ok (good for cron) |
21
+ | `--no-plugins` | Skip user-supplied plugins for this run |
22
+ | `--path <name>` | Force a specific path: `local`, `hosted`, `platform`, `auto` |
23
+ | `--timeout <ms>` | Per-check timeout (default 10000) |
24
+
25
+ ## What gets checked
26
+
27
+ ### Universal (always)
28
+ - Node version ≥ 18
29
+ - Disk space at `$HOME` and `$TMPDIR`
30
+ - SDK config files (`~/.claude/tes-memory.local.md`, etc) are mode 0600
31
+
32
+ ### Local Memory path
33
+ Triggered when `DATABASE_URL` + `EMBEDDING_URL` + `LLM_URL` are all set,
34
+ or `~/.claude/tes-memory.local.md` exists.
35
+ - PostgreSQL reachable
36
+ - pgvector extension installed
37
+ - Schema migrations applied
38
+ - Embedding endpoint responds + serves the configured model
39
+ - LLM endpoint responds + has the configured model loaded
40
+ - Memory server bound on `PORT`
41
+
42
+ ### Hosted TES path
43
+ Triggered when `TES_ENDPOINT` + `TES_API_KEY` are both set.
44
+ - TES endpoint reachable
45
+ - API key authenticates for `TES_CLIENT_ID`
46
+
47
+ ### Self-hosted platform path
48
+ Triggered when `HYBRIDRAG_URL` is set or `~/.openclaw/openclaw.json`
49
+ exists. Each individual probe is skipped if its URL env var is unset, so
50
+ partial deployments don't false-fail.
51
+ - HybridRAG proxy
52
+ - Qdrant
53
+ - Neo4j (requires `NEO4J_PASSWORD`)
54
+ - vLLM
55
+
56
+ ## Plugins
57
+
58
+ Drop a `.mjs` file into `~/.config/pentatonic-ai/doctor-plugins/` and
59
+ `doctor` will load it automatically. (Use `.mjs`, not `.js` — without a
60
+ sibling `package.json` Node treats `.js` as CommonJS.)
61
+
62
+ ```js
63
+ // ~/.config/pentatonic-ai/doctor-plugins/my-app.mjs
64
+ export default {
65
+ name: "my-app",
66
+ checks: [
67
+ {
68
+ name: "internal API reachable",
69
+ severity: "warning", // 'critical' | 'warning' | 'info'
70
+ run: async () => {
71
+ const res = await fetch("https://internal/health");
72
+ return res.ok
73
+ ? { ok: true, msg: "200 OK" }
74
+ : { ok: false, msg: `HTTP ${res.status}` };
75
+ },
76
+ },
77
+ ],
78
+ };
79
+ ```
80
+
81
+ Plugin checks appear in the report prefixed with the plugin name
82
+ (`my-app: internal API reachable`).
83
+
84
+ A broken plugin will not abort the run — failures are logged and the
85
+ loader moves on.
86
+
87
+ ## Programmatic use
88
+
89
+ ```js
90
+ import { runDoctor, renderHuman } from "@pentatonic-ai/ai-agent-sdk/doctor";
91
+
92
+ const report = await runDoctor({ path: "auto" });
93
+ console.log(renderHuman(report));
94
+
95
+ if (report.summary.critical > 0) {
96
+ process.exit(2);
97
+ }
98
+ ```
99
+
100
+ `runDoctor` accepts:
101
+ - `path` — `'local' | 'hosted' | 'platform' | 'auto'`
102
+ - `plugins` — `false` to skip plugin loading
103
+ - `pluginDir` — override the plugin directory
104
+ - `timeoutMs` — per-check timeout
105
+ - `extraChecks` — additional check descriptors to merge in (useful in tests)
106
+ - `env` — override `process.env` for path detection
@@ -0,0 +1,187 @@
1
+ import { universalChecks } from "../src/checks/universal.js";
2
+ import { hostedTesChecks } from "../src/checks/hosted-tes.js";
3
+ import { platformChecks } from "../src/checks/platform.js";
4
+
5
+ // fetch mocking — we don't want any real network in unit tests.
6
+ const realFetch = globalThis.fetch;
7
+
8
+ function mockFetch(handler) {
9
+ globalThis.fetch = async (url, opts) => handler(url, opts);
10
+ }
11
+
12
+ afterEach(() => {
13
+ globalThis.fetch = realFetch;
14
+ });
15
+
16
+ describe("universal checks", () => {
17
+ it("registers the expected names", () => {
18
+ const names = universalChecks().map((c) => c.name);
19
+ expect(names).toContain("node version");
20
+ expect(names).toContain("disk space");
21
+ expect(names).toContain("config file perms");
22
+ });
23
+
24
+ it("node version returns ok on Node ≥18", async () => {
25
+ const node = universalChecks().find((c) => c.name === "node version");
26
+ const r = await node.run();
27
+ expect(r.ok).toBe(true);
28
+ });
29
+ });
30
+
31
+ describe("hosted TES checks", () => {
32
+ beforeEach(() => {
33
+ delete process.env.TES_ENDPOINT;
34
+ delete process.env.TES_API_KEY;
35
+ delete process.env.TES_CLIENT_ID;
36
+ });
37
+
38
+ it("reports missing env clearly", async () => {
39
+ const reach = hostedTesChecks().find(
40
+ (c) => c.name === "TES endpoint reachable"
41
+ );
42
+ const r = await reach.run();
43
+ expect(r.ok).toBe(false);
44
+ expect(r.msg).toMatch(/TES_ENDPOINT/);
45
+ });
46
+
47
+ it("treats /api/health 200 as reachable", async () => {
48
+ process.env.TES_ENDPOINT = "https://example.test";
49
+ mockFetch(async () => ({
50
+ ok: true,
51
+ status: 200,
52
+ text: async () => "",
53
+ json: async () => ({}),
54
+ }));
55
+ const reach = hostedTesChecks().find(
56
+ (c) => c.name === "TES endpoint reachable"
57
+ );
58
+ const r = await reach.run();
59
+ expect(r.ok).toBe(true);
60
+ });
61
+
62
+ it("falls back to graphql when /api/health 404s", async () => {
63
+ process.env.TES_ENDPOINT = "https://example.test";
64
+ let calls = 0;
65
+ mockFetch(async (url) => {
66
+ calls++;
67
+ if (url.endsWith("/api/health")) {
68
+ return { ok: false, status: 404, text: async () => "" };
69
+ }
70
+ return { ok: true, status: 200, json: async () => ({}) };
71
+ });
72
+ const reach = hostedTesChecks().find(
73
+ (c) => c.name === "TES endpoint reachable"
74
+ );
75
+ const r = await reach.run();
76
+ expect(r.ok).toBe(true);
77
+ expect(calls).toBe(2);
78
+ });
79
+
80
+ it("rejects 401 from auth check", async () => {
81
+ process.env.TES_ENDPOINT = "https://example.test";
82
+ process.env.TES_API_KEY = "bad";
83
+ process.env.TES_CLIENT_ID = "c";
84
+ mockFetch(async () => ({ ok: false, status: 401, text: async () => "" }));
85
+ const auth = hostedTesChecks().find((c) => c.name === "TES API key valid");
86
+ const r = await auth.run();
87
+ expect(r.ok).toBe(false);
88
+ expect(r.msg).toMatch(/auth rejected/);
89
+ });
90
+
91
+ it("accepts 200 from auth check", async () => {
92
+ process.env.TES_ENDPOINT = "https://example.test";
93
+ process.env.TES_API_KEY = "good";
94
+ process.env.TES_CLIENT_ID = "c";
95
+ mockFetch(async () => ({
96
+ ok: true,
97
+ status: 200,
98
+ json: async () => ({ data: { __schema: {} } }),
99
+ }));
100
+ const auth = hostedTesChecks().find((c) => c.name === "TES API key valid");
101
+ const r = await auth.run();
102
+ expect(r.ok).toBe(true);
103
+ });
104
+ });
105
+
106
+ describe("platform checks", () => {
107
+ beforeEach(() => {
108
+ delete process.env.HYBRIDRAG_URL;
109
+ delete process.env.QDRANT_URL;
110
+ delete process.env.NEO4J_HTTP;
111
+ delete process.env.NEO4J_PASSWORD;
112
+ delete process.env.VLLM_URL;
113
+ });
114
+
115
+ it("skips each check when its URL env is unset", async () => {
116
+ const checks = platformChecks();
117
+ for (const c of checks) {
118
+ const r = await c.run();
119
+ expect(r.ok).toBe(true);
120
+ expect(r.msg).toMatch(/not set \(skipped\)/);
121
+ }
122
+ });
123
+
124
+ it("hybridrag falls back to search probe when /health 404s", async () => {
125
+ process.env.HYBRIDRAG_URL = "http://hybridrag:8031";
126
+ mockFetch(async (url) => {
127
+ if (url.endsWith("/health")) {
128
+ return { ok: false, status: 404, text: async () => "" };
129
+ }
130
+ return { ok: true, status: 200, json: async () => ({ results: [] }) };
131
+ });
132
+ const c = platformChecks().find((x) => x.name === "hybridrag reachable");
133
+ const r = await c.run();
134
+ expect(r.ok).toBe(true);
135
+ });
136
+
137
+ it("neo4j requires NEO4J_PASSWORD when NEO4J_HTTP is set", async () => {
138
+ process.env.NEO4J_HTTP = "http://neo4j:7474";
139
+ const c = platformChecks().find((x) => x.name === "neo4j reachable");
140
+ const r = await c.run();
141
+ expect(r.ok).toBe(false);
142
+ expect(r.msg).toMatch(/NEO4J_PASSWORD/);
143
+ });
144
+
145
+ it("neo4j flags 401 specifically", async () => {
146
+ process.env.NEO4J_HTTP = "http://neo4j:7474";
147
+ process.env.NEO4J_PASSWORD = "wrong";
148
+ mockFetch(async () => ({
149
+ status: 401,
150
+ ok: false,
151
+ text: async () => "",
152
+ json: async () => ({}),
153
+ }));
154
+ const c = platformChecks().find((x) => x.name === "neo4j reachable");
155
+ const r = await c.run();
156
+ expect(r.ok).toBe(false);
157
+ expect(r.msg).toMatch(/auth rejected/);
158
+ });
159
+
160
+ it("qdrant lists collections", async () => {
161
+ process.env.QDRANT_URL = "http://qdrant:6333";
162
+ mockFetch(async () => ({
163
+ ok: true,
164
+ status: 200,
165
+ json: async () => ({
166
+ result: { collections: [{ name: "a" }, { name: "b" }] },
167
+ }),
168
+ }));
169
+ const c = platformChecks().find((x) => x.name === "qdrant reachable");
170
+ const r = await c.run();
171
+ expect(r.ok).toBe(true);
172
+ expect(r.detail.collections).toEqual(["a", "b"]);
173
+ });
174
+
175
+ it("vllm flags 'no models loaded' when /v1/models is empty", async () => {
176
+ process.env.VLLM_URL = "http://vllm:8001";
177
+ mockFetch(async () => ({
178
+ ok: true,
179
+ status: 200,
180
+ json: async () => ({ data: [] }),
181
+ }));
182
+ const c = platformChecks().find((x) => x.name === "vllm reachable");
183
+ const r = await c.run();
184
+ expect(r.ok).toBe(false);
185
+ expect(r.msg).toMatch(/no models loaded/);
186
+ });
187
+ });