@ngockhoale/ukit 3.1.1 → 3.1.3

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/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  All notable changes to UKit are documented here.
4
4
 
5
+ ## 3.1.3 - 2026-09-26
6
+
7
+ - **`unic-decision-multilingual` becomes the shipped default checkpoint** — the variant is credential-bound on UNIC Provider (verified via live `health-probe` + `safe-vs-risky-lane` decision). `english`/`unknownLanguage` classes keep the bare `unic-decision` alias; `model_not_found` auto-fallback still covers unbound variants.
8
+
9
+ ## 3.1.2 - 2026-09-26
10
+
11
+ - **Decision plane goes live on the UNIC provider** — the decision lane now resolves its own endpoint independent of the engine's LLM gateway, so UKit uses UNIC even when Claude Code/Codex/omp point elsewhere:
12
+ - **Endpoint chain**: `~/.ukit/storage/gateway.json` `{decision:{baseUrl,apiKey,scheme}}` → env `UKIT_DECISION_BASE_URL`/`UKIT_DECISION_API_KEY` → default `https://openai.unicjsc.com`; credential falls back to the shared UNIC token (ANTHROPIC_*). New resolvers `resolveDecisionBaseUrl`/`resolveDecisionApiKey` in `src/core/gatewayProbe.js`, mirrored in `unic-decision.mjs`.
13
+ - **Live transport fixes** (verified against the real gateway): SSE `data: [DONE]` trailer — sometimes glued to the JSON body — is stripped via balanced-object extraction; model answers use native arg names (`choice`/`noul`/`score`) now accepted alongside the schema's `value`; a `model_not_found` variant checkpoint retries once on the bare `unic-decision` alias.
14
+ - **Checkpoints**: shipped defaults collapse to the bare `unic-decision` model (the credential-safe alias); `unic-decision-multilingual`/variants remain configurable via `decisionPlane.checkpoints`.
15
+ - **Optional backlog drained**: injection-point contracts documented (deliberation `judge`, vmEngine `classifyFn`, completionGate `modelJudgment`), `memoryV2.decision` plane marked reserved, `resume.next-action.v1` + `verify.depth.v1` shadow questions wired, gatewayProbe marked migration-exempt.
16
+
5
17
  ## 3.1.1 - 2026-09-26
6
18
 
