@cspeach/cli 1.1.32 → 1.1.34

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 (101) hide show
  1. package/dist/agent/anthropic-provider.js +2 -0
  2. package/dist/agent/azure-profile.js +7 -0
  3. package/dist/agent/connection-profile.js +49 -0
  4. package/dist/agent/loop.js +61 -8
  5. package/dist/agent/provider-shape.js +36 -0
  6. package/dist/agent/providers/ai-hub-provider.js +6 -5
  7. package/dist/agent/providers/azure-provider.js +125 -0
  8. package/dist/agent/providers/byok-provider.js +8 -7
  9. package/dist/agent/providers/factory.js +8 -3
  10. package/dist/agent/providers/local-provider.js +18 -22
  11. package/dist/agent/risk-gate.js +202 -0
  12. package/dist/agent/summarise-via-provider.js +55 -8
  13. package/dist/agent/tool-dispatch.js +19 -0
  14. package/dist/agent/turn-error-ux.js +22 -0
  15. package/dist/auth/api-key.js +10 -5
  16. package/dist/auth/auth-file.js +35 -6
  17. package/dist/auth/azure/azure-key.js +21 -0
  18. package/dist/auth/azure/azure-log.js +45 -0
  19. package/dist/auth/azure/cache-store.js +171 -0
  20. package/dist/auth/azure/clear.js +28 -0
  21. package/dist/auth/azure/loopback.js +192 -0
  22. package/dist/auth/azure/msal-client.js +41 -0
  23. package/dist/auth/azure/sign-in.js +219 -0
  24. package/dist/auth/azure/startup-sign-in.js +64 -0
  25. package/dist/auth/azure/token-provider.js +116 -0
  26. package/dist/auth/key-resolver.js +258 -0
  27. package/dist/auth/me.js +11 -5
  28. package/dist/cli.js +21 -2
  29. package/dist/commands/compact.js +12 -1
  30. package/dist/commands/config-set.js +74 -3
  31. package/dist/commands/config-show.js +25 -0
  32. package/dist/commands/cost.js +25 -8
  33. package/dist/commands/help.js +2 -1
  34. package/dist/commands/login-azure.js +33 -0
  35. package/dist/commands/login.js +56 -8
  36. package/dist/commands/logout.js +34 -10
  37. package/dist/commands/plan-boundary.js +4 -4
  38. package/dist/commands/plan-model-tier.js +8 -3
  39. package/dist/commands/usage.js +112 -0
  40. package/dist/commands/whoami.js +113 -9
  41. package/dist/config/loader.js +5 -0
  42. package/dist/connection/azure-auth.js +135 -0
  43. package/dist/connection/azure-errors.js +103 -0
  44. package/dist/connection/azure-facts.js +21 -0
  45. package/dist/connection/azure-gate.js +40 -0
  46. package/dist/connection/azure-last-call.js +5 -0
  47. package/dist/connection/azure-turn-error.js +25 -0
  48. package/dist/connection/cache.js +70 -0
  49. package/dist/connection/fetch.js +33 -0
  50. package/dist/connection/guard.js +94 -0
  51. package/dist/connection/limit-gate.js +26 -0
  52. package/dist/connection/local-usage.js +71 -0
  53. package/dist/connection/login-step.js +62 -0
  54. package/dist/connection/startup.js +67 -0
  55. package/dist/connection/state.js +68 -0
  56. package/dist/connection/types.js +1 -0
  57. package/dist/cost/cost-log.js +3 -1
  58. package/dist/cost/credits-wire.js +1 -1
  59. package/dist/cost/credits.js +4 -0
  60. package/dist/cost/display-mode.js +7 -0
  61. package/dist/cost/month-usage.js +45 -0
  62. package/dist/cost/router-spend.js +3 -0
  63. package/dist/cost/session-cost.js +4 -0
  64. package/dist/doctor/checks/azure.js +100 -0
  65. package/dist/doctor/checks/credits.js +4 -3
  66. package/dist/doctor/checks/usage-reporting.js +22 -0
  67. package/dist/doctor/run.js +7 -0
  68. package/dist/licence/licence-check.js +205 -0
  69. package/dist/licence/paths.js +6 -0
  70. package/dist/licence/turn-gate.js +66 -0
  71. package/dist/models/model-identity.js +65 -0
  72. package/dist/models/resolve.js +8 -0
  73. package/dist/models/server-config.js +3 -2
  74. package/dist/one-shot.js +86 -2
  75. package/dist/renderer/footer-line.js +7 -1
  76. package/dist/renderer/status-footer.js +13 -6
  77. package/dist/repl/ask-or-no.js +18 -0
  78. package/dist/repl/builtin-commands.js +12 -1
  79. package/dist/repl/post-turn-status.js +3 -2
  80. package/dist/repl/safety-confirm.js +47 -2
  81. package/dist/repl/session-spend-line.js +4 -1
  82. package/dist/repl/slash-completer.js +1 -0
  83. package/dist/repl/startup-account.js +51 -0
  84. package/dist/repl.js +223 -48
  85. package/dist/router/byok-classifier.js +41 -14
  86. package/dist/router/classifier.js +5 -16
  87. package/dist/router/router-target.js +41 -0
  88. package/dist/sap/onboarding.js +70 -64
  89. package/dist/session/audit-export.js +11 -4
  90. package/dist/skills/manifest-client.js +37 -4
  91. package/dist/skills/skills-cache.js +115 -0
  92. package/dist/skills/source-authed.js +276 -0
  93. package/dist/tools/filesystem/glob.js +12 -3
  94. package/dist/tools/filesystem/grep.js +14 -3
  95. package/dist/tools/tool-loading.js +9 -10
  96. package/dist/usage/usage-queue.js +323 -0
  97. package/dist/usage/usage-reporter.js +270 -0
  98. package/package.json +3 -2
  99. package/dist/skills/bundled-skills.js +0 -286
  100. package/dist/skills/source-bundled.js +0 -20
  101. package/dist/skills/source-manifest.js +0 -36
