gentle-pi 2.6.0 → 2.6.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/README.md +183 -943
- package/contracts/review-provider-contract-mirror/provider-contract.lock.json +9 -8
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/manifest.json +3 -3
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/orchestration/pi.md +7 -2
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/lens.schema.json +2 -2
- package/contracts/review-provider-contract-mirror/v1.2.0/bundle/schemas/targeted-validator.schema.json +1 -1
- package/contracts/review-provider-contract-mirror/v1.2.0/generated/provider-capabilities.baseline.json +1 -1
- package/contracts/review-provider-contract-mirror/v1.2.0/generated/provider-roles.baseline.json +2 -2
- package/contracts/telemetry/runtime-aggregate-v1.schema.json +8 -6
- package/docs/assets/brand/gentle-pi-banner.png +0 -0
- package/docs/assets/brand/gentle-pi-banner.svg +33 -0
- package/docs/assets/brand/terminal-divider.svg +17 -0
- package/docs/assets/diagrams/agent-orchestration.svg +19 -0
- package/docs/assets/diagrams/gentleman-workflow.svg +15 -0
- package/docs/assets/diagrams/native-review.svg +16 -0
- package/docs/assets/diagrams/sdd-cycle.svg +14 -0
- package/docs/assets/features/gentle-shell.png +0 -0
- package/docs/gentle-shell.md +151 -0
- package/docs/readme-reference.md +868 -0
- package/extensions/gentle-agents.ts +4 -5
- package/extensions/gentle-ai.ts +65 -22
- package/extensions/runtime-metrics.ts +10 -19
- package/lib/agents-history.ts +7 -1
- package/lib/native-review-cli.ts +14 -0
- package/lib/runtime-metrics-children.ts +3 -5
- package/lib/runtime-metrics-native.ts +17 -9
- package/lib/runtime-metrics.ts +95 -42
- package/package.json +1 -1
- package/runtime/native-review-cli.mjs +14 -0
- package/scripts/gentle-ai-installer.mjs +10 -10
- package/scripts/types-baseline.json +1 -5
- package/scripts/verify-package-files.mjs +3 -3
- package/tests/fixtures/runtime-metrics-native-batches.json +1 -1
- package/tests/gentle-agents.test.ts +22 -6
- package/tests/gentle-ai-binary.test.ts +1 -1
- package/tests/gentle-ai-installer.test.ts +47 -47
- package/tests/gentle-ai.test.ts +3 -2
- package/tests/native-review-capability-contract.test.ts +24 -1
- package/tests/package-manifest.test.ts +19 -18
- package/tests/review-authority-recovery-docs.test.ts +13 -13
- package/tests/review-controller-native-routing.test.ts +49 -0
- package/tests/review-ledger-contract.test.ts +7 -5
- package/tests/runtime-metrics-children.test.ts +4 -3
- package/tests/runtime-metrics-extension.test.ts +40 -31
- package/tests/runtime-metrics-model.test.ts +76 -0
- package/tests/runtime-metrics-native.test.ts +73 -7
- package/tests/runtime-metrics.test.ts +22 -19
- package/tests/sdd-managed-runtime-settlement.test.ts +37 -0
- package/tests/sdd-selection-transport.test.ts +57 -0
- package/tests/skill-collision-prefixes.test.ts +2 -2
- package/lib/runtime-metrics-pi-identity.ts +0 -113
- package/tests/runtime-metrics-pi-identity.test.ts +0 -113
|
@@ -101,13 +101,14 @@ function readPackageJson(): PackageJson {
|
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
103
|
|
|
104
|
-
test("
|
|
104
|
+
test("technical reference declares the tested Pi minimum required for agent_settled", () => {
|
|
105
105
|
const manifest = readPackageJson();
|
|
106
106
|
assert.equal(manifest.peerDependencies?.["@earendil-works/pi-coding-agent"], ">=0.85.1");
|
|
107
107
|
assert.equal(manifest.devDependencies?.["@earendil-works/pi-coding-agent"], "0.85.1");
|
|
108
|
-
const
|
|
109
|
-
assert.match(
|
|
110
|
-
assert.match(
|
|
108
|
+
const reference = readFileSync(join(PACKAGE_ROOT, "docs", "readme-reference.md"), "utf8");
|
|
109
|
+
assert.match(reference, /Pi 0\.85\.1 or newer/);
|
|
110
|
+
assert.match(reference, /agent_settled/);
|
|
111
|
+
assert.match(readFileSync(join(PACKAGE_ROOT, "README.md"), "utf8"), /\]\(docs\/readme-reference\.md(?:#[^)]+)?\)/);
|
|
111
112
|
});
|
|
112
113
|
|
|
113
114
|
test("package manifest has no obsolete native activation build surface", () => {
|
|
@@ -229,12 +230,12 @@ test("generated runtime modules and packed-package checks are deterministic", ()
|
|
|
229
230
|
test("package manifest ships and runs the checked-in package-local Gentle AI installer", () => {
|
|
230
231
|
const packageJson = readPackageJson();
|
|
231
232
|
const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
|
|
232
|
-
const
|
|
233
|
+
const reference = readFileSync(join(PACKAGE_ROOT, "docs", "readme-reference.md"), "utf8");
|
|
233
234
|
|
|
234
235
|
assert.equal(packageJson.scripts?.postinstall, "node scripts/install-gentle-ai.mjs");
|
|
235
|
-
assert.match(
|
|
236
|
-
assert.match(
|
|
237
|
-
assert.match(
|
|
236
|
+
assert.match(reference, /run `node scripts\/install-gentle-ai\.mjs`/, "missing-binary recovery documentation must use the package postinstall entrypoint");
|
|
237
|
+
assert.match(reference, /installed `gentle-pi` package directory/, "recovery documentation must name the package working directory");
|
|
238
|
+
assert.match(reference, /if `GENTLE_PI_SKIP_GENTLE_AI_INSTALL` is set, remove or unset it before/i, "recovery documentation must prevent the installer skip from repeating");
|
|
238
239
|
assert.ok(packageJson.files?.includes("scripts/"));
|
|
239
240
|
assert.match(verifier, /"scripts\/install-gentle-ai\.mjs"/);
|
|
240
241
|
assert.match(verifier, /"scripts\/gentle-ai-installer\.mjs"/);
|
|
@@ -277,20 +278,20 @@ test("package manifest installs pi-pretty through a wrapper without bundling nat
|
|
|
277
278
|
);
|
|
278
279
|
});
|
|
279
280
|
|
|
280
|
-
test("package verification binds the published Gentle AI v2.8.
|
|
281
|
+
test("package verification binds the published Gentle AI v2.8.2 runtime pin", () => {
|
|
281
282
|
const installer = readFileSync(join(PACKAGE_ROOT, "scripts", "gentle-ai-installer.mjs"), "utf8");
|
|
282
283
|
const binary = readFileSync(join(PACKAGE_ROOT, "lib", "gentle-ai-binary.ts"), "utf8");
|
|
283
284
|
const verifier = readFileSync(join(PACKAGE_ROOT, "scripts", "verify-package-files.mjs"), "utf8");
|
|
284
285
|
|
|
285
|
-
assert.match(installer, /INSTALLER_VERSION = "2\.8\.
|
|
286
|
+
assert.match(installer, /INSTALLER_VERSION = "2\.8\.2"/);
|
|
286
287
|
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_PACKAGE.*GENTLE_AI_WINDOWS_SOURCE_MODULE/);
|
|
287
|
-
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:
|
|
288
|
+
assert.match(installer, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM = "h1:54VJ0ruRiPc6MbNw4iFmlcBkQFOyF5D8iHY0A54Ivug="/);
|
|
288
289
|
assert.match(installer, /GOTOOLCHAIN: "local"/);
|
|
289
290
|
assert.match(installer, /GOSUMDB: "sum\.golang\.org"/);
|
|
290
291
|
assert.match(binary, /GENTLE_AI_VERSION = INSTALLER_VERSION/);
|
|
291
292
|
assert.match(binary, /GO_SUMDB_SOURCE_BUILD/);
|
|
292
293
|
assert.match(binary, /GENTLE_AI_WINDOWS_SOURCE_MODULE_CHECKSUM/);
|
|
293
|
-
assert.match(verifier, /v2\.8\.
|
|
294
|
+
assert.match(verifier, /v2\.8\.2/);
|
|
294
295
|
});
|
|
295
296
|
|
|
296
297
|
|
|
@@ -1508,9 +1509,9 @@ test("pi-pretty wrapper uses real package path resolution for pnpm symlink insta
|
|
|
1508
1509
|
assert.match(wrapper, /quietToolsEnabled/);
|
|
1509
1510
|
});
|
|
1510
1511
|
|
|
1511
|
-
test("v2.6.
|
|
1512
|
+
test("v2.6.2 release package and runtime stop before publication", () => {
|
|
1512
1513
|
const packageJson = readPackageJson();
|
|
1513
|
-
assert.equal(packageJson.version, "2.6.
|
|
1514
|
+
assert.equal(packageJson.version, "2.6.2", "the release manifest must remain explicitly pinned to v2.6.2");
|
|
1514
1515
|
assert.equal(
|
|
1515
1516
|
packageJson.scripts?.test,
|
|
1516
1517
|
"node --experimental-strip-types --test tests/*.test.ts && pnpm run check:provider-contract && pnpm run test:harness",
|
|
@@ -1540,8 +1541,8 @@ test("bounded review keeps the Judgment Day skill contract at canon metadata ver
|
|
|
1540
1541
|
assert.doesNotMatch(frontmatter, /^ version: "1\.4"$/m);
|
|
1541
1542
|
});
|
|
1542
1543
|
|
|
1543
|
-
test("
|
|
1544
|
-
const
|
|
1544
|
+
test("technical reference documents dynamic Gentle AI RDD ownership and the installed permission boundary", () => {
|
|
1545
|
+
const reference = readFileSync(join(PACKAGE_ROOT, "docs", "readme-reference.md"), "utf8");
|
|
1545
1546
|
for (const clause of [
|
|
1546
1547
|
"Gentle AI dynamically supplies runtime-specific RDD instructions",
|
|
1547
1548
|
"does not define an RDD lifecycle",
|
|
@@ -1549,9 +1550,9 @@ test("README documents dynamic Gentle AI RDD ownership and the installed permiss
|
|
|
1549
1550
|
"package-managed isolated installation",
|
|
1550
1551
|
"Project and user overrides may shadow a package asset",
|
|
1551
1552
|
]) {
|
|
1552
|
-
assert.ok(
|
|
1553
|
+
assert.ok(reference.includes(clause), `technical reference missing dynamic RDD clause: ${clause}`);
|
|
1553
1554
|
}
|
|
1554
|
-
assert.doesNotMatch(
|
|
1555
|
+
assert.doesNotMatch(reference, /New ordinary review uses compact `gentle_review` `start -> finalize -> validate`\./);
|
|
1555
1556
|
});
|
|
1556
1557
|
|
|
1557
1558
|
|
|
@@ -2,21 +2,21 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import test from "node:test";
|
|
4
4
|
|
|
5
|
-
const
|
|
5
|
+
const TECHNICAL_REFERENCE = readFileSync("docs/readme-reference.md", "utf8");
|
|
6
6
|
const CONTROLLER = readFileSync("extensions/gentle-ai.ts", "utf8");
|
|
7
7
|
|
|
8
|
-
test("
|
|
9
|
-
assert.match(
|
|
10
|
-
assert.match(
|
|
11
|
-
assert.match(
|
|
12
|
-
assert.match(
|
|
13
|
-
assert.match(
|
|
14
|
-
assert.match(
|
|
15
|
-
assert.match(
|
|
16
|
-
assert.match(
|
|
17
|
-
assert.match(
|
|
18
|
-
assert.match(
|
|
19
|
-
assert.match(
|
|
8
|
+
test("technical reference documents the narrow published native maintenance contract", () => {
|
|
9
|
+
assert.match(TECHNICAL_REFERENCE, /abandon.*quarantine-legacy.*reconcile-authority.*explicit v2\.1\.11 maintenance/i);
|
|
10
|
+
assert.match(TECHNICAL_REFERENCE, /predecessor lineage and revision.*successor lineage and revision/i);
|
|
11
|
+
assert.match(TECHNICAL_REFERENCE, /exact seven-line.*anomalies=unchanged_target,malformed_recovery_authorization/i);
|
|
12
|
+
assert.match(TECHNICAL_REFERENCE, /fresh interactive approval/i);
|
|
13
|
+
assert.match(TECHNICAL_REFERENCE, /quarantine only the bound invalid compact-v2 recovery successor/i);
|
|
14
|
+
assert.match(TECHNICAL_REFERENCE, /predecessor stays untouched/i);
|
|
15
|
+
assert.match(TECHNICAL_REFERENCE, /repair-legacy-alias.*unsupported historical v1 operation alias/i);
|
|
16
|
+
assert.match(TECHNICAL_REFERENCE, /model supplies only lineage, actor, and reason/i);
|
|
17
|
+
assert.match(TECHNICAL_REFERENCE, /review dispose-result.*unsupported.*pending.*design/i);
|
|
18
|
+
assert.match(TECHNICAL_REFERENCE, /RESET.*RECOVER.*destructive/i);
|
|
19
|
+
assert.match(TECHNICAL_REFERENCE, /typed envelopes/i);
|
|
20
20
|
});
|
|
21
21
|
|
|
22
22
|
test("controller help keeps authorization, blocked outcomes, and recovery boundaries explicit", () => {
|
|
@@ -298,6 +298,55 @@ test("approved acknowledgement burn tears down the retained candidate view and k
|
|
|
298
298
|
assert.ok(registry.hasProjection(lineageId, contributorRoot), "terminal approved cleanup keeps the lineage projection");
|
|
299
299
|
});
|
|
300
300
|
|
|
301
|
+
// gentle-ai#4003: the native burn is the committed authority outcome. A Pi-side
|
|
302
|
+
// candidate-view teardown failure after it must never be reported as a failed
|
|
303
|
+
// acknowledgement, or the caller cannot tell that authority was consumed.
|
|
304
|
+
test("approved acknowledgement reports the burn truthfully when candidate-view cleanup fails after it", async (t) => {
|
|
305
|
+
const lineageId = "acknowledge-approved-deferred-cleanup";
|
|
306
|
+
const contributorRoot = candidateRepository(t);
|
|
307
|
+
writeFileSync(join(contributorRoot, "tracked.txt"), "candidate\n");
|
|
308
|
+
let failWorktreeRemove = true;
|
|
309
|
+
const registry = new CandidateViewRegistry((file, arguments_, options) => {
|
|
310
|
+
if (failWorktreeRemove && arguments_[0] === "worktree" && arguments_[1] === "remove") {
|
|
311
|
+
failWorktreeRemove = false;
|
|
312
|
+
throw Object.assign(new Error("simulated worktree removal failure"), { status: 128 });
|
|
313
|
+
}
|
|
314
|
+
return execFileSync(file, arguments_, options);
|
|
315
|
+
});
|
|
316
|
+
t.after(() => registry.cleanupAll());
|
|
317
|
+
const view = registry.create({ contributorRoot });
|
|
318
|
+
registry.retain(view.token, lineageId);
|
|
319
|
+
const statusRequests: unknown[] = [];
|
|
320
|
+
let acknowledgements = 0;
|
|
321
|
+
const native = {
|
|
322
|
+
targetStatus: async (request: unknown) => { statusRequests.push(request); return approvedAcknowledgementStatus(lineageId, contributorRoot); },
|
|
323
|
+
acknowledgeApproved: async () => { acknowledgements += 1; },
|
|
324
|
+
} as unknown as NativeReviewCli;
|
|
325
|
+
|
|
326
|
+
const completed = await __testing.executeReviewControllerOperation({ operation: "acknowledge-approved", lineageId }, contributorRoot, native, undefined, registry);
|
|
327
|
+
|
|
328
|
+
assert.equal(acknowledgements, 1, "exactly one native burn");
|
|
329
|
+
assert.equal(statusRequests.length, 1, "no STATUS reconciliation after a cleanup-only failure");
|
|
330
|
+
assert.equal(completed.status, "closed");
|
|
331
|
+
assert.equal(completed.outcome, "native-approved-acknowledgement-completed");
|
|
332
|
+
assert.equal(completed.authority, "burned");
|
|
333
|
+
assert.equal(completed.mutation_performed, true);
|
|
334
|
+
assert.equal(completed.mutation_outcome, "committed");
|
|
335
|
+
assert.deepEqual(completed.candidate_view_cleanup, {
|
|
336
|
+
status: "deferred",
|
|
337
|
+
diagnostics: { code: "candidate-view-git-failure", message: "candidate-view Git command worktree failed; inspect the candidate state before any new START" },
|
|
338
|
+
next_action: "retry-candidate-view-cleanup-or-remove-the-view-out-of-band",
|
|
339
|
+
});
|
|
340
|
+
assert.ok(existsSync(view.root), "a failed teardown preserves the candidate view");
|
|
341
|
+
assert.ok(registry.hasProjection(lineageId, contributorRoot), "the lineage projection survives the deferred cleanup");
|
|
342
|
+
|
|
343
|
+
// The residue stays recoverable: the registry still owns the view, so a
|
|
344
|
+
// later terminal cleanup removes it without replaying the burn.
|
|
345
|
+
registry.cleanupTerminal(lineageId, "approved", contributorRoot);
|
|
346
|
+
assert.equal(existsSync(view.root), false);
|
|
347
|
+
assert.equal(acknowledgements, 1);
|
|
348
|
+
});
|
|
349
|
+
|
|
301
350
|
test("ambiguous acknowledgement reconciles STATUS once without replaying the provider vector", async () => {
|
|
302
351
|
const lineageId = "ambiguous-acknowledgement";
|
|
303
352
|
let statusCalls = 0;
|
|
@@ -19,6 +19,7 @@ const JD_SKILL = "skills/judgment-day/SKILL.md";
|
|
|
19
19
|
const JD_PROMPTS = "skills/judgment-day/references/prompts-and-formats.md";
|
|
20
20
|
const GENTLE_SKILL = "skills/gentle-ai/SKILL.md";
|
|
21
21
|
const README = "README.md";
|
|
22
|
+
const TECHNICAL_REFERENCE = "docs/readme-reference.md";
|
|
22
23
|
const CHAIN = "assets/chains/4r-review.chain.md";
|
|
23
24
|
const SDD_WORKFLOW = "assets/sdd-orchestrator-workflow.md";
|
|
24
25
|
const RELEASE_SKILL = "skills/release/SKILL.md";
|
|
@@ -132,7 +133,8 @@ test("canonical contract defines compact risk, causal admission, correction, CAS
|
|
|
132
133
|
/untrusted repository content.*malformed inputs.*stale authority.*path drift.*external callers/i,
|
|
133
134
|
...JUDGMENT_DAY_DISCOVERY_PATTERNS,
|
|
134
135
|
]);
|
|
135
|
-
assert.match(read(
|
|
136
|
+
assert.match(read(TECHNICAL_REFERENCE), /Review outcomes and receipt state are informational; commit, push, pull-request, and release delivery follow ordinary repository policy\./);
|
|
137
|
+
assert.match(read(README), /\]\(docs\/readme-reference\.md(?:#[^)]+)?\)/);
|
|
136
138
|
assert.doesNotMatch(read(README), /one one-shot authorization for the exact command/i);
|
|
137
139
|
assert.doesNotMatch(read(README), /review-publication-gate/i);
|
|
138
140
|
});
|
|
@@ -365,14 +367,14 @@ test("Judgment Day fix routing has one canonical shape and never falls back to g
|
|
|
365
367
|
assert.match(read(SDD_WORKFLOW), /\| default\s+\| balanced\s+\| SDD phase fallback; never a Judgment Day role\s+\|/);
|
|
366
368
|
});
|
|
367
369
|
|
|
368
|
-
test("orchestrator, injected skill, and
|
|
370
|
+
test("orchestrator, injected skill, and technical reference defer RDD lifecycle ownership to Gentle AI", () => {
|
|
369
371
|
const boundary = "This package injects the mirrored provider-bundle review execution contract into this session's system prompt at start; Gentle AI writes nothing into the Pi system prompt, and this package owns everything else here. Absent that mirrored contract, this package invents no lifecycle instructions.";
|
|
370
372
|
const orchestrator = union(ORCHESTRATOR);
|
|
371
373
|
assert.ok(orchestrator.includes(boundary), "orchestrator must carry the sole static ownership boundary");
|
|
372
374
|
|
|
373
375
|
for (const [label, content] of [
|
|
374
376
|
[GENTLE_SKILL, read(GENTLE_SKILL)],
|
|
375
|
-
[
|
|
377
|
+
[TECHNICAL_REFERENCE, read(TECHNICAL_REFERENCE)],
|
|
376
378
|
] as const) {
|
|
377
379
|
assertMatches(label, content, [
|
|
378
380
|
/Gentle AI dynamically supplies runtime-specific RDD instructions/i,
|
|
@@ -388,8 +390,8 @@ test("orchestrator, injected skill, and README defer RDD lifecycle ownership to
|
|
|
388
390
|
}
|
|
389
391
|
});
|
|
390
392
|
|
|
391
|
-
test("
|
|
392
|
-
const content = read(
|
|
393
|
+
test("technical reference documents the dynamic runtime authority boundary without an old package route", () => {
|
|
394
|
+
const content = read(TECHNICAL_REFERENCE);
|
|
393
395
|
assert.match(content, /Gentle AI dynamically supplies runtime-specific RDD instructions/i);
|
|
394
396
|
assert.match(content, /does not define an RDD lifecycle/i);
|
|
395
397
|
assert.doesNotMatch(content, /New ordinary review uses compact `gentle_review` `start -> finalize -> validate`\./);
|
|
@@ -6,11 +6,9 @@ import { runInNewContext } from "node:vm";
|
|
|
6
6
|
import { parseAgentClass } from "../lib/runtime-metrics.ts";
|
|
7
7
|
import { parseAgentDefinition, type AgentDefinition } from "../lib/agents-config.ts";
|
|
8
8
|
import { normalizeRpcEvent, TASK_EVENT } from "../lib/agents-protocol.ts";
|
|
9
|
-
import { lookupPiCatalogName } from "../lib/runtime-metrics-pi-identity.ts";
|
|
10
9
|
import { ChildComposition, childEvent, classifyBuiltinAgent, launchSelection } from "../lib/runtime-metrics-children.ts";
|
|
11
10
|
import { encodeNativeRuntimeEvent } from "../lib/runtime-metrics-native.ts";
|
|
12
11
|
|
|
13
|
-
await lookupPiCatalogName({ provider: "openai", modelId: "gpt-4o" });
|
|
14
12
|
const asset = new URL("../assets/agents/gentle-ai-worker.md", import.meta.url);
|
|
15
13
|
const definition = parseAgentDefinition(readFileSync(asset, "utf8"), asset.pathname, "global");
|
|
16
14
|
assert.ok("instructions" in definition);
|
|
@@ -123,7 +121,10 @@ test("launch distribution and each observed combination remain independent and p
|
|
|
123
121
|
assert.equal(view.settled, 1);
|
|
124
122
|
assert.equal(view.statuses.completed, 1);
|
|
125
123
|
assert.ok(!JSON.stringify(view).includes("local-"));
|
|
126
|
-
|
|
124
|
+
// "Private Vendor Co" fails the schema provider pattern (space, uppercase),
|
|
125
|
+
// unlike a genuine lowercase slug such as "private" that would now legitimately
|
|
126
|
+
// pass through as an open-weight provider name.
|
|
127
|
+
const privateLaunch = launchSelection({ ...workerDefinition, instructions: "private" }, { provider: "Private Vendor Co", id: "private-model" }, "private-effort");
|
|
127
128
|
const filtered = childEvent("local-session", "other", privateLaunch, "failed", {
|
|
128
129
|
coverage: "final_assistant_messages_only", agentSettled: false, responses: [response("private-model", "private-native")], droppedResponses: 0,
|
|
129
130
|
});
|
|
@@ -2,14 +2,12 @@ import assert from "node:assert/strict";
|
|
|
2
2
|
import test from "node:test";
|
|
3
3
|
import runtimeMetrics from "../extensions/runtime-metrics.ts";
|
|
4
4
|
import { parseAgentClass, type RuntimeMetricBucket } from "../lib/runtime-metrics.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { encodeNativeRuntimeEvent } from "../lib/runtime-metrics-native.ts";
|
|
6
6
|
import { CHILD_METRICS_EVENT, childEvent } from "../lib/runtime-metrics-children.ts";
|
|
7
7
|
import { normalizeRpcEvent, TASK_EVENT } from "../lib/agents-protocol.ts";
|
|
8
8
|
|
|
9
9
|
const tick = () => new Promise<void>(resolve => setImmediate(resolve));
|
|
10
|
-
|
|
11
|
-
function harness(env: NodeJS.ProcessEnv = {}, lookup?: CatalogLookup,
|
|
12
|
-
classify?: (input: unknown) => PiCatalogName, mode: "tui" | "print" = "tui", shutdownWaitMs = 1500) {
|
|
10
|
+
function harness(env: NodeJS.ProcessEnv = {}, mode: "tui" | "print" = "tui", shutdownWaitMs = 1500, model: unknown = { provider: "openai", id: "gpt-4o" }) {
|
|
13
11
|
const handlers = new Map<string, Function>();
|
|
14
12
|
const listeners = new Map<string, Function>();
|
|
15
13
|
let session = "first";
|
|
@@ -17,13 +15,13 @@ function harness(env: NodeJS.ProcessEnv = {}, lookup?: CatalogLookup,
|
|
|
17
15
|
let signal!: AbortSignal;
|
|
18
16
|
const sent: RuntimeMetricBucket[][] = [];
|
|
19
17
|
const launches: unknown[] = [];
|
|
20
|
-
const ctx: any = { cwd: "/fixture", mode, model
|
|
18
|
+
const ctx: any = { cwd: "/fixture", mode, model,
|
|
21
19
|
sessionManager: { getSessionId: () => session, getEntries: () => assert.fail("no reconstruction") } };
|
|
22
20
|
const pi: any = { on: (name: string, handler: Function) => handlers.set(name, handler),
|
|
23
21
|
getThinkingLevel: () => "high", registerCommand() {},
|
|
24
22
|
appendEntry: () => assert.fail("no metrics persistence"),
|
|
25
23
|
events: { on: (name: string, handler: Function) => { listeners.set(name, handler); return () => listeners.delete(name); } } };
|
|
26
|
-
runtimeMetrics(pi, env, {
|
|
24
|
+
runtimeMetrics(pi, env, { now: () => 0, shutdownWaitMs, send: (rows, _cwd, deps) => {
|
|
27
25
|
sent.push(structuredClone(rows)); launches.push(structuredClone(deps?.launches)); signal = deps!.signal!;
|
|
28
26
|
return new Promise(resolve => { finish = () => resolve("discarded"); });
|
|
29
27
|
} });
|
|
@@ -38,30 +36,41 @@ const final = (input = 7) => ({ role: "assistant", provider: "openai", model: "g
|
|
|
38
36
|
providerThinkingLevel: "low", stopReason: "stop", usage: { input, output: 3, cacheRead: 0 },
|
|
39
37
|
content: [{ text: "private response" }], errorMessage: "private error", path: "/private" });
|
|
40
38
|
|
|
41
|
-
test("
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
if (calls === 1) throw new Error("transient catalog failure");
|
|
48
|
-
return catalog;
|
|
49
|
-
});
|
|
50
|
-
const h = harness({}, catalogLookup.lookup, catalogLookup.classify);
|
|
51
|
-
h.emit("session_start");
|
|
52
|
-
await tick();
|
|
53
|
-
assert.equal(calls, 1);
|
|
54
|
-
assert.equal(h.emit("message_end", { message: final() }), undefined);
|
|
55
|
-
assert.equal(calls, 2);
|
|
39
|
+
test("a generic open-weight selection reaches the wire by name; no catalog probe or gate", async () => {
|
|
40
|
+
const h = harness({}, "tui", 1500, { provider: "nan", id: "deepseek-v4-flash" });
|
|
41
|
+
await h.emit("session_start");
|
|
42
|
+
h.emit("turn_start"); h.emit("before_provider_request");
|
|
43
|
+
h.emit("message_end", { message: { role: "assistant", provider: "nan", model: "deepseek-v4-flash",
|
|
44
|
+
providerThinkingLevel: "low", stopReason: "stop", usage: { input: 3, output: 2 } } });
|
|
56
45
|
await tick();
|
|
57
46
|
assert.equal(h.sent.length, 1);
|
|
58
|
-
assert.equal(h.sent[0][0].
|
|
59
|
-
|
|
60
|
-
|
|
47
|
+
assert.equal(h.sent[0][0].selectedProvider, "nan");
|
|
48
|
+
assert.equal(h.sent[0][0].selectedModelId, "deepseek-v4-flash");
|
|
49
|
+
const encoded = encodeNativeRuntimeEvent(h.sent[0]);
|
|
50
|
+
assert.ok(encoded);
|
|
51
|
+
const row = JSON.parse(encoded).rows[0];
|
|
52
|
+
assert.deepEqual(row.model, { provider: "nan", id: "deepseek-v4-flash" });
|
|
53
|
+
assert.equal(row.model_evidence, "selected");
|
|
54
|
+
await h.finish(); h.emit("session_shutdown");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("an ambiguous turn with no captured selection still reports the observed model, as selected evidence", async () => {
|
|
58
|
+
const h = harness({}, "tui", 1500, { provider: "nan", id: "deepseek-v4-flash" });
|
|
59
|
+
await h.emit("session_start");
|
|
60
|
+
// No turn_start/before_provider_request: the turn is ambiguous, so no
|
|
61
|
+
// selection was captured for this response.
|
|
62
|
+
h.emit("message_end", { message: { role: "assistant", provider: "nan", model: "glm5.3",
|
|
63
|
+
providerThinkingLevel: "low", stopReason: "stop", usage: { input: 3, output: 2 } } });
|
|
61
64
|
await tick();
|
|
62
|
-
assert.equal(h.sent
|
|
63
|
-
|
|
64
|
-
h.
|
|
65
|
+
assert.equal(h.sent.length, 1);
|
|
66
|
+
assert.equal(h.sent[0][0].selectedModelId, "unknown");
|
|
67
|
+
assert.equal(h.sent[0][0].observedModelId, "glm5.3");
|
|
68
|
+
const encoded = encodeNativeRuntimeEvent(h.sent[0]);
|
|
69
|
+
assert.ok(encoded);
|
|
70
|
+
const row = JSON.parse(encoded).rows[0];
|
|
71
|
+
assert.deepEqual(row.model, { provider: "nan", id: "glm5.3" });
|
|
72
|
+
assert.equal(row.model_evidence, "selected");
|
|
73
|
+
await h.finish(); h.emit("session_shutdown");
|
|
65
74
|
});
|
|
66
75
|
|
|
67
76
|
test("final callback returns before blocked transport; busy and duplicate messages drop", async () => {
|
|
@@ -97,7 +106,7 @@ test("replacement before launch cancels stale work; shutdown does not await a bl
|
|
|
97
106
|
});
|
|
98
107
|
|
|
99
108
|
test("print shutdown joins an accepted orchestrator delivery before disposing", async () => {
|
|
100
|
-
const h = harness({},
|
|
109
|
+
const h = harness({}, "print", 50); await h.emit("session_start");
|
|
101
110
|
h.emit("message_end", { message: final() }); await tick();
|
|
102
111
|
let stopped = false;
|
|
103
112
|
const shutdown = h.emit("session_shutdown").then(() => { stopped = true; });
|
|
@@ -109,7 +118,7 @@ test("print shutdown joins an accepted orchestrator delivery before disposing",
|
|
|
109
118
|
});
|
|
110
119
|
|
|
111
120
|
test("print shutdown joins an accepted child launch and response delivery", async () => {
|
|
112
|
-
const h = harness({},
|
|
121
|
+
const h = harness({}, "print", 50); await h.emit("session_start");
|
|
113
122
|
const observation = normalizeRpcEvent({ type: "message_end", message: final() }, { observeResponses: true })
|
|
114
123
|
.find(event => event.type === TASK_EVENT.RESPONSE_OBSERVATION);
|
|
115
124
|
assert.ok(observation?.type === TASK_EVENT.RESPONSE_OBSERVATION);
|
|
@@ -127,7 +136,7 @@ test("print shutdown joins an accepted child launch and response delivery", asyn
|
|
|
127
136
|
});
|
|
128
137
|
|
|
129
138
|
test("print shutdown aborts a delivery after the bounded join deadline", async () => {
|
|
130
|
-
const h = harness({},
|
|
139
|
+
const h = harness({}, "print", 5); await h.emit("session_start");
|
|
131
140
|
h.emit("message_end", { message: final() }); await tick();
|
|
132
141
|
await h.emit("session_shutdown");
|
|
133
142
|
assert.equal(h.signal().aborted, true);
|
|
@@ -135,7 +144,7 @@ test("print shutdown aborts a delivery after the bounded join deadline", async (
|
|
|
135
144
|
});
|
|
136
145
|
|
|
137
146
|
test("interactive shutdown never joins a blocked delivery", async () => {
|
|
138
|
-
const h = harness({},
|
|
147
|
+
const h = harness({}, "tui", 50); await h.emit("session_start");
|
|
139
148
|
h.emit("message_end", { message: final() }); await tick();
|
|
140
149
|
assert.equal(h.emit("session_shutdown"), undefined);
|
|
141
150
|
assert.equal(h.signal().aborted, true);
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import test from "node:test";
|
|
3
|
+
import { normalizeRuntimeModel } from "../lib/runtime-metrics.ts";
|
|
4
|
+
|
|
5
|
+
// Data-driven family-pattern normalizer (gentle-pi#968 / gentle-ai#4536). Rules:
|
|
6
|
+
// - non-string or empty provider or id -> {provider: "unknown", id: "unknown"};
|
|
7
|
+
// - id is trimmed, the LAST "/"-separated segment is kept, then lowercased; it is
|
|
8
|
+
// public only when it matches the schema id pattern within its maxLength,
|
|
9
|
+
// otherwise the id becomes "custom";
|
|
10
|
+
// - provider is trimmed and lowercased; it is kept when it matches the schema
|
|
11
|
+
// provider pattern, otherwise it becomes "custom";
|
|
12
|
+
// - when the id is not public the provider becomes "custom" too, except for the
|
|
13
|
+
// "opencode" provider, which yields "opencode"/"custom";
|
|
14
|
+
// - the literal "unknown"/"unknown" and "custom"/"custom" pairs pass through.
|
|
15
|
+
const cases: Array<[string, unknown, unknown, { provider: string; id: string }]> = [
|
|
16
|
+
["open-weight family on an arbitrary provider stays public", "nan", "deepseek-v4-flash", { provider: "nan", id: "deepseek-v4-flash" }],
|
|
17
|
+
["a short version suffix without a separator is still public", "nan", "glm5.3", { provider: "nan", id: "glm5.3" }],
|
|
18
|
+
["multiple suffix groups without a leading separator are still public", "nan", "glm5.3-flash", { provider: "nan", id: "glm5.3-flash" }],
|
|
19
|
+
["only the last '/'-separated id segment is kept", "nano-gpt", "TEE/glm-5.3", { provider: "nano-gpt", id: "glm-5.3" }],
|
|
20
|
+
["a nested provider path in the id is dropped, keeping the outer provider", "openrouter", "deepseek/deepseek-v4-flash", { provider: "openrouter", id: "deepseek-v4-flash" }],
|
|
21
|
+
["provider and id are lowercased", "OpenAI", "GPT-5.6-Sol", { provider: "openai", id: "gpt-5.6-sol" }],
|
|
22
|
+
["a long dotted/dashed anthropic id stays public", "anthropic", "claude-sonnet-5-20260101", { provider: "anthropic", id: "claude-sonnet-5-20260101" }],
|
|
23
|
+
["an id with no recognized family prefix collapses provider and id to custom", "acme", "acme-internal-finetune", { provider: "custom", id: "custom" }],
|
|
24
|
+
["a public id with a non-conforming provider is emitted as custom/<id>", "Acme Corp", "gpt-5.6", { provider: "custom", id: "gpt-5.6" }],
|
|
25
|
+
["an id longer than the schema maxLength collapses to custom/custom", "anthropic", `claude-${"x".repeat(64)}`, { provider: "custom", id: "custom" }],
|
|
26
|
+
["opencode keeps its provider identity even for a private id", "opencode", "private-thing", { provider: "opencode", id: "custom" }],
|
|
27
|
+
["empty provider and id fail closed to unknown/unknown", "", "", { provider: "unknown", id: "unknown" }],
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
for (const [name, provider, id, expected] of cases) {
|
|
31
|
+
test(`normalizeRuntimeModel: ${name}`, () => {
|
|
32
|
+
assert.deepEqual(normalizeRuntimeModel(provider, id), expected);
|
|
33
|
+
});
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
test("non-string provider or id fails closed to unknown/unknown", () => {
|
|
37
|
+
for (const [provider, id] of [[undefined, "gpt-5.6"], ["openai", undefined], [null, null], [42, "gpt-5.6"], ["openai", { id: "gpt-5.6" }]] as const) {
|
|
38
|
+
assert.deepEqual(normalizeRuntimeModel(provider, id), { provider: "unknown", id: "unknown" });
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("whitespace-only provider or id fails closed to unknown/unknown", () => {
|
|
43
|
+
assert.deepEqual(normalizeRuntimeModel(" ", "gpt-5.6"), { provider: "unknown", id: "unknown" });
|
|
44
|
+
assert.deepEqual(normalizeRuntimeModel("openai", " "), { provider: "unknown", id: "unknown" });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test("literal unknown/unknown passes through without pattern matching", () => {
|
|
48
|
+
assert.deepEqual(normalizeRuntimeModel("unknown", "unknown"), { provider: "unknown", id: "unknown" });
|
|
49
|
+
assert.deepEqual(normalizeRuntimeModel("Unknown", "UNKNOWN"), { provider: "unknown", id: "unknown" });
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("literal custom/custom passes through", () => {
|
|
53
|
+
assert.deepEqual(normalizeRuntimeModel("custom", "custom"), { provider: "custom", id: "custom" });
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("surrounding whitespace is trimmed before matching", () => {
|
|
57
|
+
assert.deepEqual(normalizeRuntimeModel(" nan ", " deepseek-v4-flash "), { provider: "nan", id: "deepseek-v4-flash" });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
// The mirrored pattern is unambiguous (every suffix group starts with a
|
|
61
|
+
// mandatory separator), so a long alphanumeric run followed by an invalid
|
|
62
|
+
// character cannot trigger catastrophic backtracking in V8. The bound below
|
|
63
|
+
// is generous on purpose: it catches an exponential regression, not jitter.
|
|
64
|
+
test("a pathological 64-character id is rejected in linear time", () => {
|
|
65
|
+
const ids = [`gpt${"a".repeat(60)}!`, `glm${"5".repeat(60)}/`, `deepseek${"x".repeat(55)}-`];
|
|
66
|
+
const started = performance.now();
|
|
67
|
+
for (let round = 0; round < 50; round++) for (const id of ids) {
|
|
68
|
+
assert.deepEqual(normalizeRuntimeModel("nan", id), { provider: "custom", id: "custom" });
|
|
69
|
+
}
|
|
70
|
+
assert.ok(performance.now() - started < 500, "normalizer must not backtrack catastrophically");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("attached run then separated groups stays public", () => {
|
|
74
|
+
assert.deepEqual(normalizeRuntimeModel("nan", "glm5.3-flash:thinking"), { provider: "nan", id: "glm5.3-flash:thinking" });
|
|
75
|
+
assert.deepEqual(normalizeRuntimeModel("nan", "gpt-1-2-3-4-5-6-7-8-9"), { provider: "custom", id: "custom" });
|
|
76
|
+
});
|
|
@@ -75,18 +75,84 @@ test("encoder filters custom identities and never promotes SDK model to response
|
|
|
75
75
|
row.agentClass = "private-agent" as any; row.effort = "private-effort" as any;
|
|
76
76
|
row.error = "authentication";
|
|
77
77
|
const encoded = JSON.parse(native.encodeNativeRuntimeEvent([row])!);
|
|
78
|
-
|
|
78
|
+
// "private-model" never normalizes to a public id, so it never claims
|
|
79
|
+
// response evidence; the row falls through to the still-public selection
|
|
80
|
+
// (source()'s default selectedModelId "gpt-5.4") instead of leaking it or
|
|
81
|
+
// collapsing a perfectly good selected identity to custom/custom.
|
|
82
|
+
assert.deepEqual(encoded.rows[0].model, { provider: "openai", id: "gpt-5.4" });
|
|
83
|
+
assert.equal(encoded.rows[0].model_evidence, "selected");
|
|
79
84
|
assert.equal(encoded.rows[0].agent_class, "unknown");
|
|
80
85
|
assert.equal(encoded.rows[0].selected_effort, "unavailable");
|
|
81
86
|
assert.equal(encoded.rows[0].error_category, "auth");
|
|
82
87
|
assert.ok(!JSON.stringify(encoded).includes("private"));
|
|
83
|
-
row.responseModelId = "unknown";
|
|
84
|
-
const selected = JSON.parse(native.encodeNativeRuntimeEvent([row])!);
|
|
85
|
-
assert.equal(selected.rows[0].model_evidence, "selected");
|
|
86
|
-
assert.deepEqual(selected.rows[0].model, { provider: "openai", id: "gpt-5.4" });
|
|
87
88
|
row.selectedModelId = "unknown";
|
|
88
|
-
const
|
|
89
|
-
|
|
89
|
+
const observedFallback = JSON.parse(native.encodeNativeRuntimeEvent([row])!);
|
|
90
|
+
// With no public selected model either, it falls through once more, but
|
|
91
|
+
// source()'s default observedModelId ("private-alias") is not public either.
|
|
92
|
+
assert.equal(observedFallback.rows[0].model_evidence, "unknown");
|
|
93
|
+
assert.deepEqual(observedFallback.rows[0].model, { provider: "unknown", id: "unknown" });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("a public observed model is a fallback evidence tier, behind response and selected", () => {
|
|
97
|
+
const row = source();
|
|
98
|
+
row.responseModelId = "unknown"; row.selectedModelId = "unknown"; row.observedModelId = "glm5.3-flash"; row.provider = "nan";
|
|
99
|
+
const encoded = JSON.parse(native.encodeNativeRuntimeEvent([row])!);
|
|
100
|
+
assert.deepEqual(encoded.rows[0].model, { provider: "nan", id: "glm5.3-flash" });
|
|
101
|
+
assert.equal(encoded.rows[0].model_evidence, "selected", "the transport has no separate observed evidence category");
|
|
102
|
+
// A public response model still wins over a public observed model.
|
|
103
|
+
const withResponse = source();
|
|
104
|
+
withResponse.selectedModelId = "unknown"; withResponse.observedModelId = "glm5.3-flash"; withResponse.provider = "nan";
|
|
105
|
+
const encodedResponse = JSON.parse(native.encodeNativeRuntimeEvent([withResponse])!);
|
|
106
|
+
assert.equal(encodedResponse.rows[0].model_evidence, "response");
|
|
107
|
+
// A public selected model still wins over a public observed model.
|
|
108
|
+
const withSelected = source();
|
|
109
|
+
withSelected.responseModelId = "unknown"; withSelected.observedModelId = "glm5.3-flash"; withSelected.provider = "nan";
|
|
110
|
+
const encodedSelected = JSON.parse(native.encodeNativeRuntimeEvent([withSelected])!);
|
|
111
|
+
assert.equal(encodedSelected.rows[0].model_evidence, "selected");
|
|
112
|
+
assert.deepEqual(encodedSelected.rows[0].model, { provider: withSelected.selectedProvider, id: withSelected.selectedModelId });
|
|
113
|
+
// A non-public observed model still falls through to unknown/unknown.
|
|
114
|
+
const noEvidence = source();
|
|
115
|
+
noEvidence.responseModelId = "unknown"; noEvidence.selectedModelId = "unknown"; noEvidence.observedModelId = "private-alias";
|
|
116
|
+
const encodedUnknown = JSON.parse(native.encodeNativeRuntimeEvent([noEvidence])!);
|
|
117
|
+
assert.deepEqual(encodedUnknown.rows[0].model, { provider: "unknown", id: "unknown" });
|
|
118
|
+
assert.equal(encodedUnknown.rows[0].model_evidence, "unknown");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("every encoded model satisfies the schema's anyOf, by pattern or by sentinel", () => {
|
|
122
|
+
const idRule = schema.$defs.model.anyOf[0].properties.id as { pattern: string };
|
|
123
|
+
const providerRule = schema.$defs.model.anyOf[0].properties.provider as { pattern: string };
|
|
124
|
+
const idPattern = new RegExp(idRule.pattern);
|
|
125
|
+
const providerPattern = new RegExp(providerRule.pattern);
|
|
126
|
+
const sentinels = schema.$defs.model.anyOf.slice(1) as Array<{ properties: { provider: { const: string }; id: { const: string } } }>;
|
|
127
|
+
function matchesSchema(model: { provider: string; id: string }): boolean {
|
|
128
|
+
if (providerPattern.test(model.provider) && idPattern.test(model.id)) return true;
|
|
129
|
+
return sentinels.some(({ properties }) => properties.provider.const === model.provider && properties.id.const === model.id);
|
|
130
|
+
}
|
|
131
|
+
// Response/selected/observed rows only ever win evidence with an id that is
|
|
132
|
+
// already public, so their encoded model is either the public-pattern
|
|
133
|
+
// branch or the terminal unknown/unknown sentinel. The custom/custom and
|
|
134
|
+
// opencode/custom sentinels are reachable only through a launch row, which
|
|
135
|
+
// (already upstream-classified, always "selected" evidence) is paired
|
|
136
|
+
// through the same normalizer unconditionally.
|
|
137
|
+
const rows: RuntimeMetricBucket[] = [
|
|
138
|
+
source(),
|
|
139
|
+
{ ...source(), provider: "nan", selectedProvider: "nan", selectedModelId: "deepseek-v4-flash", responseModelId: "unknown" },
|
|
140
|
+
{ ...source(), responseModelId: "unknown", selectedModelId: "unknown", observedModelId: "unknown" },
|
|
141
|
+
];
|
|
142
|
+
const launches = [
|
|
143
|
+
{ evidence: "launch_configuration" as const, agentClass: parseAgentClass("worker")!, selectedProvider: "opencode", selectedModelId: "custom", selectedEffort: "high" as const, launches: 1 },
|
|
144
|
+
{ evidence: "launch_configuration" as const, agentClass: parseAgentClass("worker")!, selectedProvider: "custom", selectedModelId: "custom", selectedEffort: "high" as const, launches: 1 },
|
|
145
|
+
{ evidence: "launch_configuration" as const, agentClass: parseAgentClass("worker")!, selectedProvider: "unknown", selectedModelId: "unknown", selectedEffort: "high" as const, launches: 1 },
|
|
146
|
+
];
|
|
147
|
+
const payload = native.encodeNativeRuntimeEvent(rows, launches);
|
|
148
|
+
assert.ok(payload);
|
|
149
|
+
const decoded = JSON.parse(payload).rows as Array<{ model: { provider: string; id: string } }>;
|
|
150
|
+
assert.equal(decoded.length, rows.length + launches.length);
|
|
151
|
+
for (const row of decoded) assert.ok(matchesSchema(row.model), `model ${JSON.stringify(row.model)} must satisfy $defs.model.anyOf`);
|
|
152
|
+
assert.deepEqual(decoded.map(row => row.model), [
|
|
153
|
+
{ provider: "openai", id: "gpt-5.4" }, { provider: "nan", id: "deepseek-v4-flash" }, { provider: "unknown", id: "unknown" },
|
|
154
|
+
{ provider: "opencode", id: "custom" }, { provider: "custom", id: "custom" }, { provider: "unknown", id: "unknown" },
|
|
155
|
+
]);
|
|
90
156
|
});
|
|
91
157
|
|
|
92
158
|
function fixture() {
|