prism-mcp-server 20.0.7 → 20.1.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.
package/README.md CHANGED
@@ -18,6 +18,32 @@ A paid subscription adds cloud sync, higher model tiers, and team features throu
18
18
 
19
19
  ---
20
20
 
21
+ ## What's New in v20.1.0
22
+
23
+ ### Every Inference Outcome Is Now Observable
24
+ `prism_infer` gains a failure contract: pass `escalation: "report"` and every call returns a structured `gate_outcome` — `success`, `degraded` (gate-failed output served anyway, explicitly flagged), or `refused` (typed, with reason, instead of a thrown error). Degraded output can no longer serve silently.
25
+
26
+ ### Big Prompts Work Locally
27
+ Prompts over 4000 chars were blanket-refused when cloud was off. Now the full text gets a deterministic reserved-keyword scan plus a head+middle+tail excerpt classification — clean oversize prompts serve locally with a distinct `UNCERTAIN_LENGTH` audit marker. Clinical/reserved handling is unchanged (and its keyword floor got stronger).
28
+
29
+ ### No More Silent Truncation
30
+ Tier context limits now match the live Modelfiles (27b/9b are 4096-token models; 4b/2b are 32768 — the old table had it backwards). Tiers that can't hold your prompt are skipped with a visible `ctx_insufficient` reason; if nothing fits, you get the full prompt on cloud or a loud error — never an answer computed from a silently-clipped prompt.
31
+
32
+ ### Know Which Plan You're Actually Running Under
33
+ Entitlements carry a `source` field: `portal` (real), `unconfigured` (free by design), or `fallback_free` (portal unreachable — free limits ASSUMED). Pass `strict_entitlements: true` to fail loud instead of running degraded.
34
+
35
+ ---
36
+
37
+ ## What's New in v20.0.8
38
+
39
+ ### verify_behavior Works Again
40
+ The `verify_behavior` tool crashed on every call (`-32602 expected object, received string`) — the handler returned a bare string instead of an MCP `CallToolResult` object. Fixed, with contract + fail-closed regression tests so the safety gate can never silently break again. If you're on 20.0.6/20.0.7, update.
41
+
42
+ ### From v20.0.7: Reserved-Content Safety, Skills Auth, Delegation Metrics
43
+ Reserved clinical content is now Claude-or-refuse (never served by a smaller model than the one that refused it), skill delivery gained a JWT auth fallback (paid-tier skills now reach machines using only `PRISM_SYNALUX_API_KEY`), and every `prism_infer` call is recorded in a persistent `infer_metrics` ledger. Full details in [CHANGELOG.md](CHANGELOG.md).
44
+
45
+ ---
46
+
21
47
  ## What's New in v20.0.5
22
48
 
23
49
  ### Local-First Delegation — 15 Categories, Measured Rate
@@ -17,12 +17,12 @@
17
17
  * BOUNDARIES_VERSION is still used by markContextLoaded for session
18
18
  * drift detection — bump it when the safety contract changes.
19
19
  */
20
- export const BOUNDARIES_VERSION = "3";
20
+ export const BOUNDARIES_VERSION = "4";
21
21
  export const BOUNDARIES_TEXT = `
22
22
  Safety boundaries are enforced in code — shown so hosts avoid wasted round-trips.
23
23
 
24
24
  - **Crisis/self-harm** inputs are intercepted before reaching any model.
25
- - **BCBA reserved categories** (restraint, seclusion, physical management, dosing) are classified by Layer 1. RESERVED and UNCERTAIN prompts escalate to cloud or are refused. On classifier failure, a keyword backstop blocks reserved vocabulary before local generation.
25
+ - **BCBA reserved categories** (restraint, seclusion, physical management, dosing): Layer 1 classifies; RESERVED/UNCERTAIN escalate to cloud or are refused. A keyword backstop covers classifier failure; oversize prompts get a full-text keyword scan + excerpt classification.
26
26
  - **Dangerous output** (restraint instructions, overdose methods, self-harm guidance) is blocked regardless of host.
27
27
  - AAC access is never restricted as a consequence.
28
28
  `.trim();
@@ -103,17 +103,109 @@ describe("keywordBackstop", () => {
103
103
  expect(keywordBackstop(padded)).toBe("OBVIOUS_RESERVED");
104
104
  });
105
105
  });