package/dist/repl.js CHANGED
@@ -27,7 +27,12 @@ import { applyResumedSkill, isShortFollowUp } from './router/resume-routing.js';
27
27
  import { readableInterruptReason, isUserStopReason } from './session/interrupt-reason.js';
28
28
  import { getStore } from './auth/api-key.js';
29
29
  import { readAuthFile } from './auth/auth-file.js';
30
+ import { resolveApiKey } from './auth/key-resolver.js';
30
31
  import { fetchMe } from './auth/me.js';
32
+ import { runLicenceCheck, licenceCheckHeaders } from './licence/licence-check.js';
33
+ import { LicenceTurnGate } from './licence/turn-gate.js';
34
+ import { runStartupAccount } from './repl/startup-account.js';
35
+ import { startSkillsCacheWarmup } from './skills/source-authed.js';
31
36
  import { formatLoginBanner, signInFragment } from './ui/login-banner.js';
32
37
  import { printStartupBanner, printKeytarFallbackBanner, keychainFallbackNotice } from './renderer/banners.js';
33
38
  import { paint } from './renderer/theme.js';
@@ -39,15 +44,18 @@ import { AdtClient, snapshots } from '@cspeach/sap-client';
39
44
  import { makeSessionId, saveSession, sessionSaved } from './session/store.js';
40
45
  import { newSession } from './session/schema.js';
41
46
  import { resolveModelRole } from './models/resolve.js';
47
+ import { familyOf, planTierModelFor, getActiveDeployments } from './models/model-identity.js';
42
48
  import { loadServerModelConfig, loadCustomerModelConfig, fetchToolLoadingMode } from './models/server-config.js';
43
49
  import { prefetchRouterPrompt } from './router/router-prompt.js';
50
+ import { profileForMode } from './agent/connection-profile.js';
44
51
  import { repinResumedSessionModel } from './session/repin-model.js';
45
52
  import { computeCost } from './cost/pricing.js';
46
53
  import { formatSessionSpendValue } from './repl/session-spend-line.js';
47
54
  import { formatGoodbye } from './renderer/goodbye.js';
48
- import { globalCredits } from './cost/credits.js';
55
+ import { globalCredits, tokensOnlyActive } from './cost/credits.js';
49
56
  import { initCredits } from './cost/credits-wire.js';
50
57
  import { createProviderForMode } from './agent/providers/factory.js';
58
+ import { startUsageFlushAtStartup } from './usage/usage-reporter.js';
51
59
  import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
52
60
  import { runTurn } from './agent/loop.js';
53
61
  import { IdleKeepalive } from './agent/cache-keepalive.js';
@@ -294,7 +302,8 @@ function existingStreamDir(sessionId) {
294
302
  }
