@cspeach/cli 1.1.25 → 1.1.26

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (44) hide show
  1. package/dist/agent/loop.js +20 -8
  2. package/dist/agent/tool-dispatch.js +6 -0
  3. package/dist/auth/auth-file.js +173 -12
  4. package/dist/classifier/client.js +55 -30
  5. package/dist/cli.js +11 -0
  6. package/dist/commands/config-set.js +13 -0
  7. package/dist/commands/config-show.js +5 -1
  8. package/dist/commands/cost.js +24 -22
  9. package/dist/commands/deploy-card.js +135 -0
  10. package/dist/commands/login.js +31 -13
  11. package/dist/commands/plan-chain.js +31 -0
  12. package/dist/commands/plan-continue.js +1 -0
  13. package/dist/commands/plan-resume.js +49 -2
  14. package/dist/cost/cost-log.js +76 -4
  15. package/dist/cost/credits-wire.js +9 -0
  16. package/dist/cost/credits.js +24 -6
  17. package/dist/cost/router-spend.js +24 -0
  18. package/dist/cost/session-cost.js +16 -2
  19. package/dist/models/server-config.js +21 -1
  20. package/dist/one-shot.js +12 -2
  21. package/dist/projects/plan-lane.js +47 -0
  22. package/dist/projects/plan-run.js +8 -0
  23. package/dist/projects/plan-schema.js +2 -0
  24. package/dist/projects/save-command.js +3 -2
  25. package/dist/renderer/footer-line.js +10 -4
  26. package/dist/renderer/status-footer.js +5 -4
  27. package/dist/renderer/tool-labels.js +2 -1
  28. package/dist/repl.js +41 -8
  29. package/dist/router/byok-classifier.js +159 -0
  30. package/dist/router/classifier.js +130 -6
  31. package/dist/router/router-core.js +307 -0
  32. package/dist/router/router-prompt.js +121 -0
  33. package/dist/router/routing-failure.js +65 -3
  34. package/dist/skills/bundled-skills.js +45 -45
  35. package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
  36. package/dist/tools/fiori/deploy/run.js +608 -0
  37. package/dist/tools/fiori/deploy/scope.js +8 -0
  38. package/dist/tools/fiori/deploy/yaml-read.js +236 -0
  39. package/dist/tools/fiori/deploy-config.js +24 -6
  40. package/dist/tools/fiori/tools.js +117 -5
  41. package/dist/tools/local-build.js +3 -0
  42. package/dist/tools/subagent/read_agent.js +4 -2
  43. package/dist/tools/tool-loading.js +1 -1
  44. package/package.json +1 -1
package/dist/one-shot.js CHANGED
@@ -13,11 +13,12 @@ import { makeSessionId, saveSession } from './session/store.js';
13
13
  import { newSession } from './session/schema.js';
14
14
  import { resolveModelRole } from './models/resolve.js';
15
15
  import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
16
+ import { prefetchRouterPrompt } from './router/router-prompt.js';
16
17
  import { createProviderForMode } from './agent/providers/factory.js';
17
18
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
18
19
  import { runTurn } from './agent/loop.js';
19
20
  import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
20
- import { routingFailureFromResult, routingFailureOneShotLine } from './router/routing-failure.js';
21
+ import { routingFailureFromResult, routingFailureOneShotLine, routingFallbackFromError } from './router/routing-failure.js';
21
22
  import { startThinkingHeartbeat, ROUTING_HEARTBEAT_THRESHOLDS } from './renderer/thinking-heartbeat.js';
22
23
  import { setRenderingOverride, setHeadlessOverride, setOneShotOverride } from './renderer/tty.js';
23
24
  import { resolveRenderingMode } from './renderer/rendering-mode.js';
@@ -96,6 +97,9 @@ export async function runOneShot(prompt, opts) {
96
97
  loadCustomerModelConfig(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
97
98
  // Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
98
99
  fetchToolLoadingMode(cfg, async () => (await (await getStore()).get()) ?? '', { timeoutMs: 1500 }),
100
+ // 2026-10-02 — BYOK / ai-hub route on the laptop: prefetch the signed
101
+ // router prompt (public manifest, no prompt data) under the same cap.
102
+ cfg.llm?.mode === 'byok' || cfg.llm?.mode === 'ai-hub' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
99
103
  ]);