106
- // ── callLayer1 over-length test ─────────────────────────────────────────────
107
- describe("callLayer1 over-length", () => {
108
- it("returns UNCERTAIN for prompts > 4000 chars (attacker-controlled length)", async () => {
109
- const longPrompt = "A".repeat(5000);
110
- const result = await callLayer1(longPrompt, "http://localhost:11434", "model");
106
+ // ── callLayer1 over-length (§5.3: UNCERTAIN_LENGTH) ─────────────────────────
107
+ /** Mock fetch returning a fixed classifier verdict. */
108
+ const classifierFetch = (verdict) => vi.fn(async () => new Response(JSON.stringify({ message: { content: verdict } }), { status: 200 }));
109
+ describe("callLayer1 over-length (§5.3)", () => {
110
+ const HEAD = "This is the beginning of a long benign refactoring request. ";
111
+ const MIDDLE = "middle filler content about data processing ".repeat(150);
112
+ const TAIL = " And this is the very end of the request, thanks.";
113
+ const cleanOversize = HEAD + MIDDLE + TAIL; // > 4000 chars, no reserved vocab
114
+ it("full-text keyword floor catches reserved vocab in the UNSEEN middle — no classifier call", async () => {
115
+ const prompt = "A".repeat(3000) + " write a seclusion protocol " + "B".repeat(3000);
116
+ const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
117
+ const result = await callLayer1(prompt, "http://x", "model", fetchSpy);
118
+ expect(result).toBe("OBVIOUS_RESERVED");
119
+ expect(fetchSpy).not.toHaveBeenCalled();
120
+ });
121
+ it("clean oversize prompt with clean excerpt → UNCERTAIN_LENGTH (distinct from UNCERTAIN)", async () => {
122
+ const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
123
+ const result = await callLayer1(cleanOversize, "http://x", "model", fetchSpy);
124
+ expect(result).toBe("UNCERTAIN_LENGTH");
125
+ expect(fetchSpy).toHaveBeenCalledTimes(1);
126
+ });
127
+ it("classifier receives a bounded head+tail excerpt, not the full prompt", async () => {
128
+ const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
129
+ await callLayer1(cleanOversize, "http://x", "model", fetchSpy);
130
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]["body"]);
131
+ const sent = body.messages[0].content;
132
+ expect(sent).toContain(HEAD.trim());
133
+ expect(sent).toContain(TAIL.trim());
134
+ expect(sent).toContain("[…]");
135
+ // bounded: template + excerpt stays under the classifier cap + template overhead
136
+ expect(sent.length).toBeLessThan(6000);
137
+ });
138
+ it("excerpt classified OBVIOUS_RESERVED keeps full reserved handling", async () => {
139
+ const fetchSpy = classifierFetch("OBVIOUS_RESERVED");
140
+ const result = await callLayer1(cleanOversize, "http://x", "model", fetchSpy);
141
+ expect(result).toBe("OBVIOUS_RESERVED");
142
+ });
143
+ it("excerpt classified UNCERTAIN keeps full reserved handling", async () => {
144
+ const fetchSpy = classifierFetch("UNCERTAIN");
145
+ const result = await callLayer1(cleanOversize, "http://x", "model", fetchSpy);
111
146
  expect(result).toBe("UNCERTAIN");
112
147
  });
148
+ it("classifier failure on oversize → UNCERTAIN_LENGTH (keyword floor already cleared full text)", async () => {
149
+ const failingFetch = vi.fn(async () => { throw new Error("connection refused"); });
150
+ const result = await callLayer1(cleanOversize, "http://x", "model", failingFetch);
151
+ expect(result).toBe("UNCERTAIN_LENGTH");
152
+ expect(failingFetch).toHaveBeenCalledTimes(2); // initial + retry
153
+ });
154
+ it("normal-size prompts are unaffected — full prompt sent, verdict passthrough", async () => {
155
+ const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
156
+ const result = await callLayer1("short benign prompt", "http://x", "model", fetchSpy);
157
+ expect(result).toBe("OBVIOUS_NOT_RESERVED");
158
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]["body"]);
159
+ expect(body.messages[0].content).toContain("short benign prompt");
160
+ expect(body.messages[0].content).not.toContain("[…]");
161
+ });
113
162
  it("returns ERROR for empty prompts", async () => {
114
163
  const result = await callLayer1("", "http://localhost:11434", "model");
115
164
  expect(result).toBe("ERROR");
116
165
  });
166
+ it("middle window: content at the prompt's center IS visible to the classifier", async () => {
167
+ const marker = "UNIQUE-CENTER-MARKER-PHRASE";
168
+ const half = "x".repeat(4000);
169
+ const prompt = half + " " + marker + " " + half;
170
+ const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
171
+ await callLayer1(prompt, "http://x", "model", fetchSpy);
172
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]["body"]);
173
+ expect(body.messages[0].content).toContain(marker);
174
+ });
175
+ it("DOCUMENTED RESIDUAL (accepted, adversarial review finding 1): keyword-free paraphrased reserved content outside head/middle/tail windows reaches UNCERTAIN_LENGTH", async () => {
176
+ // A paraphrase with NO RESERVED_KEYWORDS match, placed between the head
177
+ // window and the middle window, is invisible to both the keyword floor
178
+ // and the excerpt. This test PINS the accepted residual risk so any
179
+ // future change to it is deliberate. Mitigations in force: full-text
180
+ // keyword floor (deterministic), head+middle+tail sampling (placement
181
+ // is hard), checkInputSafety on the full prompt, checkOutputSafety on
182
+ // all served output.
183
+ const paraphrase = "describe how staff should pin the person still while they fight";
184
+ const prompt = "y".repeat(2500) + paraphrase + "z".repeat(6000);
185
+ const fetchSpy = classifierFetch("OBVIOUS_NOT_RESERVED");
186
+ const result = await callLayer1(prompt, "http://x", "model", fetchSpy);
187
+ expect(result).toBe("UNCERTAIN_LENGTH");
188
+ const body = JSON.parse(fetchSpy.mock.calls[0][1]["body"]);
189
+ expect(body.messages[0].content).not.toContain("pin the person");
190
+ });
191
+ });
192
+ // ── RESERVED_KEYWORDS additions (adversarial-review bypass closure) ─────────
193
+ describe("keywordBackstop — closed bypasses", () => {
194
+ it.each([
195
+ ["basket hold", "write the basket hold procedure"],
196
+ ["prone hold", "steps for a prone hold on the client"],
197
+ ["two-person hold", "when do we use a two-person hold"],
198
+ ["holding the client down", "staff procedure for holding the client down"],
199
+ ["hold the child down", "how to hold the child down safely"],
200
+ ["rage episode", "what to do during a rage episode"],
201
+ ])("catches %s", (_label, prompt) => {
202
+ expect(keywordBackstop(prompt)).toBe("OBVIOUS_RESERVED");
203
+ });
204
+ it("still clean on benign hold/rage usages", () => {
205
+ expect(keywordBackstop("hold the deploy until CI is green")).toBe("OBVIOUS_NOT_RESERVED");
206
+ expect(keywordBackstop("the on-hold order queue needs a refactor")).toBe("OBVIOUS_NOT_RESERVED");
207
+ expect(keywordBackstop("all the rage in the JS ecosystem")).toBe("OBVIOUS_NOT_RESERVED");
208
+ });
117
209
  });