295
303
  function printGoodbye(session) {
296
304
  const userTurns = session.messages.filter((m) => m.role === 'user' && typeof m.content === 'string').length;
297
- const sessionCost = computeCost(session.model, {
305
+ // Azure: no price for the customer's deployment — never consult one.
306
+ const sessionCost = tokensOnlyActive() ? 0 : computeCost(session.model, {
298
307
  input: session.usage.input_tokens ?? 0,
299
308
  output: session.usage.output_tokens ?? 0,
300
309
  cacheRead: session.usage.cache_read_input_tokens ?? 0,
@@ -457,46 +466,51 @@ export async function runRepl(opts) {
457
466
  }
458
467
  }
459
468
  catch { /* startup banner is best-effort */ }
460
- // Customer-onboarding (Task 9): if the new auth-file is present, hit /v1/me
461
- // and render the verbose login banner. Falls through to the legacy keytar
462
- // flow below when the file is absent (back-compat for pre-`cspeach login`
463
- // users).
469
+ // Customer-onboarding (Task 9) + security batch item 1.
470
+ // Managed: today's call exactly — only when auth.json exists, the key is
471
+ // resolved only then, the same request, and a 401 exits. Falls through to the
472
+ // legacy keytar flow below when the file is absent (back-compat for
473
+ // pre-`cspeach login` users).
474
+ // byok / ai-hub / local: the shared licence check — server verdict, 72-hour
475
+ // offline grace, licence.json — whether or not auth.json exists. It IS this
476
+ // start-up /v1/me call (one request, the same 5 s budget), never a second one.
477
+ // The block lives in repl/startup-account.ts (behaviour-identical lift, driven
478
+ // by repl/__tests__/startup-account.test.ts).
464
479
  let signIn;
465
480
  let signedInAs;
466
- try {
467
- const authFile = await readAuthFile();
468
- if (authFile) {
469
- const cfgEarly = await loadConfig();
470
- const meResult = await fetchMe({ proxyUrl: cfgEarly.proxy_url, apiKey: authFile.apiKey });
471
- if (!meResult.ok) {
472
- if (meResult.status === 401) {
473
- earlyBrand?.close();
474
- console.log(formatFailure({ what: 'Your CSPeach sign-in is no longer valid', next: 'cspeach login' }));
475
- return 1;
476
- }
477
- const note = formatInfo(`Could not refresh your account (${meResult.error})`);
478
- if (getLook() === 'panels')
479
- startupNotices.push(note);
480
- else
481
- console.log(note.trimEnd());
482
- }
483
- else {
484
- signedInAs = meResult.profile.email;
485
- const banner = formatLoginBanner(meResult.profile);
486
- if (getLook() === 'panels') {
487
- signIn = signInFragment(meResult.profile);
488
- if (banner.warningLine)
489
- startupNotices.push(formatAttention({ text: banner.warningLine }));
490
- }
491
- else {
492
- console.log(banner.line);
493
- if (banner.warningLine)
494
- console.log(formatAttention({ text: banner.warningLine }).trimEnd());
495
- }
496
- }
481
+ const showProfile = (profile) => {
482
+ signedInAs = profile.email;
483
+ const banner = formatLoginBanner(profile);
484
+ if (getLook() === 'panels') {
485
+ signIn = signInFragment(profile);
486
+ if (banner.warningLine)
487
+ startupNotices.push(formatAttention({ text: banner.warningLine }));
497
488
  }
498
- }
499
- catch { /* banner is best-effort — never block REPL startup */ }
489
+ else {
490
+ console.log(banner.line);
491
+ if (banner.warningLine)
492
+ console.log(formatAttention({ text: banner.warningLine }).trimEnd());
493
+ }
494
+ };
495
+ const startupAccount = await runStartupAccount({
496
+ loadConfig, readAuthFile, resolveApiKey: () => resolveApiKey(), fetchMe, runLicenceCheck, licenceCheckHeaders,
497
+ // Final review finding 1 — fill the skills cache for offline use. Fire-and-forget.
498
+ startSkillsWarmup: (c, verdict) => { void startSkillsCacheWarmup(c, verdict); },
499
+ }, {
500
+ print: (line) => console.log(line),
501
+ notice: (note) => {
502
+ if (getLook() === 'panels')
503
+ startupNotices.push(note);
504
+ else
505
+ console.log(note.trimEnd());
506
+ },
507
+ showProfile,
508
+ closeBrand: () => earlyBrand?.close(),
509
+ });
510
+ if (startupAccount.exit === 1)
511
+ return 1;
512
+ // Read by the between-turns recheck (Task 6): the 4-hour clock starts here.
513
+ const licenceCheckedAtMs = startupAccount.licenceCheckedAtMs;
500
514
  earlyBrand?.finish(signIn);
501
515
  const store = await getStore();
502
516
  const keychainMissing = !(await store.isKeychainAvailable());
@@ -601,8 +615,55 @@ export async function runRepl(opts) {
601
615
  fetchToolLoadingMode(cfg, async () => apiKey ?? '', { timeoutMs: 1500 }),
602
616
  // 2026-10-02 — BYOK / ai-hub route on the laptop: prefetch the signed
603
617
  // router prompt (public manifest, no prompt data) under the same cap.
604
- cfg.llm?.mode === 'byok' || cfg.llm?.mode === 'ai-hub' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
618
+ profileForMode(cfg.llm?.mode).router === 'direct' ? prefetchRouterPrompt({ timeoutMs: 1500 }) : Promise.resolve(),
605
619
  ]);
620
+ // Azure tenant mode (Task 4) — the company's connection settings, before
621
+ // the session model is pinned (resolveModelRole reads the deployments).
622
+ // Cached connection.json, refreshed under the same 1.5 s cap; a changed
623
+ // endpoint is shown and asked about. A refusal is shown now and the gate
624
+ // refuses turns — never a fall-back to managed.
625
+ if (cfg.llm.mode === 'azure') {
626
+ const { refreshAzureConnection } = await import('./connection/startup.js');
627
+ const { confirm } = await import('./repl/themed-prompts.js');
628
+ const { askOrNo } = await import('./repl/ask-or-no.js');
629
+ const note = (line) => {
630
+ if (getLook() === 'panels')
631
+ startupNotices.push(line.endsWith('\n') ? line : `${line}\n`);
632
+ else
633
+ console.log(line.trimEnd());
634
+ };
635
+ // The "endpoint changed" rows must be on screen BEFORE the question, so
636
+ // they are held here and printed straight away when a question opens;
637
+ // anything left over joins the start-up notices.
638
+ const held = [];
639
+ const az = await refreshAzureConnection({
640
+ proxyUrl: cfg.proxy_url, apiKey: apiKey ?? null, interactive: true,
641
+ print: (line) => { held.push(line); },
642
+ confirm: async (message) => {
643
+ for (const line of held.splice(0))
644
+ console.log(line.trimEnd());
645
+ // Ctrl+C here is a no (pending kept), never an unhandled ExitPromptError.
646
+ return askOrNo((m) => confirm({ message: m, default: false }))(message);
647
+ },
648
+ });
649
+ for (const line of held)
650
+ note(line);
651
+ if (!az.verdict.allow)
652
+ note(az.verdict.message);
653
+ else if (az.cached) {
654
+ // Task 6b — the Microsoft sign-in, before the first turn: asks first,
655
+ // then the browser. Esc / Ctrl+C during it stops it at once (the
656
+ // listener closes). Its own lines (the address to copy when no browser
657
+ // opens) print straight away, never held for after the sign-in.
658
+ const { azureSignInAtStart } = await import('./auth/azure/startup-sign-in.js');
659
+ const { loginOpener } = await import('./util/open-in-browser.js');
660
+ const s = await azureSignInAtStart(az.cached.connection, {
661
+ ask: askOrNo((message) => confirm({ message, default: true })), print: (line) => console.log(line.trimEnd()), open: loginOpener(),
662
+ });
663
+ if (!s.ok)
664
+ note(s.message);
665
+ }
666
+ }
606
667
  // Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
607
668
  // A managed customer is billed in credits at a multiplier we do not
608
669
  // disclose, so the dollar figures (our Anthropic COGS) must never reach
@@ -815,6 +876,41 @@ export async function runRepl(opts) {
815
876
  throw new Error('No API key configured');
816
877
  return key;
817
878
  });
879
+ // Security batch item 1 — licence re-check between turns (byok / ai-hub /
880
+ // local, every 4 hours from the start-up check). Managed: never calls.
881
+ const licenceGate = new LicenceTurnGate({
882
+ mode: cfg.llm.mode,
883
+ proxyUrl: cfg.proxy_url,
884
+ getKey: async () => (await store.get()) ?? null,
885
+ // No start-up check ran (no key yet, e.g. pasted at the prompt below):
886
+ // non-managed checks on the very first turn. Managed never reads it.
887
+ lastCheckAtMs: licenceCheckedAtMs ?? (cfg.llm.mode === 'managed' ? Date.now() : -Infinity),
888
+ ...(cfg.llm.mode === 'azure' ? {
889
+ extra: async () => (await import('./connection/azure-gate.js')).azureGateCheck(),
890
+ // Plan D13 — never a prompt between turns (no inquirer inside the Ink
891
+ // UI): a changed endpoint or tenant is held as pending and the `extra`
892
+ // gate refuses with "…waiting for you to confirm it · start cspeach to
893
+ // review it". Nothing is printed here; the gate's refusal is the notice.
894
+ // So on this 4-hourly path the cache-write-failure line and the ignored
895
+ // env-override lines are dropped (they show again at the next start).
896
+ onRecheck: async () => {
897
+ const { refreshAzureConnection } = await import('./connection/startup.js');
898
+ await refreshAzureConnection({
899
+ proxyUrl: cfg.proxy_url, apiKey: (await store.get()) ?? null, interactive: false,
900
+ print: () => { }, confirm: async () => false,
901
+ });
902
+ },
903
+ } : {}),
904
+ });
905
+ // Security batch item 3 — send the usage counts still waiting from earlier
906
+ // runs. Non-managed only (a managed seat makes no call); fire-and-forget:
907
+ // never awaited, never throws into start-up.
908
+ if (cfg.llm.mode !== 'managed') {
909
+ try {
910
+ startUsageFlushAtStartup(cfg);
911
+ }
912
+ catch { /* best effort */ }
913
+ }
818
914
  // Task 15 — keep the prompt cache warm while the user is idle between
819
915
  // turns: armed after a turn, disarmed on the next submit and on exit;
820
916
  // `[keepalive] idle_pings` per idle period (managed / BYOK only).
@@ -1068,6 +1164,12 @@ export async function runRepl(opts) {
1068
1164
  printHelp((line) => chunkEmitter.emit('chunk', line + '\n'));
1069
1165
  return;
1070
1166
  }
1167
+ // /usage (2026-10-09) — this month in credits, from the proxy's ledger.
1168
+ if (trimmed === '/usage') {
1169
+ const { runUsageCommand } = await import('./commands/usage.js');
1170
+ await runUsageCommand((l) => chunkEmitter.emit('chunk', l + '\n'));
1171
+ return;
1172
+ }
1071
1173
  // Phase 1 cost tracking (2026-05-16) — /cost prints session $ summary
1072
1174
  // from the JSONL log written each turn. Read-only, no network.
1073
1175
  if (trimmed === '/cost') {
@@ -1142,6 +1244,16 @@ export async function runRepl(opts) {
1142
1244
  // older turns via Haiku so subsequent turns ingest ~5k tokens of
1143
1245
  // context instead of 500k+. Saves \$3-8 per follow-up turn.
1144
1246
  if (trimmed === '/compact') {
1247
+ // Security batch item 1 — /compact makes a model call: gated like a turn.
1248
+ {
1249
+ const g = await licenceGate.beforeTurn();
1250
+ if (!g.allow) {
1251
+ chunkEmitter.emit('chunk', '\n' + g.message + '\n');
1252
+ return;
1253
+ }
1254
+ if (g.line)
1255
+ chunkEmitter.emit('chunk', formatInfo(g.line));
1256
+ }
1145
1257
  const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser } = await import('./commands/compact.js');
1146
1258
  const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
1147
1259
  const { loadConfig } = await import('./config/loader.js');
@@ -1150,7 +1262,7 @@ export async function runRepl(opts) {
1150
1262
  return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
1151
1263
  // model-governance step 2d — compact model resolves env > local >
1152
1264
  // server > built-in (== COMPACTION_MODEL when nothing set).
1153
- { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } });
1265
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' }, sessionId: session.id });
1154
1266
  };
1155
1267
  try {
1156
1268
  chunkEmitter.emit('chunk', '\n' + formatInfo('Compacting older turns into a summary…'));
@@ -1213,6 +1325,15 @@ export async function runRepl(opts) {
1213
1325
  chunkEmitter.emit('chunk', '\n' + formatInfo(`Rerouting "${preview}" to /${rr.skill}`));
1214
1326
  if (rr.warning)
1215
1327
  chunkEmitter.emit('chunk', chalk.dim(` (${rr.warning})\n`));
1328
+ {
1329
+ const g = await licenceGate.beforeTurn();
1330
+ if (!g.allow) {
1331
+ chunkEmitter.emit('chunk', '\n' + g.message + '\n');
1332
+ return;
1333
+ }
1334
+ if (g.line)
1335
+ chunkEmitter.emit('chunk', formatInfo(g.line));
1336
+ }
1216
1337
  // Re-review 1 — its own AbortController, wired like the main path, so
1217
1338
  // Ctrl+C and `/exit` stop a rerouted turn the same way.
1218
1339
  const rerouteAbort = new AbortController();
@@ -1570,6 +1691,20 @@ export async function runRepl(opts) {
1570
1691
  session.last_object = extractedObject ?? previousObject ?? null;
1571
1692
  return;
1572
1693
  }
1694
+ // Security batch item 1 — never mid-turn: checked here, before the turn
1695
+ // and before any plan-resume work (run lease, object-list grant,
1696
+ // re-audit, phase context, plan_in_flight). A plan step refused here
1697
+ // never starts; the plan file keeps its state, so `/abap-plan --resume`
1698
+ // continues later.
1699
+ {
1700
+ const g = await licenceGate.beforeTurn();
1701
+ if (!g.allow) {
1702
+ chunkEmitter.emit('chunk', '\n' + g.message + '\n');
1703
+ return;
1704
+ }
1705
+ if (g.line)
1706
+ chunkEmitter.emit('chunk', formatInfo(g.line));
1707
+ }
1573
1708
  // B3 (2026-06-06) — `/abap-plan --resume @plan-file`. Token-free
1574
1709
  // pre-work (load envelope, pick next eligible phase, print the
1575
1710
  // tracker board, inline the phase's rule files) happens in
@@ -1718,7 +1853,7 @@ export async function runRepl(opts) {
1718
1853
  const { beginPlanPhaseContext, makePhaseBoundarySummariser } = await import('./commands/plan-boundary.js');
1719
1854
  await beginPlanPhaseContext({
1720
1855
  session,
1721
- summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg)),
1856
+ summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg), session.id),
1722
1857
  log: (line) => chunkEmitter.emit('chunk', line + '\n'),
1723
1858
  });
1724
1859
  }
@@ -1746,7 +1881,9 @@ export async function runRepl(opts) {
1746
1881
  delegateTo: planResume.nextDelegateTo,
1747
1882
  // model-governance step 2d — the session default now resolves
1748
1883
  // env > local > server > built-in (== cfg.default_model when nothing set).
1749
- defaultModel: resolveModelRole('session_default', cfg),
1884
+ defaultModel: familyOf(resolveModelRole('session_default', cfg), provider),
1885
+ // Azure tenant mode (Task 2) — undefined (today's Sonnet) with no connection table.
1886
+ tierModel: planTierModelFor(getActiveDeployments()),
1750
1887
  });