100
104
  // B5 (2026-06-11) — one-shot is headless by design: a single turn, no
101
105
  // REPL, often piped (CI / battery). Any stdin prompt would hang forever
@@ -224,7 +228,13 @@ export async function runOneShot(prompt, opts) {
224
228
  console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
225
229
  return 1;
226
230
  }
227
- throw err;
231
+ // Live fix 2026-10-02 — a classify failure (proxy HTTP 500, an
232
+ // unreadable body) escaped as "cspeach: unhandled error". Continue on
233
+ // the bundled local rules instead; the offline line below says so.
234
+ const fallback = routingFallbackFromError(err, body);
235
+ if (!fallback)
236
+ throw err;
237
+ c = fallback;
228
238
  }
229
239
  finally {
230
240
  routingBeat.stop();
@@ -108,6 +108,53 @@ export function checkLaneConsistency(content, rec) {
108
108
  }
109
109
  return null;
110
110
  }
111
+ /* ── deploy is a required step of a UI lane (owner, 2026-10-02) ─────────── */
112
+ /**
113
+ * A rap lane with a UI, and the `none` lane (a UI on an already published
114
+ * service), build an app that runs on SAP — its plan must deploy it. cap is
115
+ * out (the BTP deploy is not built); abap-report has no UI.
116
+ */
117
+ export function laneRequiresDeploy(rec) {
118
+ const { backend, ui } = rec.final;
119
+ return (backend === 'rap' && ui !== 'none') || backend === 'none';
120
+ }
121
+ /** The deploy phase of a UI: ui layer, /abap-fiori-build, id `ui.deploy` (or `<app>.deploy`). */
122
+ export function isDeployPhase(p) {
123
+ return p.layer === 'ui' && p.delegateTo === 'abap-fiori-build' && /(^|\.)deploy$/.test(p.id);
124
+ }
125
+ /** The developer's recorded reason for not deploying (phases[ui.build].ui.deployOptOut), if any. */
126
+ export function deployOptOutOf(phases) {
127
+ for (const p of phases) {
128
+ const why = p.ui?.deployOptOut?.trim();
129
+ if (why)
130
+ return why;
131
+ }
132
+ return undefined;
133
+ }
134
+ /**
135
+ * Refuse a UI-lane plan with no deploy phase (and no recorded opt-out), or
136
+ * whose deploy phase does not wait for a UI build phase. Returns a plain
137
+ * refusal or null. Checked at save; a phase write-back may not remove it.
138
+ */
139
+ export function checkRequiredDeploy(content, rec) {
140
+ if (!laneRequiresDeploy(rec))
141
+ return null;
142
+ const deploys = content.phases.filter(isDeployPhase);
143
+ if (deploys.length === 0) {
144
+ if (deployOptOutOf(content.phases))
145
+ return null;
146
+ return ('This lane builds an app that runs on SAP, so the plan needs a ui.deploy phase (deploy the app to the ABAP ' +
147
+ 'repository, delegateTo abap-fiori-build, layer ui) after ui.build. If the developer does not want it deployed, ' +
148
+ 'record their words in phases[ui.build].ui.deployOptOut. Re-run /abap-plan.');
149
+ }
150
+ const builds = new Set(content.phases.filter((p) => p.layer === 'ui' && !isDeployPhase(p)).map((p) => p.id));
151
+ for (const d of deploys) {
152
+ if (!d.entryCriteria.some((id) => builds.has(id))) {
153
+ return `Phase ${cardLine(d.id)} must run after ui.build — list the UI build phase in its entryCriteria. Re-run /abap-plan.`;
154
+ }
155
+ }
156
+ return null;
157
+ }
111
158
  /**
112
159
  * Resume / continuation gate: read the plan's lane with its recorded sha,
113
160
  * re-validate it and check the stored phases against it. Lane-less plans pass
@@ -372,6 +372,14 @@ g = getGlyphs()) {
372
372
  if (deployed?.work?.deployedUrl) {
373
373
  lines.push(`App deployed: ${deployed.work.deployedUrl}`);
374
374
  }
375
+ else if (phases.some((p) => p.layer === 'ui' && p.delegateTo === 'abap-fiori-build' && p.ui !== undefined)) {
376
+ // 2026-10-02 — deploy is a chained step of a UI plan: finishing without a
377
+ // deployed app is said out loud, never left to silence.
378
+ const optOut = phases.map((p) => p.ui?.deployOptOut?.trim()).find((w) => !!w);
379
+ lines.push(optOut
380
+ ? `${g.info} Deploy skipped by the developer: ${sanitizeControlChars(optOut)}`
381
+ : `${g.attention} The app was not deployed to SAP — it runs only in local preview. Deploy it with /abap-fiori-build (Step 10, fiori_deploy) or record why not.`);
382
+ }
375
383
  lines.push('Consider /abap-preflight on the produced transport(s) before release.');
376
384
  return lines;
377
385
  }
@@ -78,6 +78,8 @@ const planPhaseUiSchema = z.object({
78
78
  floorplan: z.enum(PLAN_UI_FLOORPLANS).optional(),
79
79
  appId: z.string().min(1).optional(),
80
80
  appTitle: z.string().min(1).optional(),
81
+ // The developer's own words for not deploying (checkRequiredDeploy, plan-lane.ts).
82
+ deployOptOut: z.string().min(1).optional(),
81
83
  });
82
84
  /** Keep in sync with PlanAuditState in types.ts. 'warn' is the non-blocking
83
85
  * tier (confidence-tiered redesign, 2026-07-08) — persisted literally so a
@@ -18,7 +18,7 @@ import { renderEmailTemplate } from './email-template.js';
18
18
  import { writePlanHandover } from './handover-md.js';
19
19
  import { ensureWorkspace, resolveWorkspacePath } from './workspace.js';
20
20
  import { askYesNo, isYes } from './yes-no.js';
21
- import { checkLaneConsistency, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
21
+ import { checkLaneConsistency, checkRequiredDeploy, LANE_CHANGED_AT_CREATE, readPlanLane } from './plan-lane.js';
22
22
  const SKILL_REGISTRY = {
23
23
  'abap-spec-gap': {
24
24
  artefactType: 'spec-gap',
@@ -343,7 +343,8 @@ export async function runSaveCommand(args) {
343
343
  args.log('[save] The plan was not saved.');
344
344
  return null;
345
345
  }
346
- const conflict = checkLaneConsistency(planExtract.content, lr.rec);
346
+ // 2026-10-02 — a UI lane must deploy (or record the developer's opt-out).
347
+ const conflict = checkLaneConsistency(planExtract.content, lr.rec) ?? checkRequiredDeploy(planExtract.content, lr.rec);
347
348
  if (conflict) {
348
349
  args.log('');
349
350
  args.log(`[save] ${conflict}`);
@@ -80,18 +80,24 @@ export function formatFooterLine(s, width) {
80
80
  export function turnSpendText(p) {
81
81
  if (creditsActive()) {
82
82
  const c = p.credits;
83
- if (!c || c.chargedThisTurn === null)
83
+ // 2026-10-02: the whole user request (every model round), not its last round.
84
+ if (!c || c.chargedThisRequest === null)
84
85
  return CREDITS_PENDING;
85
- return formatCredits(c.chargedThisTurn, c.unitLabel);
86
+ return formatCredits(c.chargedThisRequest, c.unitLabel);
86
87
  }
87
88
  return p.costThisTurn != null ? formatCost(p.costThisTurn) : '';
88
89
  }
89
- /** `/<skill> · <tokens> tokens · <this-turn spend> · <time>` — one dim line, no newline. */
90
+ /**
91
+ * `/<skill> · <tokens> tokens · <this-turn spend> · <time>` — one dim line, no newline.
92
+ * 2026-10-02 (owner): in credits mode (a managed customer) the token count is
93
+ * dropped — credits only, so nobody works out "credits per token".
94
+ */
90
95
  export function formatTurnReceipt(p) {
91
96
  const parts = [];
92
97
  if (p.skill && p.skill !== '(none)')
93
98
  parts.push(p.skill.startsWith('/') ? p.skill : `/${p.skill}`);
94
- parts.push(`${p.tokens.toLocaleString('en-US')} tokens`);
99
+ if (!creditsActive())
100
+ parts.push(`${p.tokens.toLocaleString('en-US')} tokens`);
95
101
  if (p.spend)
96
102
  parts.push(p.spend);
97
103
  if (p.ms > 0)
@@ -71,13 +71,14 @@ export function formatStatusFooter(params) {
71
71
  const shortSession = sessionId.slice(0, 8);
72
72
  const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
73
73
  const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
74
- const tokStr = tokens.toLocaleString() + ' tok this turn';
75
- const cachedStr = cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
76
- const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
77
74
  // Credits mode: the whole spend story collapses into ONE customer-facing
78
75
  // segment (turn · session · balance) in place of the two dollar segments.
79
76
  // (Classic receipt only — the Ink 'turn' variant returned above.)
77
+ // 2026-10-02 (owner): and no token/cached counts — credits only.
80
78
  const inCredits = creditsActive();
79
+ const tokStr = inCredits ? '' : tokens.toLocaleString() + ' tok this turn';
80
+ const cachedStr = !inCredits && cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
81
+ const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
81
82
  const costTurnStr = inCredits
82
83
  ? (credits ? formatCreditsLine(credits) : CREDITS_PENDING)
83
84
  : (costThisTurn != null ? formatCost(costThisTurn) : '');
@@ -92,7 +93,7 @@ export function formatStatusFooter(params) {
92
93
  sapAlias,
93
94
  skillDisplay,
94
95
  turns,
95
- tokStr,
96
+ ...(tokStr ? [tokStr] : []),
96
97
  ...(costTurnStr ? [costTurnStr] : []),
97
98
  ...(cachedStr ? [cachedStr] : []),
98
99
  ...(costSessStr ? [costSessStr] : []),
@@ -192,6 +192,7 @@ const RULES = {
192
192
  fiori_scaffold: (a) => ({ verb: 'Scaffolded app', object: shortLeft(str(a['basePath']) ?? '') }),
193
193
  fiori_apply: (a) => ({ verb: 'Applied', object: shortRight([str(a['entry']), str(a['appDir'])].filter(Boolean).join(' · ')) }),
194
194
  fiori_list: () => ({ verb: 'Listed patterns', object: '' }),
195
+ fiori_deploy: (a) => ({ verb: str(a['mode']) === 'deploy' ? 'Deploy app' : 'Deploy test', object: shortRight(str(a['appDir']) ?? '') }),
195
196
  fiori_deploy_config: (a) => ({ verb: 'Deploy config', object: shortRight(str(a['name']) ?? str(a['appDir']) ?? '') }),
196
197
  fiori_fe_extend: (a) => ({ verb: 'Extended app', object: shortRight([str(a['op']), str(a['basePath'])].filter(Boolean).join(' · ')) }),
197
198
  fiori_preview: (a) => ({ verb: 'Preview', object: shortRight([str(a['action']) ?? 'start', str(a['appDir'])].filter(Boolean).join(' ')) }),
@@ -258,7 +259,7 @@ const PRESENT = {
258
259
  'Read playbook': 'Reading playbook', 'Read convention': 'Reading convention', 'Merged CCA': 'Merging CCA',
259
260
  'Merged progress': 'Merging progress', 'Checked background': 'Checking background',
260
261
  Subagent: 'Running subagent', Reader: 'Running reader', 'Drafted schedule': 'Drafting schedule', Applied: 'Applying',
261
- 'Listed patterns': 'Listing patterns', 'Deploy config': 'Writing deploy config', 'Extended app': 'Extending app',
262
+ 'Listed patterns': 'Listing patterns', 'Deploy config': 'Writing deploy config', 'Deploy app': 'Deploying app', 'Deploy test': 'Test-deploying app', 'Extended app': 'Extending app',
262
263
  Preview: 'Preview', 'Searched samples': 'Searching samples', 'Read sample': 'Reading sample',
263
264
  'Audited metadata': 'Auditing metadata', 'Extended model': 'Extending model',
264
265
  'Checked capability': 'Checking capability',
package/dist/repl.js CHANGED
@@ -38,6 +38,7 @@ import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
38
38
  import { newSession } from './session/schema.js';
39
39
  import { resolveModelRole } from './models/resolve.js';
40
40
  import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
41
+ import { prefetchRouterPrompt } from './router/router-prompt.js';
41
42
  import { repinResumedSessionModel } from './session/repin-model.js';
42
43
  import { computeCost } from './cost/pricing.js';
43
44
  import { formatSessionSpendValue } from './repl/session-spend-line.js';
@@ -60,7 +61,7 @@ import { enqueueSteering } from './agent/steering-queue.js';
60
61
  import { classifyPrompt, parseShortcut, rejectUnknownSkill } from './router/classifier.js';
61
62
  import { pipedRoutingPick } from './router/piped-routing.js';
62
63
  import { decideRouting } from './router/routing-decision.js';
63
- import { routingFailureFromError, routingFailureFromResult, routingFailureNotice, routingFailureOutcome, routingFailureText } from './router/routing-failure.js';
64
+ import { routingFailureFromError, routingFailureFromResult, routingFailureNotice, routingFailureOutcome, routingFailureText, routingFallbackFromError } from './router/routing-failure.js';
64
65
  import { coachingPickerClassic } from './ui/coaching-picker-classic.js';
65
66
  import { coachingPickerEmitter } from './ui/coaching-picker-emitter.js';
66
67
  import { globalStore as sapStateStore } from './ui/sap-state-store.js';
@@ -553,6 +554,9 @@ export async function runRepl(opts) {
553
554
  loadCustomerModelConfig(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
554
555
  // Task 16 (F13) — BYOK reads only the served tool_loading (signed in only).
555
556
  fetchToolLoadingMode(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
557
+ // 2026-10-02 — BYOK / ai-hub route on the laptop: prefetch the signed
558
+ // router prompt (public manifest, no prompt data) under the same cap.
559
+ cfg.llm?.mode === 'byok' || cfg.llm?.mode === 'ai-hub' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
556
560
  ]);
557
561
  // Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
558
562
  // A managed customer is billed in credits at a multiplier we do not
@@ -1318,8 +1322,16 @@ export async function runRepl(opts) {
1318
1322
  }, { signal: routingCtl.signal });
1319
1323
  }
1320
1324
  catch (err) {
1321
- if (!routingCtl.signal.aborted)
1322
- throw err;
1325
+ if (!routingCtl.signal.aborted) {
1326
+ // Live fix 2026-10-02 — a classify failure (proxy HTTP 500,
1327
+ // an unreadable body) dropped the turn. Continue on the
1328
+ // bundled local rules: the offline handling below prints one
1329
+ // notice, then the low-confidence picker (or fix 10's stay).
1330
+ const fallback = routingFallbackFromError(err, prompt);
1331
+ if (!fallback)
1332
+ throw err;
1333
+ c = fallback;
1334
+ }
1323
1335
  }
1324
1336
  finally {
1325
1337
  routingAbort = null;
@@ -1467,6 +1479,8 @@ export async function runRepl(opts) {
1467
1479
  // Task 9 — run-lease identity + takeover prompt (concurrent
1468
1480
  // resume guard; inkAwarePrompt answers '' in headless = decline).
1469
1481
  sessionId: session.id,
1482
+ // 2026-10-02 — deploy card on a direct resume into ui.deploy.
1483
+ sapAlias: session.sap_system ?? undefined,
1470
1484
  prompt: inkAwarePrompt,
1471
1485
  confirm: inkAwareConfirm,
1472
1486
  log: (...lines) => chunkEmitter.emit('chunk', lines.join('\n') + '\n'),
@@ -1494,6 +1508,7 @@ export async function runRepl(opts) {
1494
1508
  phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
1495
1509
  sessionId: session.id,
1496
1510
  cwd: process.cwd(),
1511
+ sapAlias: session.sap_system ?? undefined,
1497
1512
  mode: planResume.effectiveMode ?? 'step',
1498
1513
  modeFlag: planResume.mode,
1499
1514
  // Off-switch (I3) — footer honesty on re-audit boards; (I2) the
@@ -1833,6 +1848,7 @@ export async function runRepl(opts) {
1833
1848
  executedPhaseId: planResume.nextPhaseId,
1834
1849
  sessionId: session.id,
1835
1850
  cwd: process.cwd(),
1851
+ sapAlias: session.sap_system ?? undefined,
1836
1852
  mode: planResume.effectiveMode ?? 'step',
1837
1853
  modeFlag: planResume.mode,
1838
1854
  // Off-switch — skips the normal post-phase audit when 'off'; the
@@ -1903,6 +1919,7 @@ export async function runRepl(opts) {
1903
1919
  outcome: planOutcome,
1904
1920
  sessionId: session.id,
1905
1921
  cwd: process.cwd(),
1922
+ sapAlias: session.sap_system ?? undefined,
1906
1923
  subagentDispatches: planSubagentDispatches,
1907
1924
  prompt: inkAwarePrompt,
1908
1925
  confirm: inkAwareConfirm,
@@ -3077,11 +3094,22 @@ export async function runRepl(opts) {
3077
3094
  }
3078
3095
  else {
3079
3096
  try {
3080
- const c = await classifyPrompt(prompt, {
3081
- sap_system: selectedAlias,
3082
- last_skill: session.last_skill ?? null,
3083
- last_object: extractedObject ?? session.last_object ?? null,
3084
- });
3097
+ let c;
3098
+ try {
3099
+ c = await classifyPrompt(prompt, {
3100
+ sap_system: selectedAlias,
3101
+ last_skill: session.last_skill ?? null,
3102
+ last_object: extractedObject ?? session.last_object ?? null,
3103
+ });
3104
+ }
3105
+ catch (err) {
3106
+ // Live fix 2026-10-02 — same as the Ink path: a classify failure
3107
+ // continues on the bundled local rules instead of ending the turn.
3108
+ const fallback = routingFallbackFromError(err, prompt);
3109
+ if (!fallback)
3110
+ throw err;
3111
+ c = fallback;
3112
+ }
3085
3113
  if (c.screenshot)
3086
3114
  console.log(formatInfo(`Screenshot: ${c.screenshot}`).trimEnd());
3087
3115
  // Fix 10 (2026-09-26): routing failed mid-skill → stay in the
@@ -3214,6 +3242,8 @@ export async function runRepl(opts) {
3214
3242
  configAudit: cfg.plan_audit,
3215
3243
  // Task 9 — run-lease identity + takeover prompt (classic mirror).
3216
3244
  sessionId: session.id,
3245
+ // 2026-10-02 — deploy card on a direct resume into ui.deploy.
3246
+ sapAlias: session.sap_system ?? undefined,
3217
3247
  prompt: inkAwarePrompt,
3218
3248
  confirm: inkAwareConfirm,
3219
3249
  log: (...lines) => lines.forEach((l) => console.log(l)),
@@ -3240,6 +3270,7 @@ export async function runRepl(opts) {
3240
3270
  phaseIds: planResume.reauditPhaseIds ?? [planResume.reauditPhaseId],
3241
3271
  sessionId: session.id,
3242
3272
  cwd: process.cwd(),
3273
+ sapAlias: session.sap_system ?? undefined,
3243
3274
  mode: planResume.effectiveMode ?? 'step',
3244
3275
  modeFlag: planResume.mode,
3245
3276
  // Off-switch (I3/I2) — classic mirror; see the Ink re-audit site.
@@ -3551,6 +3582,7 @@ export async function runRepl(opts) {
3551
3582
  executedPhaseId: planResume.nextPhaseId,
3552
3583
  sessionId: session.id,
3553
3584
  cwd: process.cwd(),
3585
+ sapAlias: session.sap_system ?? undefined,
3554
3586
  mode: planResume.effectiveMode ?? 'step',
3555
3587
  modeFlag: planResume.mode,
3556
3588
  // Off-switch (I3/I2) — classic mirror of the Ink chain call: skip
@@ -3620,6 +3652,7 @@ export async function runRepl(opts) {
3620
3652
  outcome: planOutcome,
3621
3653
  sessionId: session.id,
3622
3654
  cwd: process.cwd(),
3655
+ sapAlias: session.sap_system ?? undefined,
3623
3656
  subagentDispatches: planSubagentDispatches,
3624
3657
  prompt: inkAwarePrompt,
3625
3658
  confirm: inkAwareConfirm,
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Skill routing without our server (2026-10-02): BYOK and ai-hub.
3
+ *
4
+ * The site promises that in BYOK (the customer's own Anthropic key) prompts
5
+ * never touch CSPeach servers, and that in ai-hub they stay in the customer's
6
+ * SAP BTP landscape. Until today classifyPrompt POSTed every free-text prompt
7
+ * — plus pasted screenshots, the SAP alias and the last skill — to
8
+ * {proxy}/v1/classify in every mode, and the proxy classified it on CSPeach's
9
+ * key. Now:
10
+ * - BYOK: one Messages API call, direct to the model API, with the
11
+ * customer's keychain key; router model = the one CSPeach serves
12
+ * (roles.router from the public /v1/model-config), with a default.
13
+ * - ai-hub (review item 3): the same call through the customer's own hub
14
+ * (llm.ai_hub_base_url, their token, their model alias), with today's
15
+ * field set only — no output_config, like the ai-hub provider.
16
+ *
17
+ * Same quality as managed: the request and its handling come from
18
+ * router-core.ts, a verbatim copy of the proxy's router core (system prompt
19
+ * from the signed manifest or the bundled copy — router-prompt.ts; JSON
20
+ * schema via structured outputs; thinking off; max_tokens 600; self-heal
21
+ * without output_config on a 400; one retry on an unparseable answer; then the
22
+ * low-confidence fallback).
23
+ *
24
+ * Latency (review item 1): ONE overall deadline covers every call (self-heal
25
+ * and retry included); past it the caller routes by local rules with the
26
+ * timeout notice. Ctrl+C aborts the in-flight call at once.
27
+ *
28
+ * Nothing in here talks to a CSPeach host. The only other network read on
29
+ * this path is the public signed manifest (router-prompt.ts), which carries
30
+ * no prompt data.
31
+ */
32
+ import Anthropic from '@anthropic-ai/sdk';
33
+ import keychain from '../auth/keychain.js';
34
+ import { byokRouterModel } from '../models/server-config.js';
35
+ import { addRouterSpend } from '../cost/router-spend.js';
36
+ import { loadRouterPrompt } from './router-prompt.js';
37
+ import { ROUTER_UNPARSEABLE_RESULT, buildRouterRequest, runRouterCalls, } from './router-core.js';
38
+ /** Same keychain entry the BYOK provider reads (byok-provider.ts) and login writes. */
39
+ export const BYOK_KEYCHAIN_SERVICE = 'cspeach.anthropic';
40
+ export const BYOK_KEYCHAIN_ACCOUNT = 'api-key';
41
+ /** Same keychain entry the ai-hub provider reads. */
42
+ const AI_HUB_KEYCHAIN_SERVICE = 'cspeach.ai-hub';
43
+ const AI_HUB_KEYCHAIN_ACCOUNT = 'token';
44
+ /** The ai-hub provider's default alias (ai-hub-provider.ts). */
45
+ const AI_HUB_DEFAULT_MODEL = 'anthropic--claude-opus-4-6';
46
+ /** Per model call; the SDK retries a timeout / dropped connection once (maxRetries 1). */
47
+ const ROUTER_CALL_TIMEOUT_MS = 8000;
48
+ /** One deadline for the whole routing step (every call, self-heal and retry included). */
49
+ const ROUTER_DEADLINE_MS = 10_000;
50
+ let deadlineOverride;
51
+ /** Test-only: shorten the overall routing deadline (undefined = default). */
52
+ export function __setRouterDeadlineForTests(ms) {
53
+ deadlineOverride = ms;
54
+ }
55
+ let selfHealWarned = false;
56
+ /** Test-only: re-arm the once-per-session self-heal warning. */
57
+ export function __resetRouterWarnForTests() {
58
+ selfHealWarned = false;
59
+ }
60
+ export class ByokRouterFailure extends Error {
61
+ failure;
62
+ constructor(failure) {
63
+ super(`router call failed (${failure.kind})`);
64
+ this.failure = failure;
65
+ this.name = 'ByokRouterFailure';
66
+ }
67
+ }
68
+ function causeOf(err) {
69
+ const e = err;
70
+ if (e?.cause)
71
+ return `${e.cause.code ?? e.cause.errno ?? 'unknown'}${e.cause.message ? `: ${e.cause.message}` : ''}`;
72
+ return e?.message ?? String(err);
73
+ }
74
+ function cancelled() {
75
+ const e = new Error('routing cancelled');
76
+ e.name = 'AbortError';
77
+ return e;
78
+ }
79
+ /**
80
+ * Classify one prompt on the laptop. Returns the router's answer (or the
81
+ * low-confidence fallback when it gave no parseable answer twice). Throws:
82
+ * AbortError on a user cancel; ByokRouterFailure for a model-API failure or
83
+ * the overall deadline; an Error when no key / token is configured.
84
+ */
85
+ export async function classifyByok(prompt, context, images, opts = {}) {
86
+ const target = opts.target ?? { mode: 'byok' };
87
+ if (opts.signal?.aborted)
88
+ throw cancelled();
89
+ const key = target.mode === 'byok'
90
+ ? await keychain.getPassword(BYOK_KEYCHAIN_SERVICE, BYOK_KEYCHAIN_ACCOUNT)
91
+ : await keychain.getPassword(AI_HUB_KEYCHAIN_SERVICE, AI_HUB_KEYCHAIN_ACCOUNT);
92
+ if (!key) {
93
+ throw new Error(target.mode === 'byok'
94
+ ? 'BYOK Anthropic API key not configured — run: cspeach config set llm.byok_key <key>'
95
+ : 'AI Hub token not configured — run: cspeach config set llm.ai_hub_token <token>');
96
+ }
97
+ // One controller for the whole step: the user's cancel OR the deadline.
98
+ const ctrl = new AbortController();
99
+ let deadlineHit = false;
100
+ const timer = setTimeout(() => { deadlineHit = true; ctrl.abort(); }, deadlineOverride ?? ROUTER_DEADLINE_MS);
101
+ timer.unref?.();
102
+ const onUserAbort = () => ctrl.abort();
103
+ opts.signal?.addEventListener('abort', onUserAbort, { once: true });
104
+ const userCancelled = () => opts.signal?.aborted === true;
105
+ try {
106
+ const { prompt: routerPrompt } = await loadRouterPrompt({ signal: ctrl.signal });
107
+ const model = target.mode === 'byok' ? byokRouterModel() : (target.modelAlias ?? AI_HUB_DEFAULT_MODEL);
108
+ const full = buildRouterRequest(model, prompt, context, images, routerPrompt);
109
+ // ai-hub: today's field set only (the hub has no flag for newer fields).
110
+ const request = target.mode === 'byok'
111
+ ? full
112
+ : (({ output_config: _dropped, ...rest }) => rest)(full);
113
+ const sdk = new Anthropic({
114
+ apiKey: key,
115
+ maxRetries: 1,
116
+ timeout: ROUTER_CALL_TIMEOUT_MS,
117
+ ...(target.mode === 'ai-hub' ? { baseURL: target.baseURL } : {}),
118
+ });
119
+ const { result } = await runRouterCalls((req) => sdk.messages.create(req, { signal: ctrl.signal }), request, {
120
+ // Once per session — a model that rejects structured outputs would
121
+ // otherwise print this on every prompt.
122
+ warn: (message, detail) => {
123
+ if (selfHealWarned)
124
+ return;
125
+ selfHealWarned = true;
126
+ console.warn(message, detail);
127
+ },
128
+ // The customer's own bill (BYOK) — counted in the USD meter as each
129
+ // call finishes, so a deadline / cancel after call 1 keeps call 1
130
+ // (review S1). ai-hub is billed by their BTP account at rates we
131
+ // don't know: not priced here.
132
+ onCall: (c) => { if (target.mode === 'byok')
133
+ addRouterSpend(model, c.usage); },
134
+ });
135
+ return result ?? { ...ROUTER_UNPARSEABLE_RESULT, alternates: [] };
136
+ }
137
+ catch (err) {
138
+ if (userCancelled())
139
+ throw cancelled();
140
+ if (deadlineHit)
141
+ throw new ByokRouterFailure({ kind: 'timeout', detail: 'routing deadline' });
142
+ if (err instanceof Anthropic.APIUserAbortError)
143
+ throw cancelled();
144
+ if (err instanceof Anthropic.APIConnectionTimeoutError) {
145
+ throw new ByokRouterFailure({ kind: 'timeout', detail: causeOf(err) });
146
+ }
147
+ if (err instanceof Anthropic.APIConnectionError) {
148
+ throw new ByokRouterFailure({ kind: 'connection', detail: causeOf(err) });
149
+ }
150
+ if (err instanceof Anthropic.APIError && typeof err.status === 'number') {
151
+ throw new ByokRouterFailure({ kind: 'http', status: err.status });
152
+ }
153
+ throw err;
154
+ }
155
+ finally {
156
+ clearTimeout(timer);
157
+ opts.signal?.removeEventListener('abort', onUserAbort);
158
+ }
159
+ }