@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.
- package/dist/agent/anthropic-provider.js +2 -0
- package/dist/agent/azure-profile.js +7 -0
- package/dist/agent/connection-profile.js +49 -0
- package/dist/agent/loop.js +61 -8
- package/dist/agent/provider-shape.js +36 -0
- package/dist/agent/providers/ai-hub-provider.js +6 -5
- package/dist/agent/providers/azure-provider.js +125 -0
- package/dist/agent/providers/byok-provider.js +8 -7
- package/dist/agent/providers/factory.js +8 -3
- package/dist/agent/providers/local-provider.js +18 -22
- package/dist/agent/risk-gate.js +202 -0
- package/dist/agent/summarise-via-provider.js +55 -8
- package/dist/agent/tool-dispatch.js +19 -0
- package/dist/agent/turn-error-ux.js +22 -0
- package/dist/auth/api-key.js +10 -5
- package/dist/auth/auth-file.js +35 -6
- package/dist/auth/azure/azure-key.js +21 -0
- package/dist/auth/azure/azure-log.js +45 -0
- package/dist/auth/azure/cache-store.js +171 -0
- package/dist/auth/azure/clear.js +28 -0
- package/dist/auth/azure/loopback.js +192 -0
- package/dist/auth/azure/msal-client.js +41 -0
- package/dist/auth/azure/sign-in.js +219 -0
- package/dist/auth/azure/startup-sign-in.js +64 -0
- package/dist/auth/azure/token-provider.js +116 -0
- package/dist/auth/key-resolver.js +258 -0
- package/dist/auth/me.js +11 -5
- package/dist/cli.js +21 -2
- package/dist/commands/compact.js +12 -1
- package/dist/commands/config-set.js +74 -3
- package/dist/commands/config-show.js +25 -0
- package/dist/commands/cost.js +25 -8
- package/dist/commands/help.js +2 -1
- package/dist/commands/login-azure.js +33 -0
- package/dist/commands/login.js +56 -8
- package/dist/commands/logout.js +34 -10
- package/dist/commands/plan-boundary.js +4 -4
- package/dist/commands/plan-model-tier.js +8 -3
- package/dist/commands/usage.js +112 -0
- package/dist/commands/whoami.js +113 -9
- package/dist/config/loader.js +5 -0
- package/dist/connection/azure-auth.js +135 -0
- package/dist/connection/azure-errors.js +103 -0
- package/dist/connection/azure-facts.js +21 -0
- package/dist/connection/azure-gate.js +40 -0
- package/dist/connection/azure-last-call.js +5 -0
- package/dist/connection/azure-turn-error.js +25 -0
- package/dist/connection/cache.js +70 -0
- package/dist/connection/fetch.js +33 -0
- package/dist/connection/guard.js +94 -0
- package/dist/connection/limit-gate.js +26 -0
- package/dist/connection/local-usage.js +71 -0
- package/dist/connection/login-step.js +62 -0
- package/dist/connection/startup.js +67 -0
- package/dist/connection/state.js +68 -0
- package/dist/connection/types.js +1 -0
- package/dist/cost/cost-log.js +3 -1
- package/dist/cost/credits-wire.js +1 -1
- package/dist/cost/credits.js +4 -0
- package/dist/cost/display-mode.js +7 -0
- package/dist/cost/month-usage.js +45 -0
- package/dist/cost/router-spend.js +3 -0
- package/dist/cost/session-cost.js +4 -0
- package/dist/doctor/checks/azure.js +100 -0
- package/dist/doctor/checks/credits.js +4 -3
- package/dist/doctor/checks/usage-reporting.js +22 -0
- package/dist/doctor/run.js +7 -0
- package/dist/licence/licence-check.js +205 -0
- package/dist/licence/paths.js +6 -0
- package/dist/licence/turn-gate.js +66 -0
- package/dist/models/model-identity.js +65 -0
- package/dist/models/resolve.js +8 -0
- package/dist/models/server-config.js +3 -2
- package/dist/one-shot.js +86 -2
- package/dist/renderer/footer-line.js +7 -1
- package/dist/renderer/status-footer.js +13 -6
- package/dist/repl/ask-or-no.js +18 -0
- package/dist/repl/builtin-commands.js +12 -1
- package/dist/repl/post-turn-status.js +3 -2
- package/dist/repl/safety-confirm.js +47 -2
- package/dist/repl/session-spend-line.js +4 -1
- package/dist/repl/slash-completer.js +1 -0
- package/dist/repl/startup-account.js +51 -0
- package/dist/repl.js +223 -48
- package/dist/router/byok-classifier.js +41 -14
- package/dist/router/classifier.js +5 -16
- package/dist/router/router-target.js +41 -0
- package/dist/sap/onboarding.js +70 -64
- package/dist/session/audit-export.js +11 -4
- package/dist/skills/manifest-client.js +37 -4
- package/dist/skills/skills-cache.js +115 -0
- package/dist/skills/source-authed.js +276 -0
- package/dist/tools/filesystem/glob.js +12 -3
- package/dist/tools/filesystem/grep.js +14 -3
- package/dist/tools/tool-loading.js +9 -10
- package/dist/usage/usage-queue.js +323 -0
- package/dist/usage/usage-reporter.js +270 -0
- package/package.json +3 -2
- package/dist/skills/bundled-skills.js +0 -286
- package/dist/skills/source-bundled.js +0 -20
- 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
|
-
|
|
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)
|
|
461
|
-
//
|
|
462
|
-
//
|
|
463
|
-
//
|
|
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
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
if (
|
|
472
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
timeout: ROUTER_CALL_TIMEOUT_MS
|
|
117
|
-
|
|
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
|
-
|
|
133
|
-
|
|
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
|
|
169
|
-
if (
|
|
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 (
|
|
175
|
-
return classifyPromptByok(prompt, context, opts,
|
|
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
|
+
}
|