118
210
  // ── LAYER1_PROMPT drift test ──────────────────────────────────────────────────
119
211
  describe("LAYER1_PROMPT drift", () => {
@@ -23,7 +23,16 @@ const FALLBACK_SCENARIO = [
23
23
  "",
24
24
  "Answer concretely. If you cannot, READ THE FILE FIRST.",
25
25
  ].join("\n");
26
+ /**
27
+ * MCP tool entrypoint. Every tool handler MUST return a CallToolResult
28
+ * object ({ content: [{ type: "text", text }] }) — returning a bare string
29
+ * makes the MCP SDK reject the result ("expected object, received string",
30
+ * -32602). See the dispatch contract in server.ts (result.content usage).
31
+ */
26
32
  export async function verifyBehaviorHandler(args) {
33
+ return { content: [{ type: "text", text: await buildScenarioText(args) }] };
34
+ }
35
+ async function buildScenarioText(args) {
27
36
  if (!SYNALUX_CONFIGURED || !PRISM_SYNALUX_BASE_URL) {
28
37
  return FALLBACK_SCENARIO;
29
38
  }
@@ -30,7 +30,7 @@ import { stripThink } from "../utils/thinkStrip.js";
30
30
  import { passesQualityGate } from "../utils/qualityGate.js";
31
31
  import { checkInputSafety, checkOutputSafety } from "../utils/safetyGate.js";
32
32
  import { callLayer1 as defaultCallLayer1, keywordBackstop } from "../utils/layer1.js";
33
- import { recordInference, recordThinkOnlyRetry, formatInferenceMetrics } from "../utils/inferenceMetrics.js";
33
+ import { recordInference, recordThinkOnlyRetry, formatInferenceMetrics, estimateTokens } from "../utils/inferenceMetrics.js";
34
34
  import { appendInferMetric } from "../storage/inferMetricsLedger.js";
35
35
  // ─── Tool Definition ────────────────────────────────────────────
36
36
  export const PRISM_INFER_TOOL = {
@@ -122,6 +122,26 @@ export const PRISM_INFER_TOOL = {
122
122
  description: "Enable thinking mode (<think> blocks). Default: true for chat/code, false for route. " +
123
123
  "Thinking improves quality on complex tasks but adds latency (~2-5s).",
124
124
  },
125
+ strict_entitlements: {
126
+ type: "boolean",
127
+ description: "Fail loud instead of running with ASSUMED free-tier limits (plan v2 §5.5). " +
128
+ "When true and entitlement resolution fell back to free because the portal " +
129
+ "was unreachable (source='fallback_free'), the call throws instead of " +
130
+ "silently applying free clamps. Portal-confirmed free plans and " +
131
+ "unconfigured machines are unaffected. Default: false.",
132
+ default: false,
133
+ },
134
+ escalation: {
135
+ type: "string",
136
+ enum: ["serve", "report"],
137
+ description: "Failure contract (plan v2 §5.2). 'serve' (default) keeps legacy behavior: " +
138
+ "safety refusals throw, gate-failed output may be served. 'report' returns a " +
139
+ "structured gate_outcome on every terminal path — refused results come back as " +
140
+ "{status:'refused', output:''} instead of an error, and degraded (gate-failed, " +
141
+ "served-anyway) output is explicitly flagged so callers can distinguish " +
142
+ "success / degraded / refused.",
143
+ default: "serve",
144
+ },
125
145
  },
126
146
  required: ["prompt"],
127
147
  },
@@ -158,6 +178,11 @@ export function isPrismInferArgs(args) {
158
178
  return false;
159
179
  if (a.verifier_timeout_ms !== undefined && typeof a.verifier_timeout_ms !== "number")
160
180
  return false;
181
+ if (a.escalation !== undefined &&
182
+ !["serve", "report"].includes(a.escalation))
183
+ return false;
184
+ if (a.strict_entitlements !== undefined && typeof a.strict_entitlements !== "boolean")
185
+ return false;
161
186
  if (a.evidence !== undefined) {
162
187
  if (!Array.isArray(a.evidence))
163
188
  return false;
@@ -278,9 +303,11 @@ export class ReservedRefusalError extends Error {
278
303
  }
279
304
  function makeReservedRefusal(verdict, attempts) {
280
305
  // Ledger the refusal (fire-and-forget). No prompt content is persisted —
281
- // same HIPAA posture as the safety_gate exclusion.
306
+ // same HIPAA posture as the safety_gate exclusion. gate_outcome mirrors
307
+ // the §5.2 report-mode row so refusal queries see both modes.
282
308
  appendInferMetric({
283
309
  backend: "refused", model: null, used_cloud: false,
310
+ gate_outcome: "refused",
284
311
  refusal_reason: "layer1_reserved",
285
312
  });
286
313
  return new ReservedRefusalError(verdict, attempts);
@@ -396,9 +423,20 @@ export async function runInfer(args, deps) {
396
423
  };
397
424
  }
398
425
  // ── Entitlement enforcement ──────────────────────────────────
399
- // Fetch user's plan limits (cached 1hr). Free users without auth
400
- // get 4b ceiling, 50 calls/day, 512 max tokens.
426
+ // Resolved per call (§5.5) getEntitlements dedupes via a 5-min cache.
427
+ // Free users without auth get 4b ceiling, 50 calls/day, 512 max tokens.
401
428
  const ent = deps.entitlements ?? await getEntitlements();
429
+ const entSource = ent.source ?? "portal";
430
+ // §5.5 fail-loud: "fallback_free" means auth IS configured but the
431
+ // portal couldn't be reached and no cached plan exists — the free-tier
432
+ // clamps below would be an ASSUMPTION, not the user's plan. Strict
433
+ // callers refuse to run on assumptions. This is an infrastructure
434
+ // failure, not a safety refusal — it throws in both escalation modes.
435
+ if (args.strict_entitlements && entSource === "fallback_free") {
436
+ throw new Error("prism_infer: entitlements_unavailable — portal resolution failed (source=fallback_free) " +
437
+ "and strict_entitlements=true; refusing to run with assumed free-tier limits. " +
438
+ "Retry, or drop strict_entitlements to accept free clamps.");
439
+ }
402
440
  // MF2: In chat/code modes, request the 27B tier (subject to plan ceiling + RAM).
403
441
  // mode:"code" implies quality → start higher in the cascade.
404
442
  const mode = args.mode ?? "route";
@@ -415,6 +453,25 @@ export async function runInfer(args, deps) {
415
453
  const attempts = [];
416
454
  // Strip verification args if plan lacks grounding_verifier
417
455
  const gatedArgs = canVerify ? args : { ...args, verify: false, evidence: undefined };
456
+ // §5.2 failure contract: under escalation:"report", safety refusals return
457
+ // a typed result (output:"") instead of throwing. Infra exhaustion (no
458
+ // backend produced output) still throws in BOTH modes — an infrastructure
459
+ // failure is not a refusal (§5.1 distinction).
460
+ const wantReport = args.escalation === "report";
461
+ // Shared per-result entitlement metadata (§5.5) — spread into every
462
+ // terminal result so callers can audit which plan/provenance applied.
463
+ const entMeta = { plan: ent.plan, entitlements_source: entSource };
464
+ const refusedResult = (reason) => ({
465
+ output: "",
466
+ backend: "refused",
467
+ model_picked: null,
468
+ ram_free_mb: ramFreeMb,
469
+ latency_ms: Date.now() - t0,
470
+ used_cloud: false,
471
+ attempts,
472
+ ...entMeta,
473
+ gate_outcome: { status: "refused", reason, served_anyway: false },
474
+ });
418
475
  debugLog(`[prism_infer] plan=${ent.plan} ceiling=${effectiveCeiling} max_tokens=${maxTokens} cloud=${allowCloud} verify=${canVerify}`);
419
476
  // Log tier enforcement to Datadog for monetization visibility
420
477
  const ceilingClamped = effectiveCeiling !== (args.model_ceiling ?? ent.model_ceiling);
@@ -423,7 +480,7 @@ export async function runInfer(args, deps) {
423
480
  const verifierBlocked = (args.verify === true || (args.evidence?.length ?? 0) > 0) && !canVerify;
424
481
  if (ceilingClamped || tokensClamped || cloudBlocked || verifierBlocked) {
425
482
  ddLog("info", "prism_infer.tier_enforcement", {
426
- plan: ent.plan,
483
+ ...entMeta,
427
484
  requested_ceiling: args.model_ceiling,
428
485
  effective_ceiling: effectiveCeiling,
429
486
  ceiling_clamped: ceilingClamped,
@@ -473,6 +530,8 @@ export async function runInfer(args, deps) {
473
530
  const weakBackend = /^(ollama-|openrouter-)/.test(cloud.backend ?? "");
474
531
  if (weakBackend) {
475
532
  attempts.push({ tier: "synalux", reason: `reserved_weak_backend:${cloud.backend}` });
533
+ if (wantReport)
534
+ return refusedResult("layer1_reserved");
476
535
  throw makeReservedRefusal(l1, attempts);
477
536
  }
478
537
  return await applyVerification(cloud.output, gatedArgs, deps, {
@@ -482,14 +541,27 @@ export async function runInfer(args, deps) {
482
541
  latency_ms: Date.now() - t0,
483
542
  used_cloud: true,
484
543
  attempts,
485
- plan: ent.plan,
544
+ ...entMeta,
486
545
  completion_tokens: Math.ceil(cloud.output.length / 4),
546
+ gate_outcome: { status: "success", served_anyway: false },
487
547
  });
488
548
  }
489
549
  attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
490
550
  }
551
+ if (wantReport)
552
+ return refusedResult("layer1_reserved");
491
553
  throw makeReservedRefusal(l1, attempts);
492
554
  }
555
+ if (l1 === "UNCERTAIN_LENGTH") {
556
+ // §5.3: prompt too long to classify in full, but the full-text
557
+ // keyword floor was clean AND the head+tail excerpt classified
558
+ // clean. Proceed to the local tier walk with a distinct audit
559
+ // marker — "too long to classify" is not a safety verdict.
560
+ // Whether the prompt FITS a local tier's context is the §5.4
561
+ // ctx gate's job, not Layer 1's.
562
+ debugLog(`[prism_infer] Layer 1 verdict=UNCERTAIN_LENGTH — oversize prompt cleared by keyword floor + excerpt, proceeding local`);
563
+ attempts.push({ tier: "layer1", reason: "layer1_uncertain_length" });
564
+ }
493
565
  if (l1 === "ERROR") {
494
566
  debugLog(`[prism_infer] Layer 1 verdict=ERROR — classifier failed, trying cloud then keyword backstop`);
495
567
  attempts.push({ tier: "layer1", reason: "layer1_error" });
@@ -504,8 +576,9 @@ export async function runInfer(args, deps) {
504
576
  latency_ms: Date.now() - t0,
505
577
  used_cloud: true,
506
578
  attempts,
507
- plan: ent.plan,
579
+ ...entMeta,
508
580
  completion_tokens: Math.ceil(cloud.output.length / 4),
581
+ gate_outcome: { status: "success", served_anyway: false },
509
582
  });
510
583
  }
511
584
  attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
@@ -514,10 +587,21 @@ export async function runInfer(args, deps) {
514
587
  debugLog(`[prism_infer] keyword backstop verdict=${backstop}`);
515
588
  attempts.push({ tier: "keyword_backstop", reason: `backstop_${backstop.toLowerCase()}` });
516
589
  if (backstop === "OBVIOUS_RESERVED") {
590
+ if (wantReport)
591
+ return refusedResult("keyword_backstop_reserved");
592
+ // Serve-mode backstop refusal previously wrote NO ledger row —
593
+ // ledger it like every other refusal (no prompt content persisted).
594
+ appendInferMetric({
595
+ backend: "refused", model: null, used_cloud: false,
596
+ gate_outcome: "refused",
597
+ refusal_reason: "keyword_backstop_reserved",
598
+ });
517
599
  throw new Error(`prism_infer: classifier failed + keyword backstop caught reserved content. attempts=${JSON.stringify(attempts)}`);
518
600
  }
519
601
  }
520
- debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
602
+ if (l1 === "OBVIOUS_NOT_RESERVED") {
603
+ debugLog(`[prism_infer] Layer 1 verdict=OBVIOUS_NOT_RESERVED — proceeding local`);
604
+ }
521
605
  }
522
606
  // ── end Layer 1 ─────────────────────────────────────────────────────────
523
607
  // Walk the tier table top → bottom, capped by model_ceiling. Each tier
@@ -603,6 +687,23 @@ export async function runInfer(args, deps) {
603
687
  attempts.push({ tier: tier.tag, reason: "ram_insufficient" });
604
688
  continue;
605
689
  }
690
+ // Ctx gate (§5.4): skip tiers whose live Modelfile num_ctx cannot
691
+ // hold the PROMPT (+ system + small template margin). Ollama
692
+ // silently truncates an over-ctx prompt and answers from the
693
+ // fragment — "never silent truncation" (plan §7). Generated
694
+ // tokens shift the window rather than truncate the prompt, so
695
+ // max_tokens is deliberately NOT reserved here — requiring
696
+ // prompt+output ≤ ctx would make a max_tokens=4096 request
697
+ // unroutable to the 4096-ctx tiers even for tiny prompts.
698
+ // ctxTokens mirrors the live Modelfile values (see MODEL_TIERS).
699
+ const CTX_TEMPLATE_MARGIN = 64;
700
+ const promptTokensEst = estimateTokens(args.prompt)
701
+ + (args.system ? estimateTokens(args.system) : 0)
702
+ + CTX_TEMPLATE_MARGIN;
703
+ if (promptTokensEst > tier.ctxTokens) {
704
+ attempts.push({ tier: tier.tag, reason: "ctx_insufficient" });
705
+ continue;
706
+ }
606
707
  anyViable = true;
607
708
  const timeout = args.timeout_ms ?? DEFAULT_TIMEOUTS[tier.tag] ?? 60_000;
608
709
  const enableThink = args.think ?? (mode !== "route");
@@ -624,11 +725,14 @@ export async function runInfer(args, deps) {
624
725
  debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — escalating to cloud`);
625
726
  attempts.push({ tier: tier.tag, reason: `quality_gate:${gate.reason}` });
626
727
  if (gate.reason === "hard_truncation" || gate.reason === "loop_detected") {
627
- localDraft = { output, tier: tier.tag, promptTokens: result.promptTokens, completionTokens: result.completionTokens };
728
+ localDraft = { output, tier: tier.tag, gateReason: gate.reason, promptTokens: result.promptTokens, completionTokens: result.completionTokens };
628
729
  }
629
730
  break;
630
731
  }
631
732
  if (!gate.pass) {
733
+ // §5.2: this served-anyway path used to be silent — the result
734
+ // carried no flag at all. Now both quality_gate_failed and
735
+ // gate_outcome mark it degraded.
632
736
  debugLog(`[prism_infer] quality gate FAIL (${gate.reason}) — no cloud, serving local`);
633
737
  }
634
738
  return await applyVerification(output, gatedArgs, deps, {
@@ -638,9 +742,13 @@ export async function runInfer(args, deps) {
638
742
  latency_ms: Date.now() - t0,
639
743
  used_cloud: false,
640
744
  attempts,
641
- plan: ent.plan,
745
+ ...entMeta,
642
746
  prompt_tokens: result.promptTokens,
643
747
  completion_tokens: result.completionTokens,
748
+ quality_gate_failed: gate.pass ? undefined : true,
749
+ gate_outcome: gate.pass
750
+ ? { status: "success", served_anyway: false }
751
+ : { status: "degraded", reason: gate.reason, served_anyway: true },
644
752
  });
645
753
  }
646
754
  attempts.push({ tier: tier.tag, reason: result.reason });
@@ -663,11 +771,12 @@ export async function runInfer(args, deps) {
663
771
  latency_ms: Date.now() - t0,
664
772
  used_cloud: true,
665
773
  attempts,
666
- plan: ent.plan,
774
+ ...entMeta,
667
775
  // T4: omit prompt_tokens — cloud doesn't return Ollama actual eval count.
668
776
  // recordInference receives prompt_text and computes submittedEst via
669
777
  // estimateTokens(), keeping promptTokensEvaluated=0 (correct for cloud).
670
778
  completion_tokens: Math.ceil(cloud.output.length / 4),
779
+ gate_outcome: { status: "success", served_anyway: false },
671
780
  });
672
781
  }
673
782
  attempts.push({ tier: "synalux", reason: cloud.reason ?? "unknown" });
@@ -685,10 +794,11 @@ export async function runInfer(args, deps) {
685
794
  latency_ms: Date.now() - t0,
686
795
  used_cloud: false,
687
796
  attempts,
688
- plan: ent.plan,
797
+ ...entMeta,
689
798
  prompt_tokens: localDraft.promptTokens,
690
799
  completion_tokens: localDraft.completionTokens,
691
800
  quality_gate_failed: true,
801
+ gate_outcome: { status: "degraded", reason: localDraft.gateReason, served_anyway: true },
692
802
  });
693
803
  }
694
804
  const err = new Error(`prism_infer: no backend produced output. attempts=${JSON.stringify(attempts)}, free=${fmtGb(freeBytes)}`);
@@ -785,6 +895,12 @@ export async function prismInferHandler(args) {
785
895
  ` used_cloud=${result.used_cloud}` +
786
896
  tokenStr +
787
897
  (result.quality_gate_failed ? ` quality_gate_failed=true` : "") +
898
+ (result.gate_outcome && result.gate_outcome.status !== "success"
899
+ ? ` gate=${result.gate_outcome.status}${result.gate_outcome.reason ? `:${result.gate_outcome.reason}` : ""}`
900
+ : "") +
901
+ (result.entitlements_source && result.entitlements_source !== "portal"
902
+ ? ` ent_source=${result.entitlements_source}`
903
+ : "") +
788
904
  (result.verification ? ` verify=${result.verification.action}` : "") +
789
905
  (result.attempts.length ? ` attempts=${JSON.stringify(result.attempts)}` : "");
790
906
  // Append periodic session-level stats to the header line.
@@ -31,6 +31,13 @@ export const FREE_ENTITLEMENTS = {
31
31
  const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
32
32
  let cache = null;
33
33
  let inFlight = null;
34
+ // §5.5 negative cache: fallback_free is kept OUT of the main 5-min cache
35
+ // (recovery must not wait a full TTL), but an un-cached failure would make
36
+ // every sequential call during an outage pay the full JWT+fetch attempt
37
+ // (worst case ~10s on a hanging network). A short negative TTL caps that
38
+ // amplification while still retrying promptly.
39
+ const FALLBACK_NEGATIVE_TTL_MS = 25_000;
40
+ let negativeCache = null;
34
41
  // ── Model tier ordering for ceiling enforcement ───────────────────
35
42
  const TIER_ORDER = ["2b", "4b", "9b", "27b"];
36
43
  /**
@@ -63,12 +70,12 @@ export function clampCeiling(requested, planCeiling) {
63
70
  async function fetchEntitlements() {
64
71
  if (!SYNALUX_CONFIGURED || !PRISM_SYNALUX_BASE_URL) {
65
72
  debugLog("[entitlements] no Synalux auth configured — free tier");
66
- return FREE_ENTITLEMENTS;
73
+ return { ...FREE_ENTITLEMENTS, source: "unconfigured" };
67
74
  }
68
75
  const jwt = await getSynaluxJwt();
69
76
  if (!jwt) {
70
- debugLog("[entitlements] JWT exchange failed — free tier fallback");
71
- return FREE_ENTITLEMENTS;
77
+ debugLog("[entitlements] JWT exchange failed — free tier fallback (fallback_free)");
78
+ return { ...FREE_ENTITLEMENTS, source: "fallback_free" };
72
79
  }
73
80
  try {
74
81
  const url = `${PRISM_SYNALUX_BASE_URL}/api/v1/prism/entitlements`;
@@ -84,15 +91,18 @@ async function fetchEntitlements() {
84
91
  debugLog("[entitlements] using last-known-good (safety fail-closed)");
85
92
  return cache.entitlements;
86
93
  }
87
- return FREE_ENTITLEMENTS;
94
+ return { ...FREE_ENTITLEMENTS, source: "fallback_free" };
88
95
  }
89
96
  const data = (await res.json());
90
97
  if (!data.plan || !data.model_ceiling) {
91
98
  debugLog("[entitlements] malformed response");
92
99
  if (cache)
93
100
  return cache.entitlements;
94
- return FREE_ENTITLEMENTS;
101
+ return { ...FREE_ENTITLEMENTS, source: "fallback_free" };
95
102
  }
103
+ // §5.5: provenance — this is REAL portal data (a portal "free" plan
104
+ // gets source:"portal", distinguishing it from an assumed fallback).
105
+ data.source = "portal";
96
106
  // Normalize legacy ceiling values to the current fleet.
97
107
  if (data.model_ceiling === "14b") {
98
108
  debugLog("[entitlements] grandfathered 14b ceiling → 9b");
@@ -114,20 +124,27 @@ async function fetchEntitlements() {
114
124
  debugLog("[entitlements] using last-known-good (safety fail-closed)");
115
125
  return cache.entitlements;
116
126
  }
117
- debugLog("[entitlements] no cached entitlements — free tier fallback (cold start)");
118
- return FREE_ENTITLEMENTS;
127
+ debugLog("[entitlements] no cached entitlements — free tier fallback (cold start, fallback_free)");
128
+ return { ...FREE_ENTITLEMENTS, source: "fallback_free" };
119
129
  }
120
130
  }
121
131
  // ── Public API ────────────────────────────────────────────────────
122
132
  /**
123
- * Get the current user's entitlements (cached for 1 hour).
124
- * Concurrent callers share a single in-flight fetch.
133
+ * Get the current user's entitlements (5-minute cache; resolved per call —
134
+ * plan v2 §5.5). Concurrent callers share a single in-flight fetch.
135
+ * fallback_free results are never cached, so degraded resolution retries
136
+ * on the next call.
125
137
  */
126
138
  export async function getEntitlements() {
127
139
  const now = Date.now();
128
140
  if (cache && cache.expiresAt > now) {
129
141
  return cache.entitlements;
130
142
  }
143
+ // §5.5: within the short negative window after a fallback_free
144
+ // resolution, return it without re-attempting the portal.
145
+ if (negativeCache && negativeCache.until > now) {
146
+ return negativeCache.entitlements;
147
+ }
131
148
  if (inFlight)
132
149
  return inFlight;
133
150
  inFlight = (async () => {
@@ -136,11 +153,20 @@ export async function getEntitlements() {
136
153
  // Only update cache if this is a REAL fetch (not a cached fallback).
137
154
  // fetchEntitlements returns cache.entitlements on error — detect by
138
155
  // checking if the returned object is the exact same reference.
139
- const isFallback = cache && ent === cache.entitlements;
156
+ // §5.5: fallback_free results are also never cached — pinning an
157
+ // assumed-free degradation for the TTL would delay recovery; the
158
+ // next call retries the portal instead.
159
+ const isFallback = (cache && ent === cache.entitlements) || ent.source === "fallback_free";
140
160
  if (!isFallback) {
141
161
  cache = { entitlements: ent, expiresAt: Date.now() + CACHE_TTL_MS };
142
162
  }
143
163
  // On fallback: DON'T refresh expiresAt — let it expire so we retry.
164
+ if (ent.source === "fallback_free") {
165
+ negativeCache = { entitlements: ent, until: Date.now() + FALLBACK_NEGATIVE_TTL_MS };
166
+ }
167
+ else {
168
+ negativeCache = null;
169
+ }
144
170
  return ent;
145
171
  }
146
172
  finally {
@@ -154,11 +180,13 @@ export async function getEntitlements() {
154
180
  */
155
181
  export function invalidateEntitlements() {
156
182
  cache = null;
183
+ negativeCache = null;
157
184
  }
158
185
  /** Test-only: reset all state. */
159
186
  export function _resetEntitlementsForTest() {
160
187
  cache = null;
161
188
  inFlight = null;
189
+ negativeCache = null;
162
190
  }
163
191
  /** Test-only: inject a cached entitlement. */
164
192
  export function _setCacheForTest(ent, ttlMs = CACHE_TTL_MS) {
@@ -9,9 +9,9 @@
9
9
  * stateless MCP), pointed at free-form generation instead of tool-call
10
10
  * responses.
11
11
  *
12
- * Cascade role: prism-coder:4b is the default verifier (fast, 2.5GB).
12
+ * Cascade role: qwen3.5:4b is the default verifier (fast, 2.5GB).
13
13
  * 14b drafts; 4b verifies. Different model = Patronus rule satisfied.
14
- * Falls back to 1b7 on devices with <4GB free RAM.
14
+ * Falls back to 2b on devices with <4GB free RAM.
15
15
  *
16
16
  * Failure modes:
17
17
  * - Verifier model unreachable / timeout → fail-closed refusal
@@ -93,7 +93,7 @@ function refusalText(action, failedClaim) {
93
93
  }
94
94
  }
95
95
  export async function verifyGrounding(opts) {
96
- const verifierModel = opts.verifierModel ?? "prism-coder:4b";
96
+ const verifierModel = opts.verifierModel ?? "qwen3.5:4b";
97
97
  const timeoutMs = opts.timeoutMs ?? 2000;
98
98
  const ollamaUrl = opts.ollamaUrl ?? PRISM_LOCAL_LLM_URL;
99
99
  const fetchImpl = opts.fetchImpl ?? fetch;
@@ -55,17 +55,35 @@ export function recordInference(result) {
55
55
  return;
56
56
  // Durable ledger row (fire-and-forget; in-memory counters below remain the
57
57
  // per-session view). safety_gate is excluded by the early return above.
58
+ // §5.2: prefer the structured gate_outcome; keep the legacy
59
+ // "gate_failed_served" string for degraded rows so existing ledger
60
+ // queries stay valid. Refused rows (escalation:"report") carry the
61
+ // refusal reason — the serve-mode throw paths ledger directly at the
62
+ // refusal site (makeReservedRefusal / backstop), so exactly one row
63
+ // is written either way.
64
+ const gateOutcomeStr = result.gate_outcome
65
+ ? (result.gate_outcome.status === "degraded" ? "gate_failed_served" : result.gate_outcome.status)
66
+ : (result.quality_gate_failed ? "gate_failed_served" : undefined);
58
67
  appendInferMetric({
59
68
  backend: result.backend,
60
69
  model: result.model_picked,
61
70
  used_cloud: result.used_cloud,
62
71
  mode: result.mode,
63
- gate_outcome: result.quality_gate_failed ? "gate_failed_served" : undefined,
72
+ gate_outcome: gateOutcomeStr,
73
+ refusal_reason: result.gate_outcome?.status === "refused" ? result.gate_outcome.reason : undefined,
64
74
  prompt_tokens: result.prompt_tokens,
65
75
  completion_tokens: result.completion_tokens,
66
76
  latency_ms: result.latency_ms,
67
77
  ram_free_mb: result.ram_free_mb,
68
78
  });
79
+ // §5.2: refused results (escalation:"report") get a ledger row above but
80
+ // must NOT touch the session accumulators — no model ran and nothing was
81
+ // served, so counting them as local serves would inflate local% and
82
+ // cloudTokensSavedEst (the GATE 6 KPI: "improves only when local inference
83
+ // replaces a cloud call"). Serve-mode refusals throw and never reach here,
84
+ // so skipping keeps both modes' accounting identical.
85
+ if (result.backend === "refused")
86
+ return;
69
87
  const key = result.model_picked ?? result.backend;
70
88
  if (result.used_cloud) {
71
89
  cloudCalls++;