7
19
  - **Decision plane — review + workflow families (UNIC_DECISION_MIGRATION S1–S4)** — the reserved `review` and `workflow` registry families are now populated and wired; all keys ship `rolloutStage: 'off'` so deterministic fallbacks stay authoritative (stage promotion is a separate owner decision):
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ngockhoale/ukit",
3
- "version": "3.1.1",
3
+ "version": "3.1.3",
4
4
  "description": "Install/update an index-first AI workspace for Claude Code, OpenAI Codex and omp (Oh My Pi).",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -152,7 +152,10 @@ export function evaluateCompletionEvidence({
152
152
  verdict = 'FAILED';
153
153
  }
154
154
 
155
- // modelJudgment may only request more evidence, never promote.
155
+ // modelJudgment may only request more evidence, never promote (enforced
156
+ // below: a judgment can downgrade PASS → MORE_EVIDENCE, nothing else). When
157
+ // model-backed, the judgment MUST originate from the decision plane
158
+ // (unic-decision, a bounded verdict question) — never a general LLM call.
156
159
  const judgment = modelJudgment != null
157
160
  ? (typeof modelJudgment === 'string' ? modelJudgment : String(modelJudgment))
158
161
  : null;
@@ -85,6 +85,11 @@ async function writeFileAtomic(target, data) {
85
85
  }
86
86
 
87
87
  /**
88
+ * Migration contract (decision plane): `classifyFn` is invoked only when the
89
+ * IR itself cannot route an event (see deliver()). It may only consult the
90
+ * decision plane (unic-decision / createDecisionClient registry keys) for a
91
+ * bounded route/escalate answer — never a general LLM call.
92
+ *
88
93
  * @param {{dir:string, now?:()=>Date, classifyFn?:Function, startFn?:Function, hooks?:object}} opts
89
94
  */
90
95
  export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
@@ -512,7 +517,9 @@ export function createVmEngine({ dir, now, classifyFn, startFn, hooks } = {}) {
512
517
  return { consumed: false, code: 'out_of_order' };
513
518
  }
514
519
 
515
- // classify only when the IR itself cannot route the event (G4-FR05)
520
+ // classify only when the IR itself cannot route the event (G4-FR05);
521
+ // classifyFn may consult the decision plane for bounded route/escalate
522
+ // answers only — never a general LLM call (see createVmEngine opts).
516
523
  let outcome = event.safePayload?.outcome;
517
524
  let targetMatched = matched;
518
525
  if (!targetMatched) {
@@ -244,6 +244,12 @@ function deepFreezeCopy(value) {
244
244
  * verdict is post-checked: hard-constraint waivers/violations and
245
245
  * majority-only (citation-free) verdicts are rejected.
246
246
  *
247
+ * Migration contract (decision plane): an injected `judge` implementation
248
+ * that performs bounded verdict work MUST route it through the decision
249
+ * plane (`createDecisionClient` + `src/decision/registry.js` keys) — never a
250
+ * general LLM call. The default `smartJudge` is deterministic: pure scoring,
251
+ * hard-constraint filtering, and a stable sort — no I/O, no model calls.
252
+ *
247
253
  * @param {{candidates: object[], rubric: object, judge: Function,
248
254
  * config?: object}} input
249
255
  * @returns {{status: 'ok'|'disabled'|'rejected', selected?: object|null,
@@ -23,6 +23,52 @@ import path from 'node:path';
23
23
 
24
24
  const GATEWAY_DOC = 'docs/GATEWAY.md';
25
25
  const SETTINGS_RELATIVE_PROBE_PATH = path.join('.claude', 'settings.json');
26
+ // --- Decision-plane gateway resolution (UNIC_DECISION_MIGRATION backlog) -----
27
+ // The decision plane is a SEPARATE lane from the host's LLM endpoint: it must
28
+ // reach the UNIC OpenAI-compatible provider no matter what Claude Code/omp is
29
+ // configured to. Chain: ~/.ukit/storage/gateway.json {decision:{baseUrl,
30
+ // apiKey, scheme}} → env UKIT_DECISION_BASE_URL / UKIT_DECISION_API_KEY →
31
+ // DEFAULT_DECISION_BASE_URL + the shared UNIC token (ANTHROPIC_*) so a host
32
+ // already pointed at UNIC works with zero extra config. Missing key → the
33
+ // caller surfaces 'unavailable' and deterministic policy stays authoritative.
34
+ export const DEFAULT_DECISION_BASE_URL = 'https://openai.unicjsc.com';
35
+ const USER_GATEWAY_REL = path.join('.ukit', 'storage', 'gateway.json');
36
+
37
+ function readUserGatewayDecision(homeDir) {
38
+ if (!homeDir) return null;
39
+ const json = safeReadJson(path.join(homeDir, USER_GATEWAY_REL));
40
+ const decision = json?.decision;
41
+ return decision && typeof decision === 'object' ? decision : null;
42
+ }
43
+
44
+ // Endpoint for the decision lane. Never throws; file and env malformed values
45
+ // are ignored, the default is the final fallback (not ANTHROPIC_BASE_URL —
46
+ // that env may point at a non-decision gateway).
47
+ export function resolveDecisionBaseUrl({ homeDir = os.homedir(), env = process.env } = {}) {
48
+ const file = readUserGatewayDecision(homeDir);
49
+ const fromFile = pickString(file?.baseUrl);
50
+ if (fromFile) return { baseUrl: fromFile, source: 'ukit-gateway-json' };
51
+ const fromEnv = pickString(env?.UKIT_DECISION_BASE_URL);
52
+ if (fromEnv) return { baseUrl: fromEnv, source: 'env' };
53
+ return { baseUrl: DEFAULT_DECISION_BASE_URL, source: 'default' };
54
+ }
55
+
56
+ // API key for the decision lane. `scheme`: 'auth-token' → Authorization: Bearer
57
+ // (UNIC OpenAI-compatible default) | 'api-key' → x-api-key. Falls back to the
58
+ // host's UNIC token so existing installs need no duplicate credential.
59
+ export function resolveDecisionApiKey({ projectRoot, homeDir = os.homedir(), env = process.env } = {}) {
60
+ const file = readUserGatewayDecision(homeDir);
61
+ const fileKey = pickString(file?.apiKey);
62
+ if (fileKey) {
63
+ const scheme = file?.scheme === 'api-key' ? 'api-key' : 'auth-token';
64
+ return { value: fileKey, scheme, source: 'ukit-gateway-json' };
65
+ }
66
+ const envKey = pickString(env?.UKIT_DECISION_API_KEY);
67
+ if (envKey) return { value: envKey, scheme: 'auth-token', source: 'env' };
68
+ const shared = resolveGatewayApiKey({ projectRoot, homeDir, env });
69
+ if (shared?.value) return { ...shared, source: 'shared-unic-token' };
70
+ return { value: null, scheme: null, source: null };
71
+ }
26
72
 
27
73
  function safeReadFile(filePath) {
28
74
  try {
@@ -491,6 +537,13 @@ async function runNonStreamingProbe({ url, headers, body: requestBody, fetchImpl
491
537
  }
492
538
 
493
539
  /**
540
+ * Migration-contract exemption (decision plane): the two `fetchImpl` calls below
541
+ * are the ONLY generic-LLM HTTP calls under src/. They are diagnostic-only —
542
+ * connectivity probes that send a trivial "pong?" message and measure the
543
+ * transport (SSE events, buffering, Anthropic Message shape) — and are
544
+ * intentionally NOT routed through the decision plane: no bounded verdict
545
+ * vocabulary exists for a connectivity probe. Keep-as-probe; do not migrate.
546
+ *
494
547
  * Runs both probes against `baseUrl`. Returns a structured result; never propagates exceptions
495
548
  * — every failure is reflected in the returned object so the CLI can print it.
496
549
  *
@@ -4,6 +4,12 @@
4
4
  // resolveConfigStage machinery: off → shadow → canary → default. Flags only
5
5
  // ever REDUCE capability — malformed/missing resolves 'off', killSwitch is
6
6
  // absolute, and 'canary' without an opted-in projectId resolves 'off'.
7
+ // The `decision` plane is reserved for the memoryV2 candidate-classification
8
+ // lane (registry key `learn.candidate-class.v1`, family 'learn'). Note the
9
+ // decision-plane stage namespace is separate: promotion gates that key via
10
+ // `decisionPlane.families.learn.stage`, NOT `memoryV2.decision.stage` — this
11
+ // flag only guards the memoryV2 lane itself. Zero consumers today — do not
12
+ // remove; user configs may pin its stage key.
7
13
  //
8
14
  // Shadow receipts are the only telemetry: bounded JSONL of counts/codes/
9
15
  // latency bands — never record text, prompt bodies, or secrets.
@@ -304,9 +304,9 @@ export function buildDefaultRuntimeConfig(overrides = {}) {
304
304
  stage: 'off',
305
305
  logicalModel: 'unic-decision',
306
306
  checkpoints: {
307
- default: 'unic-decision/laya-multilingual',
308
- english: 'unic-decision/laya',
309
- unknownLanguage: 'unic-decision/auto',
307
+ default: 'unic-decision-multilingual',
308
+ english: 'unic-decision',
309
+ unknownLanguage: 'unic-decision',
310
310
  },
311
311
  timeoutMs: 5000,
312
312
  maxStateTokens: { multilingual: 1024, english: 512 },
@@ -27,8 +27,8 @@
27
27
 
28
28
  import { createHash } from 'node:crypto';
29
29
  import {
30
- resolveGatewayBaseUrl,
31
- resolveGatewayApiKey,
30
+ resolveDecisionBaseUrl,
31
+ resolveDecisionApiKey,
32
32
  } from '../core/gatewayProbe.js';
33
33
  import {
34
34
  encodeBatch,
@@ -42,11 +42,19 @@ import {
42
42
  } from './statePacket.js';
43
43
 
44
44
  const DEFAULT_CHECKPOINTS = {
45
- default: 'unic-decision/laya-multilingual',
46
- english: 'unic-decision/laya',
47
- unknownLanguage: 'unic-decision/auto',
45
+ // 'unic-decision-multilingual' is credential-bound (verified live
46
+ // 2026-09-26) and is the shipped default; english/unknown classes keep the
47
+ // bare 'unic-decision' alias until laya/auto variants are bound.
48
+ default: 'unic-decision-multilingual',
49
+ english: 'unic-decision',
50
+ unknownLanguage: 'unic-decision',
48
51
  };
49
52
 
53
+ // Credential-safe alias: every UNIC decision credential binds the bare name
54
+ // first — variants (unic-decision-multilingual, …) may lag. On model_not_found
55
+ // the client falls back here once per batch.
56
+ const FALLBACK_CHECKPOINT = 'unic-decision';
57
+
50
58
  const DEFAULT_TIMEOUT_MS = 5000;
51
59
  const DEFAULT_MAX_RETRIES = 1;
52
60
  const DEFAULT_BREAKER = { failureThreshold: 3, cooldownMs: 30_000 };
@@ -102,9 +110,44 @@ function isTransientStatus(status) {
102
110
  return status === 429 || (status >= 500 && status <= 599);
103
111
  }
104
112
 
113
+ // The UNIC decision endpoint streams the completion even without stream:true —
114
+ // the body is one JSON object followed by an SSE "data: [DONE]" trailer.
115
+ // Parse the first JSON object, ignore the trailer.
116
+ function parseMaybeSse(text) {
117
+ const trimmed = String(text ?? '').trim();
118
+ if (!trimmed.startsWith('{')) {
119
+ // Pure SSE frames: join data payloads.
120
+ const payload = trimmed
121
+ .split('\n')
122
+ .filter((line) => line.startsWith('data:') && !line.includes('[DONE]'))
123
+ .map((line) => line.slice(5).trim())
124
+ .join('');
125
+ return JSON.parse(payload || trimmed);
126
+ }
127
+ // JSON object possibly followed by a glued SSE trailer (`}data: [DONE]`,
128
+ // observed live) — extract the balanced object, strings/comments aware.
129
+ let depth = 0;
130
+ for (let i = 0; i < trimmed.length; i += 1) {
131
+ const ch = trimmed[i];
132
+ if (ch === '"' || ch === "'") {
133
+ for (let j = i + 1; j < trimmed.length; j += 1) {
134
+ if (trimmed[j] === '\\') j += 1;
135
+ else if (trimmed[j] === ch) { i = j; break; }
136
+ }
137
+ continue;
138
+ }
139
+ if (ch === '{') depth += 1;
140
+ else if (ch === '}') {
141
+ depth -= 1;
142
+ if (depth === 0) return JSON.parse(trimmed.slice(0, i + 1));
143
+ }
144
+ }
145
+ return JSON.parse(trimmed);
146
+ }
147
+
105
148
  async function readResponseBody(res) {
149
+ if (res && typeof res.text === 'function') return parseMaybeSse(await res.text());
106
150
  if (res && typeof res.json === 'function') return res.json();
107
- if (res && typeof res.text === 'function') return JSON.parse(await res.text());
108
151
  return res;
109
152
  }
110
153
 
@@ -167,9 +210,13 @@ export function createDecisionClient({
167
210
  }
168
211
 
169
212
  async function resolveEndpoint() {
170
- const base = await resolveGatewayBaseUrl({ projectRoot, homeDir, env });
213
+ // Decision lane resolves its own endpoint (user-level gateway.json →
214
+ // env → openai.unicjsc.com default), independent of the host's LLM
215
+ // endpoint — the UNIC provider is used even when the engine points
216
+ // elsewhere.
217
+ const base = await resolveDecisionBaseUrl({ projectRoot, homeDir, env });
171
218
  if (!base?.baseUrl) return null;
172
- const key = await resolveGatewayApiKey({ projectRoot, homeDir, env });
219
+ const key = await resolveDecisionApiKey({ projectRoot, homeDir, env });
173
220
  return {
174
221
  url: `${String(base.baseUrl).replace(/\/+$/, '')}/v1/chat/completions`,
175
222
  headers: buildAuthHeaders(key),
@@ -305,6 +352,27 @@ export function createDecisionClient({
305
352
 
306
353
  const status = res?.status ?? (res?.ok === false ? 500 : 200);
307
354
  if (res?.ok === false || status < 200 || status >= 300) {
355
+ // A variant checkpoint without bound credentials (e.g.
356
+ // unic-decision-multilingual before provider binding) returns a 4xx
357
+ // model_not_found body — retry once on the bare `unic-decision`
358
+ // model, the credential-safe alias the owner provisions first.
359
+ if (checkpoint !== FALLBACK_CHECKPOINT) {
360
+ let notFound = status === 404;
361
+ try {
362
+ const clone = typeof res?.clone === 'function' ? res.clone() : null;
363
+ const errBody = clone ? parseMaybeSse(await clone.text()) : null;
364
+ if (errBody?.error?.code === 'model_not_found') notFound = true;
365
+ } catch { /* keep status-based guess */ }
366
+ if (notFound) {
367
+ checkpoint = FALLBACK_CHECKPOINT;
368
+ try {
369
+ body = JSON.stringify(
370
+ encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
371
+ );
372
+ } catch { /* keep prior body */ }
373
+ continue;
374
+ }
375
+ }
308
376
  if (isTransientStatus(status) && attempt + 1 < maxAttempts) continue;
309
377
  if (status === 401 || status === 403) {
310
378
  lastError = 'unavailable';
@@ -169,7 +169,10 @@ function normalizeProbabilities(raw) {
169
169
  }
170
170
 
171
171
  function validateValue(question, args) {
172
- const value = args?.value;
172
+ // The model answers with its native field names — `choice`/`noul`/`score` —
173
+ // not the schema's `value`; accept both so a live gateway isn't parsed as
174
+ // malformed.
175
+ const value = args?.value ?? args?.choice ?? args?.noul ?? args?.score;
173
176
  if (question.kind === 'choice') {
174
177
  if (typeof value !== 'string') return 'missing-value';
175
178
  if (!Array.isArray(question.candidates) || !question.candidates.includes(value)) return 'out-of-enum';
@@ -259,7 +259,7 @@ export const DECISION_REGISTRY = Object.freeze([
259
259
  family: 'learn',
260
260
  owner: 'learningCandidates',
261
261
  kind: 'choice',
262
- description: 'Learning candidate classification for a captured signal.',
262
+ description: 'Learning candidate classification for a captured signal. Reserved for the memoryV2 decision lane; promotion gates via decisionPlane.families.learn.stage.',
263
263
  candidatePolicy: 'owner-shortlist',
264
264
  hardConstraints: ['promotion-approval'],
265
265
  probabilityPolicy: 'raw-label',
@@ -1883,6 +1883,36 @@ const ROUTE_SHADOW_QUESTIONS = Object.freeze([
1883
1883
  instruction: 'R0-R4 rigor recommendation inside deterministic floors.',
1884
1884
  candidates: ['r0', 'r1', 'r2', 'r3', 'r4'],
1885
1885
  },
1886
+ {
1887
+ decisionKey: 'resume.next-action.v1',
1888
+ family: 'resume',
1889
+ kind: 'choice',
1890
+ instruction: 'Next resumable action at the continuation boundary.',
1891
+ // deriveNextAction vocabulary (read from source); the rescue-bias override
1892
+ // 'execute-current-milestone' can overwrite nextActionType post-derivation —
1893
+ // baseline compare then scores 'disagree', which is a real signal, not noise.
1894
+ candidates: [
1895
+ 'ask-user-confirmation',
1896
+ 'run-primary-verification',
1897
+ 'run-fallback-verification',
1898
+ 'pull-indexed-context',
1899
+ 'read-skill-instructions',
1900
+ 'inspect-structure',
1901
+ ],
1902
+ },
1903
+ {
1904
+ decisionKey: 'verify.depth.v1',
1905
+ family: 'verify',
1906
+ kind: 'choice',
1907
+ instruction: 'Verification depth among allowed levels.',
1908
+ candidates: ['sanity', 'targeted', 'impact', 'full'],
1909
+ },
1910
+ // NOT wired — recorded per migration-backlog review:
1911
+ // capability.impact.v1 — noul kind; no threshold baseline exists on
1912
+ // routeSummary (capabilityPolicy.recommended is unpopulated upstream), so
1913
+ // the question has no comparison value.
1914
+ // learn.candidate-class.v1 — reserved for the memoryV2 `decision` plane
1915
+ // (memoryFlags.js), not the route boundary.
1886
1916
  ]);
1887
1917
 
1888
1918
  // Only families whose resolved stage is not 'off' get a question.
@@ -1906,6 +1936,17 @@ function probabilityBand(value) {
1906
1936
  function shadowBaselineFor(decisionKey, routeSummary) {
1907
1937
  if (decisionKey === 'route.intent-kind.v1') return routeSummary?.intent?.kind ?? null;
1908
1938
  if (decisionKey === 'route.rigor.v1') return routeSummary?.execution?.rigor ?? null;
1939
+ if (decisionKey === 'resume.next-action.v1') return routeSummary?.nextActionType ?? null;
1940
+ // Verification depth: routeSummary carries neither verificationRecommendation
1941
+ // nor resolvedVerificationPlan (the plan lives on the outer route result at
1942
+ // ~:2412, and its `mode` uses a different vocabulary — docs-only/
1943
+ // targeted-tests-first/… — NOT a depth). Baseline resolves null → agreement
1944
+ // 'unknown'; bands still record.
1945
+ if (decisionKey === 'verify.depth.v1') {
1946
+ return routeSummary?.verificationRecommendation?.depth
1947
+ ?? routeSummary?.resolvedVerificationPlan?.depth
1948
+ ?? null;
1949
+ }
1909
1950
  return null;
1910
1951
  }
1911
1952
 
@@ -181,7 +181,10 @@ function normalizeProbabilities(raw) {
181
181
  }
182
182
 
183
183
  function validateValue(question, args) {
184
- const value = args?.value;
184
+ // The model answers with its native field names — `choice`/`noul`/`score` —
185
+ // not the schema's `value`; accept both so a live gateway isn't parsed as
186
+ // malformed.
187
+ const value = args?.value ?? args?.choice ?? args?.noul ?? args?.score;
185
188
  if (question.kind === 'choice') {
186
189
  if (typeof value !== 'string') return 'missing-value';
187
190
  if (!Array.isArray(question.candidates) || !question.candidates.includes(value)) return 'out-of-enum';
@@ -327,6 +330,44 @@ function pickString(...candidates) {
327
330
  return null;
328
331
  }
329
332
 
333
+ // --- Decision-plane gateway resolution -------------------------------------
334
+ // The decision lane is independent of the host's LLM endpoint: default is the
335
+ // UNIC OpenAI-compatible provider; ~/.ukit/storage/gateway.json {decision:
336
+ // {baseUrl, apiKey, scheme}} overrides; env UKIT_DECISION_BASE_URL /
337
+ // UKIT_DECISION_API_KEY next; the shared UNIC token (ANTHROPIC_*) is the
338
+ // credential fallback so existing installs need nothing extra.
339
+ const DEFAULT_DECISION_BASE_URL = 'https://openai.unicjsc.com';
340
+ const USER_GATEWAY_REL = () => path.join('.ukit', 'storage', 'gateway.json');
341
+
342
+ function readUserGatewayDecision(homeDir) {
343
+ if (!homeDir) return null;
344
+ const json = safeReadJson(path.join(homeDir, USER_GATEWAY_REL()));
345
+ const decision = json?.decision;
346
+ return decision && typeof decision === 'object' ? decision : null;
347
+ }
348
+
349
+ function resolveDecisionBaseUrl({ homeDir, env }) {
350
+ const fileUrl = pickString(readUserGatewayDecision(homeDir)?.baseUrl);
351
+ if (fileUrl) return { baseUrl: fileUrl, source: 'ukit-gateway-json' };
352
+ const envUrl = pickString(env?.UKIT_DECISION_BASE_URL);
353
+ if (envUrl) return { baseUrl: envUrl, source: 'env' };
354
+ return { baseUrl: DEFAULT_DECISION_BASE_URL, source: 'default' };
355
+ }
356
+
357
+ function resolveDecisionApiKey({ projectRoot, homeDir, env }) {
358
+ const file = readUserGatewayDecision(homeDir);
359
+ const fileKey = pickString(file?.apiKey);
360
+ if (fileKey) {
361
+ const scheme = file?.scheme === 'api-key' ? 'api-key' : 'auth-token';
362
+ return { value: fileKey, scheme, source: 'ukit-gateway-json' };
363
+ }
364
+ const envKey = pickString(env?.UKIT_DECISION_API_KEY);
365
+ if (envKey) return { value: envKey, scheme: 'auth-token', source: 'env' };
366
+ const shared = resolveGatewayApiKey({ projectRoot, homeDir, env });
367
+ if (shared?.value) return { ...shared, source: 'shared-unic-token' };
368
+ return { value: null, scheme: null, source: null };
369
+ }
370
+
330
371
  function readSettingsEnvBlock(filePath) {
331
372
  const json = safeReadJson(filePath);
332
373
  if (!json || typeof json !== 'object') return null;
@@ -422,13 +463,13 @@ function readMergedConfig(rootDir, homeDir) {
422
463
 
423
464
  // ---------------------------------------------------------------------------
424
465
  // Client core — mirror of src/decision/client.js semantics minus the circuit
425
- // breaker (a one-shot CLI has no cross-request state to protect).
426
- // ---------------------------------------------------------------------------
427
-
428
466
  const DEFAULT_CHECKPOINTS = {
429
- default: 'unic-decision/laya-multilingual',
430
- english: 'unic-decision/laya',
431
- unknownLanguage: 'unic-decision/auto',
467
+ // 'unic-decision-multilingual' is credential-bound (verified live
468
+ // 2026-09-26) and is the shipped default; english/unknown classes keep the
469
+ // bare 'unic-decision' alias until laya/auto variants are bound.
470
+ default: 'unic-decision-multilingual',
471
+ english: 'unic-decision',
472
+ unknownLanguage: 'unic-decision',
432
473
  };
433
474
 
434
475
  const DEFAULT_TIMEOUT_MS = 5000;
@@ -466,9 +507,44 @@ function isTransientStatus(status) {
466
507
  return status === 429 || (status >= 500 && status <= 599);
467
508
  }
468
509
 
510
+ // The UNIC decision endpoint streams the completion even without stream:true —
511
+ // the body is one JSON object followed by an SSE "data: [DONE]" trailer.
512
+ // Parse the first JSON object, ignore the trailer.
513
+ function parseMaybeSse(text) {
514
+ const trimmed = String(text ?? '').trim();
515
+ if (!trimmed.startsWith('{')) {
516
+ // Pure SSE frames: join data payloads.
517
+ const payload = trimmed
518
+ .split('\n')
519
+ .filter((line) => line.startsWith('data:') && !line.includes('[DONE]'))
520
+ .map((line) => line.slice(5).trim())
521
+ .join('');
522
+ return JSON.parse(payload || trimmed);
523
+ }
524
+ // JSON object possibly followed by a glued SSE trailer (`}data: [DONE]`,
525
+ // observed live) — extract the balanced object, strings/comments aware.
526
+ let depth = 0;
527
+ for (let i = 0; i < trimmed.length; i += 1) {
528
+ const ch = trimmed[i];
529
+ if (ch === '"' || ch === "'") {
530
+ for (let j = i + 1; j < trimmed.length; j += 1) {
531
+ if (trimmed[j] === '\\') j += 1;
532
+ else if (trimmed[j] === ch) { i = j; break; }
533
+ }
534
+ continue;
535
+ }
536
+ if (ch === '{') depth += 1;
537
+ else if (ch === '}') {
538
+ depth -= 1;
539
+ if (depth === 0) return JSON.parse(trimmed.slice(0, i + 1));
540
+ }
541
+ }
542
+ return JSON.parse(trimmed);
543
+ }
544
+
469
545
  async function readResponseBody(res) {
546
+ if (res && typeof res.text === 'function') return parseMaybeSse(await res.text());
470
547
  if (res && typeof res.json === 'function') return res.json();
471
- if (res && typeof res.text === 'function') return JSON.parse(await res.text());
472
548
  return res;
473
549
  }
474
550
 
@@ -574,14 +650,14 @@ export async function requestBatch(batch, {
574
650
  return outcome('blocked-sensitive', { checkpoint });
575
651
  }
576
652
 
577
- const base = resolveGatewayBaseUrl({ projectRoot, homeDir, env });
653
+ const base = resolveDecisionBaseUrl({ homeDir, env });
578
654
  if (!base?.baseUrl) {
579
655
  return outcome('unsupported', {
580
656
  fallbackCode: 'endpoint-unconfigured',
581
657
  checkpoint,
582
658
  });
583
659
  }
584
- const key = resolveGatewayApiKey({ projectRoot, homeDir, env });
660
+ const key = resolveDecisionApiKey({ projectRoot, homeDir, env });
585
661
  const endpoint = {
586
662
  url: `${String(base.baseUrl).replace(/\/+$/, '')}/v1/chat/completions`,
587
663
  headers: buildAuthHeaders(key),
@@ -621,6 +697,26 @@ export async function requestBatch(batch, {
621
697
 
622
698
  const status = res?.status ?? (res?.ok === false ? 500 : 200);
623
699
  if (res?.ok === false || status < 200 || status >= 300) {
700
+ // A variant checkpoint without bound credentials (e.g.
701
+ // unic-decision-multilingual before provider binding) returns a 4xx
702
+ // model_not_found body — retry once on the bare `unic-decision` model.
703
+ if (checkpoint !== 'unic-decision') {
704
+ let notFound = status === 404;
705
+ try {
706
+ const clone = typeof res?.clone === 'function' ? res.clone() : null;
707
+ const errBody = clone ? parseMaybeSse(await clone.text()) : null;
708
+ if (errBody?.error?.code === 'model_not_found') notFound = true;
709
+ } catch { /* keep status-based guess */ }
710
+ if (notFound) {
711
+ checkpoint = 'unic-decision';
712
+ try {
713
+ body = JSON.stringify(
714
+ encodeBatch({ model: checkpoint, statePacket: serialized, questions }),
715
+ );
716
+ } catch { /* keep prior body */ }
717
+ continue;
718
+ }
719
+ }
624
720
  if (isTransientStatus(status) && attempt + 1 < maxAttempts) continue;
625
721
  if (status === 401 || status === 403) {
626
722
  lastError = 'unavailable';
@@ -680,9 +776,9 @@ export async function runHealthProbe({
680
776
  const dp = decisionPlaneConfig(config);
681
777
  if (dp.enabled === false) return { status: 'unavailable' };
682
778
  if (!transport) return { status: 'unsupported' };
683
- const base = resolveGatewayBaseUrl({ projectRoot, homeDir, env });
779
+ const base = resolveDecisionBaseUrl({ homeDir, env });
684
780
  if (!base?.baseUrl) return { status: 'unsupported', fallbackCode: 'endpoint-unconfigured' };
685
- const key = resolveGatewayApiKey({ projectRoot, homeDir, env });
781
+ const key = resolveDecisionApiKey({ projectRoot, homeDir, env });
686
782
  const checkpoint = resolveCheckpoint('multilingual', config);
687
783
  const timeoutMs = Number.isFinite(dp.timeoutMs) ? dp.timeoutMs : DEFAULT_TIMEOUT_MS;
688
784
  const body = JSON.stringify({