1751
1888
  if (choice.model) {
1752
1889
  planModelOverride = choice.model;
@@ -2172,7 +2309,7 @@ export async function runRepl(opts) {
2172
2309
  session,
2173
2310
  config: { ...cfg.compact, auto_threshold_tokens: resolveAutoCompactThreshold(cfg) },
2174
2311
  queued: pendingDispatchAccessor.get() !== null,
2175
- summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg)),
2312
+ summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg), session.id),
2176
2313
  emit: (line) => chunkEmitter.emit('chunk', line + '\n'),
2177
2314
  });
2178
2315
  }
@@ -2992,6 +3129,12 @@ export async function runRepl(opts) {
2992
3129
  printHelp();
2993
3130
  continue;
2994
3131
  }
3132
+ // /usage (2026-10-09) — classic mirror of the Ink handler above.
3133
+ if (trimmed === '/usage') {
3134
+ const { runUsageCommand } = await import('./commands/usage.js');
3135
+ await runUsageCommand();
3136
+ continue;
3137
+ }
2995
3138
  // Phase 1 cost tracking (2026-05-16) — classic-mode /cost.
2996
3139
  // Mirrors the Ink-mode handler above. /cost prints session $ summary
2997
3140
  // from the JSONL log written each turn. Read-only, no network.
@@ -3062,6 +3205,16 @@ export async function runRepl(opts) {
3062
3205
  // Summarises older turns via Haiku 4.5 so subsequent turns ingest ~5k
3063
3206
  // tokens of context instead of 500k+. Backs up session pre-mutation.
3064
3207
  if (trimmed === '/compact') {
3208
+ // Security batch item 1 — /compact makes a model call: gated like a turn.
3209
+ {
3210
+ const g = await licenceGate.beforeTurn();
3211
+ if (!g.allow) {
3212
+ console.log(g.message);
3213
+ continue;
3214
+ }
3215
+ if (g.line)
3216
+ console.log(formatInfo(g.line).trimEnd());
3217
+ }
3065
3218
  const { runCompact, formatCompactResult, buildSummarisationPrompt, serialiseForSummariser } = await import('./commands/compact.js');
3066
3219
  const { summariseViaProvider } = await import('./agent/summarise-via-provider.js');
3067
3220
  const { loadConfig } = await import('./config/loader.js');
@@ -3070,7 +3223,7 @@ export async function runRepl(opts) {
3070
3223
  return await summariseViaProvider(provider, buildSummarisationPrompt(), serialiseForSummariser(toSummarise),
3071
3224
  // model-governance step 2d — compact model resolves env > local >
3072
3225
  // server > built-in (== COMPACTION_MODEL when nothing set).
3073
- { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' } });
3226
+ { model: resolveModelRole('compact', cfg), maxTokens: 4000, headers: { 'X-CSForge-Skill': 'compact', 'X-CSPeach-Model-Role': 'compact' }, sessionId: session.id });
3074
3227
  };
3075
3228
  try {
3076
3229
  console.log('\n' + formatInfo('Compacting older turns into a summary…').trimEnd());
@@ -3142,6 +3295,15 @@ export async function runRepl(opts) {
3142
3295
  console.log(formatInfo(`Rerouting "${preview}" to /${rr.skill}`).trimEnd());
3143
3296
  if (rr.warning)
3144
3297
  console.log(chalk.dim(` (${rr.warning})`));
3298
+ {
3299
+ const g = await licenceGate.beforeTurn();
3300
+ if (!g.allow) {
3301
+ console.log(g.message);
3302
+ continue;
3303
+ }
3304
+ if (g.line)
3305
+ console.log(formatInfo(g.line).trimEnd());
3306
+ }
3145
3307
  // A turn error prints the same stderr block as the normal turn path.
3146
3308
  await runRerouteTurn({
3147
3309
  run: () => runTurn({
@@ -3562,6 +3724,17 @@ export async function runRepl(opts) {
3562
3724
  session.last_object = extractedObject ?? previousObject ?? null;
3563
3725
  continue;
3564
3726
  }
3727
+ // Security batch item 1 — checked before the turn and before any
3728
+ // plan-resume work (classic mirror of the Ink path).
3729
+ {
3730
+ const g = await licenceGate.beforeTurn();
3731
+ if (!g.allow) {
3732
+ console.log(g.message);
3733
+ continue;
3734
+ }
3735
+ if (g.line)
3736
+ console.log(formatInfo(g.line).trimEnd());
3737
+ }
3565
3738
  // B3 (2026-06-06) — `/abap-plan --resume @plan-file` (classic-mode
3566
3739
  // mirror of the Ink path above; see that block for the full design
3567
3740
  // note). Token-free pre-work; terminal states end the turn with no
@@ -3688,7 +3861,7 @@ export async function runRepl(opts) {
3688
3861
  const { beginPlanPhaseContext, makePhaseBoundarySummariser } = await import('./commands/plan-boundary.js');
3689
3862
  await beginPlanPhaseContext({
3690
3863
  session,
3691
- summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg)),
3864
+ summarise: makePhaseBoundarySummariser(provider, resolveModelRole('compact', cfg), session.id),
3692
3865
  log: (line) => console.log(line),
3693
3866
  });
3694
3867
  }
@@ -3711,7 +3884,9 @@ export async function runRepl(opts) {
3711
3884
  delegateTo: planResume.nextDelegateTo,
3712
3885
  // model-governance step 2d — the session default now resolves
3713
3886
  // env > local > server > built-in (== cfg.default_model when nothing set).
3714
- defaultModel: resolveModelRole('session_default', cfg),
3887
+ defaultModel: familyOf(resolveModelRole('session_default', cfg), provider),
3888
+ // Azure tenant mode (Task 2) — undefined (today's Sonnet) with no connection table.
3889
+ tierModel: planTierModelFor(getActiveDeployments()),
3715
3890
  });
3716
3891
  if (choice.model) {
3717
3892
  planModelOverride = choice.model;
@@ -4115,7 +4290,7 @@ export async function runRepl(opts) {
4115
4290
  session,
4116
4291
  config: { ...cfg.compact, auto_threshold_tokens: resolveAutoCompactThreshold(cfg) },
4117
4292
  queued: pendingDispatchAccessor.get() !== null,
4118
- summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg)),
4293
+ summarise: makeAutoCompactSummariser(provider, resolveModelRole('compact', cfg), session.id),
4119
4294
  emit: (line) => console.log(line),
4120
4295
  });
4121
4296
  }
@@ -33,7 +33,9 @@ import Anthropic from '@anthropic-ai/sdk';
33
33
  import keychain from '../auth/keychain.js';
34
34
  import { byokRouterModel } from '../models/server-config.js';
35
35
  import { addRouterSpend } from '../cost/router-spend.js';
36
+ import { reportCallUsage } from '../usage/usage-reporter.js';
36
37
  import { loadRouterPrompt } from './router-prompt.js';
38
+ import { azureSdkOptions } from '../connection/azure-auth.js';
37
39
  import { ROUTER_UNPARSEABLE_RESULT, buildRouterRequest, runRouterCalls, } from './router-core.js';
38
40
  /** Same keychain entry the BYOK provider reads (byok-provider.ts) and login writes. */
39
41
  export const BYOK_KEYCHAIN_SERVICE = 'cspeach.anthropic';
@@ -65,6 +67,17 @@ export class ByokRouterFailure extends Error {
65
67
  this.name = 'ByokRouterFailure';
66
68
  }
67
69
  }
70
+ /** Final fix wave B — fire-and-forget, never throws: usage counts are best effort. */
71
+ function reportAzureRouterCall(family, u) {
72
+ try {
73
+ void reportCallUsage({
74
+ mode: 'azure', skill: '_router', model: family,
75
+ input: u.input, output: u.output, cacheRead: u.cacheRead, cacheWrite: u.cacheCreate,
76
+ sessionId: '', started: true,
77
+ }).catch(() => { });
78
+ }
79
+ catch { /* best effort */ }
80
+ }
68
81
  function causeOf(err) {
69
82
  const e = err;
70
83
  if (e?.cause)
@@ -86,10 +99,13 @@ export async function classifyByok(prompt, context, images, opts = {}) {
86
99
  const target = opts.target ?? { mode: 'byok' };
87
100
  if (opts.signal?.aborted)
88
101
  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) {
102
+ // Azure signs in through target.auth() (Entra token or Azure key), never the keychain entries here.
103
+ const key = target.mode === 'azure'
104
+ ? null
105
+ : target.mode === 'byok'
106
+ ? await keychain.getPassword(BYOK_KEYCHAIN_SERVICE, BYOK_KEYCHAIN_ACCOUNT)
107
+ : await keychain.getPassword(AI_HUB_KEYCHAIN_SERVICE, AI_HUB_KEYCHAIN_ACCOUNT);
108
+ if (target.mode !== 'azure' && !key) {
93
109
  throw new Error(target.mode === 'byok'
94
110
  ? 'BYOK Anthropic API key not configured — run: cspeach config set llm.byok_key <key>'
95
111
  : 'AI Hub token not configured — run: cspeach config set llm.ai_hub_token <token>');
@@ -104,18 +120,23 @@ export async function classifyByok(prompt, context, images, opts = {}) {
104
120
  const userCancelled = () => opts.signal?.aborted === true;
105
121
  try {
106
122
  const { prompt: routerPrompt } = await loadRouterPrompt({ signal: ctrl.signal });
107
- const model = target.mode === 'byok' ? byokRouterModel() : (target.modelAlias ?? AI_HUB_DEFAULT_MODEL);
123
+ const model = target.mode === 'byok' ? byokRouterModel() : target.mode === 'azure' ? target.model : (target.modelAlias ?? AI_HUB_DEFAULT_MODEL);
108
124
  const full = buildRouterRequest(model, prompt, context, images, routerPrompt);
109
125
  // ai-hub: today's field set only (the hub has no flag for newer fields).
110
- const request = target.mode === 'byok'
126
+ // azure: output_config only when the profile allows structured outputs.
127
+ const request = target.mode === 'byok' || (target.mode === 'azure' && target.structuredOutputs)
111
128
  ? full
112
129
  : (({ 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
- });
130
+ // Azure: the guarded options (host allow-list on every request URL, no
131
+ // redirects, the other auth field null so no ANTHROPIC_* env var leaks).
132
+ const sdk = target.mode === 'azure'
133
+ ? new Anthropic({ ...azureSdkOptions(target.baseURL, await target.auth()), maxRetries: 1, timeout: ROUTER_CALL_TIMEOUT_MS })
134
+ : new Anthropic({
135
+ apiKey: key,
136
+ maxRetries: 1,
137
+ timeout: ROUTER_CALL_TIMEOUT_MS,
138
+ ...(target.mode === 'ai-hub' ? { baseURL: target.baseURL } : {}),
139
+ });
119
140
  const { result } = await runRouterCalls((req) => sdk.messages.create(req, { signal: ctrl.signal }), request, {
120
141
  // Once per session — a model that rejects structured outputs would
121
142
  // otherwise print this on every prompt.
@@ -129,8 +150,14 @@ export async function classifyByok(prompt, context, images, opts = {}) {
129
150
  // call finishes, so a deadline / cancel after call 1 keeps call 1
130
151
  // (review S1). ai-hub is billed by their BTP account at rates we
131
152
  // don't know: not priced here.
132
- onCall: (c) => { if (target.mode === 'byok')
133
- addRouterSpend(model, c.usage); },
153
+ // Azure (final fix wave B): each router call is counted like any other
154
+ // azure call — a usage record and the local limit count.
155
+ onCall: (c) => {
156
+ if (target.mode === 'byok')
157
+ addRouterSpend(model, c.usage);
158
+ else if (target.mode === 'azure')
159
+ reportAzureRouterCall(target.family, c.usage);
160
+ },
134
161
  });
135
162
  return result ?? { ...ROUTER_UNPARSEABLE_RESULT, alternates: [] };
136
163
  }
@@ -6,6 +6,7 @@ import { isBuiltinCommandHead } from '../repl/builtin-commands.js';
6
6
  import { expandImageAttachments } from '../projects/image-attachments.js';
7
7
  import { expandImageChips } from '../ui/clipboard-image.js';
8
8
  import { classifyByok, ByokRouterFailure } from './byok-classifier.js';
9
+ import { routerRouteFor } from './router-target.js';
9
10
  /** A non-2xx answer from the classify endpoint (fix 9: carries the kind). */
10
11
  export class ClassifyHttpError extends Error {
11
12
  status;
@@ -165,26 +166,14 @@ export async function classifyPrompt(prompt, context, opts = {}) {
165
166
  // with the customer's own key; ai-hub through the customer's own hub;
166
167
  // local by the bundled rules (byok-classifier.ts / localModeResult).
167
168
  // Managed keeps /v1/classify below, byte-identical.
168
- const mode = cfg.llm?.mode;
169
- if (mode === 'local') {
169
+ const route = routerRouteFor(cfg);
170
+ if (route.kind === 'local-rules') {
170
171
  if (opts.signal?.aborted)
171
172
  throw routingCancelled();
172
173
  return localModeResult(prompt);
173
174
  }
174
- if (mode === 'byok')
175
- return classifyPromptByok(prompt, context, opts, { mode: 'byok' });
176
- if (mode === 'ai-hub') {
177
- const baseURL = cfg.llm.ai_hub_base_url;
178
- if (!baseURL)
179
- throw new Error('ai-hub mode requires llm.ai_hub_base_url — run: cspeach config set llm.ai_hub_base_url <url>');
180
- // llm.ai_hub_router_model (optional, 2026-10-02) routes on a cheaper /
181
- // faster deployment; unset ⇒ the session alias.
182
- return classifyPromptByok(prompt, context, opts, {
183
- mode: 'ai-hub',
184
- baseURL,
185
- modelAlias: cfg.llm.ai_hub_router_model ?? cfg.llm.ai_hub_model_alias,
186
- });
187
- }
175
+ if (route.kind === 'direct')
176
+ return classifyPromptByok(prompt, context, opts, route.target);
188
177
  const store = await getStore();
189
178
  const key = await store.get();
190
179
  if (!key)
@@ -0,0 +1,41 @@
1
+ import { profileForMode } from '../agent/connection-profile.js';
2
+ import { getActiveConnection } from '../connection/cache.js';
3
+ import { azureTurnVerdict, AzureConnectionRefusedError } from '../connection/state.js';
4
+ import { selectAzureAuth } from '../connection/azure-auth.js';
5
+ import { routerDeployment } from '../models/model-identity.js';
6
+ export function routerRouteFor(cfg, profileFor = profileForMode) {
7
+ const mode = cfg.llm?.mode;
8
+ const p = profileFor(mode);
9
+ if (p.router === 'local-rules')
10
+ return { kind: 'local-rules' };
11
+ if (p.router === 'proxy')
12
+ return { kind: 'proxy' };
13
+ switch (mode) {
14
+ case 'byok':
15
+ return { kind: 'direct', target: { mode: 'byok' } };
16
+ case 'ai-hub': {
17
+ const baseURL = cfg.llm.ai_hub_base_url;
18
+ if (!baseURL)
19
+ throw new Error('ai-hub mode requires llm.ai_hub_base_url — run: cspeach config set llm.ai_hub_base_url <url>');
20
+ // llm.ai_hub_router_model (optional, 2026-10-02) routes on a cheaper /
21
+ // faster deployment; unset ⇒ the session alias.
22
+ return { kind: 'direct', target: { mode: 'ai-hub', baseURL, modelAlias: cfg.llm.ai_hub_router_model ?? cfg.llm.ai_hub_model_alias } };
23
+ }
24
+ case 'azure': {
25
+ const cached = getActiveConnection();
26
+ const v = azureTurnVerdict(cached);
27
+ if (!v.allow)
28
+ throw new AzureConnectionRefusedError(v.message);
29
+ const c = cached.connection;
30
+ return {
31
+ kind: 'direct',
32
+ target: {
33
+ mode: 'azure', baseURL: c.endpoint, model: routerDeployment(c.deployments).name, family: routerDeployment(c.deployments).model,
34
+ auth: async () => (await selectAzureAuth(c)).getAuth(), structuredOutputs: p.structuredOutputs,
35
+ },
36
+ };
37
+ }
38
+ default:
39
+ throw new Error(`no direct router for llm.mode ${String(mode)}`);
40
+ }
41
+ }