@cspeach/cli 1.1.25 → 1.1.27
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/loop.js +28 -9
- package/dist/agent/tool-dispatch.js +6 -0
- package/dist/auth/auth-file.js +173 -12
- package/dist/classifier/client.js +55 -30
- package/dist/cli.js +11 -0
- package/dist/commands/config-set.js +13 -0
- package/dist/commands/config-show.js +5 -1
- package/dist/commands/cost.js +24 -22
- package/dist/commands/deploy-card.js +135 -0
- package/dist/commands/login.js +31 -13
- package/dist/commands/plan-chain.js +31 -0
- package/dist/commands/plan-continue.js +1 -0
- package/dist/commands/plan-resume.js +49 -2
- package/dist/commands/whoami.js +1 -1
- package/dist/cost/cost-log.js +76 -4
- package/dist/cost/credits-wire.js +9 -0
- package/dist/cost/credits.js +24 -6
- package/dist/cost/router-spend.js +24 -0
- package/dist/cost/session-cost.js +16 -2
- package/dist/doctor/checks/skill.js +60 -11
- package/dist/doctor/run.js +0 -2
- package/dist/lane/app-chain.js +28 -0
- package/dist/models/server-config.js +21 -1
- package/dist/one-shot.js +12 -2
- package/dist/projects/extract-design.js +4 -3
- package/dist/projects/extract-estimate.js +4 -3
- package/dist/projects/extract-spec-gap.js +28 -5
- package/dist/projects/plan-lane.js +47 -0
- package/dist/projects/plan-run.js +8 -0
- package/dist/projects/plan-schema.js +2 -0
- package/dist/projects/save-command.js +7 -3
- package/dist/projects/title-heading.js +36 -0
- package/dist/renderer/footer-line.js +10 -4
- package/dist/renderer/status-footer.js +5 -4
- package/dist/renderer/thinking-heartbeat.js +2 -1
- package/dist/renderer/tool-labels.js +2 -1
- package/dist/repl.js +41 -8
- package/dist/router/byok-classifier.js +159 -0
- package/dist/router/classifier.js +130 -6
- package/dist/router/router-core.js +307 -0
- package/dist/router/router-prompt.js +121 -0
- package/dist/router/routing-failure.js +65 -3
- package/dist/sap/unreachable.js +4 -1
- package/dist/skills/bundled-skills.js +45 -45
- package/dist/tools/fiori/deploy/existing-bsp.js +67 -0
- package/dist/tools/fiori/deploy/run.js +608 -0
- package/dist/tools/fiori/deploy/scope.js +8 -0
- package/dist/tools/fiori/deploy/yaml-read.js +236 -0
- package/dist/tools/fiori/deploy-config.js +24 -6
- package/dist/tools/fiori/tools.js +117 -5
- package/dist/tools/local-build.js +3 -0
- package/dist/tools/subagent/read_agent.js +4 -2
- package/dist/tools/tool-loading.js +1 -1
- package/package.json +121 -121
- package/dist/doctor/checks/zcspeach.js +0 -96
|
@@ -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
|
-
|
|
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.
|
|
86
|
+
return formatCredits(c.chargedThisRequest, c.unitLabel);
|
|
86
87
|
}
|
|
87
88
|
return p.costThisTurn != null ? formatCost(p.costThisTurn) : '';
|
|
88
89
|
}
|
|
89
|
-
/**
|
|
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
|
-
|
|
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] : []),
|
|
@@ -49,7 +49,8 @@ const GENERIC_SUFFIXES = [
|
|
|
49
49
|
{ at: 30_000, suffix: ' (30s)' },
|
|
50
50
|
{ at: 60_000, suffix: ' (60s — still running, hold on)' },
|
|
51
51
|
{ at: 120_000, suffix: ' (2m — still running; Ctrl+C aborts the turn)' },
|
|
52
|
-
|
|
52
|
+
// 2026-10-04: never assume a VPN — many users sit on the company network.
|
|
53
|
+
{ at: 300_000, suffix: ' (5m — likely stuck; Ctrl+C and check network access to the SAP system)' },
|
|
53
54
|
];
|
|
54
55
|
/**
|
|
55
56
|
* 2026-07-06 (audit-timeout) — the plan-chain audit's own liveness schedule.
|
|
@@ -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
|
-
|
|
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
|
-
|
|
3081
|
-
|
|
3082
|
-
|
|
3083
|
-
|
|
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
|
+
}
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { loadConfig } from '../config/loader.js';
|
|
2
2
|
import { getStore } from '../auth/api-key.js';
|
|
3
|
-
import { classify as classifyLocal } from '../classifier/client.js';
|
|
3
|
+
import { classify as classifyLocal, classifyWithConfidence } from '../classifier/client.js';
|
|
4
4
|
import { SKILL_CATALOG, SKILL_ALIASES, resolveSkillAlias } from '../skill-catalog.js';
|
|
5
5
|
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
|
+
import { classifyByok, ByokRouterFailure } from './byok-classifier.js';
|
|
8
9
|
/** A non-2xx answer from the classify endpoint (fix 9: carries the kind). */
|
|
9
10
|
export class ClassifyHttpError extends Error {
|
|
10
11
|
status;
|
|
@@ -15,6 +16,51 @@ export class ClassifyHttpError extends Error {
|
|
|
15
16
|
this.name = 'ClassifyHttpError';
|
|
16
17
|
}
|
|
17
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* Live fix 2026-10-02 — the classify endpoint answered 2xx but the body was
|
|
21
|
+
* not a classification (not JSON, or no skill). Used to escape as a raw
|
|
22
|
+
* SyntaxError; now a routing failure the callers degrade from.
|
|
23
|
+
*/
|
|
24
|
+
export class ClassifyBodyError extends Error {
|
|
25
|
+
transport_kind = 'failed';
|
|
26
|
+
constructor() {
|
|
27
|
+
super('classifier returned an unreadable answer');
|
|
28
|
+
this.name = 'ClassifyBodyError';
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The bundled local-rules pick used whenever the remote classifier gives no
|
|
33
|
+
* answer (transport failure, and since 2026-10-02 any classify failure).
|
|
34
|
+
* Confidence 0.3 < 0.60 ⇒ the low-confidence path (picker / one-shot notice).
|
|
35
|
+
*
|
|
36
|
+
* 2026-10-02: the no-rule-matched default is abap-explain (read-only,
|
|
37
|
+
* generic — the same default the router uses for an unparseable answer). It
|
|
38
|
+
* was abap-radar, retired 2026-06-28 (merged into abap-explain's technical
|
|
39
|
+
* mode).
|
|
40
|
+
*/
|
|
41
|
+
export function localRulesResult(prompt) {
|
|
42
|
+
return { skill: classifyLocal(prompt) ?? 'abap-explain', confidence: 0.3, alternates: [] };
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* Local mode's routing (2026-10-02, review items 3 + S2): the bundled rules
|
|
46
|
+
* only — the prompt never leaves the laptop. A matching rule is a real pick,
|
|
47
|
+
* not a failure fallback, so there is no "couldn't route" notice: an
|
|
48
|
+
* unambiguous (strong) rule routes silently (0.85), any other match opens the
|
|
49
|
+
* picker preselected (0.6); no match is the low-confidence local-rules result
|
|
50
|
+
* (picker, abap-explain).
|
|
51
|
+
*
|
|
52
|
+
* Why not the local model: local endpoints (Ollama / vLLM, OpenAI-compatible)
|
|
53
|
+
* have no structured outputs, small local models are unreliable at strict
|
|
54
|
+
* JSON routing, and a large one adds seconds per prompt on a laptop — on top
|
|
55
|
+
* of the turn itself. Local mode is text-only today; instant, offline,
|
|
56
|
+
* deterministic rules with a one-key confirm fit it better.
|
|
57
|
+
*/
|
|
58
|
+
function localModeResult(prompt) {
|
|
59
|
+
const hit = classifyWithConfidence(prompt);
|
|
60
|
+
return hit
|
|
61
|
+
? { skill: resolveSkillAlias(hit.skill), confidence: hit.confidence, alternates: [] }
|
|
62
|
+
: localRulesResult(prompt);
|
|
63
|
+
}
|
|
18
64
|
// 8s — sized to cover the observed Fly cold-start (~3.2s on the 2026-05-20
|
|
19
65
|
// probe) plus headroom for a slow network. Tighter values risk timing out
|
|
20
66
|
// legitimate cold-start hits and burning the retry budget on the same.
|
|
@@ -111,6 +157,31 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
111
157
|
throw new Error('slash command reached the classifier — explicit /commands must be routed before classification (CLI bug, please report)');
|
|
112
158
|
}
|
|
113
159
|
const cfg = await loadConfig();
|
|
160
|
+
// 2026-10-02 (local routing): outside managed mode the prompt never goes to
|
|
161
|
+
// a CSPeach host. BYOK classifies on the laptop, direct to the model API
|
|
162
|
+
// with the customer's own key; ai-hub through the customer's own hub;
|
|
163
|
+
// local by the bundled rules (byok-classifier.ts / localModeResult).
|
|
164
|
+
// Managed keeps /v1/classify below, byte-identical.
|
|
165
|
+
const mode = cfg.llm?.mode;
|
|
166
|
+
if (mode === 'local') {
|
|
167
|
+
if (opts.signal?.aborted)
|
|
168
|
+
throw routingCancelled();
|
|
169
|
+
return localModeResult(prompt);
|
|
170
|
+
}
|
|
171
|
+
if (mode === 'byok')
|
|
172
|
+
return classifyPromptByok(prompt, context, opts, { mode: 'byok' });
|
|
173
|
+
if (mode === 'ai-hub') {
|
|
174
|
+
const baseURL = cfg.llm.ai_hub_base_url;
|
|
175
|
+
if (!baseURL)
|
|
176
|
+
throw new Error('ai-hub mode requires llm.ai_hub_base_url — run: cspeach config set llm.ai_hub_base_url <url>');
|
|
177
|
+
// llm.ai_hub_router_model (optional, 2026-10-02) routes on a cheaper /
|
|
178
|
+
// faster deployment; unset ⇒ the session alias.
|
|
179
|
+
return classifyPromptByok(prompt, context, opts, {
|
|
180
|
+
mode: 'ai-hub',
|
|
181
|
+
baseURL,
|
|
182
|
+
modelAlias: cfg.llm.ai_hub_router_model ?? cfg.llm.ai_hub_model_alias,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
114
185
|
const store = await getStore();
|
|
115
186
|
const key = await store.get();
|
|
116
187
|
if (!key)
|
|
@@ -147,11 +218,8 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
147
218
|
// local rules so the user's turn is NOT dropped — they still get a
|
|
148
219
|
// coaching picker (confidence 0.3 < 0.60 = picker decision), and the
|
|
149
220
|
// REPL surfaces an offline hint with the real cause.
|
|
150
|
-
const skill = classifyLocal(prompt) ?? 'abap-radar';
|
|
151
221
|
return {
|
|
152
|
-
|
|
153
|
-
confidence: 0.3,
|
|
154
|
-
alternates: [],
|
|
222
|
+
...localRulesResult(prompt),
|
|
155
223
|
offline: true,
|
|
156
224
|
transport_error: lastErr ? extractCause(lastErr) : 'unknown',
|
|
157
225
|
transport_kind: transportKindOf(lastErr),
|
|
@@ -159,7 +227,28 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
159
227
|
}
|
|
160
228
|
if (!r.ok)
|
|
161
229
|
throw new ClassifyHttpError(r.status);
|
|
162
|
-
|
|
230
|
+
// Live fix 2026-10-02: an unreadable 2xx body is a routing failure, not a
|
|
231
|
+
// raw SyntaxError escaping to the caller.
|
|
232
|
+
let result;
|
|
233
|
+
try {
|
|
234
|
+
result = (await r.json());
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
throw new ClassifyBodyError();
|
|
238
|
+
}
|
|
239
|
+
return normaliseResult(result);
|
|
240
|
+
}
|
|
241
|
+
/**
|
|
242
|
+
* The checks every classification gets, wherever it came from (the proxy, or
|
|
243
|
+
* since 2026-10-02 the BYOK router on the laptop).
|
|
244
|
+
*/
|
|
245
|
+
function normaliseResult(result) {
|
|
246
|
+
if (!result || typeof result.skill !== 'string' || result.skill === '')
|
|
247
|
+
throw new ClassifyBodyError();
|
|
248
|
+
if (typeof result.confidence !== 'number')
|
|
249
|
+
result.confidence = 0;
|
|
250
|
+
if (!Array.isArray(result.alternates))
|
|
251
|
+
result.alternates = [];
|
|
163
252
|
// Portfolio-merge resilience (2026-06-12): the proxy classifier's skill
|
|
164
253
|
// list can lag a rename (it still knows 'abap-dump-analyze' until its
|
|
165
254
|
// SKILL_LIST is redeployed). Map retired names — primary and alternates —
|
|
@@ -171,6 +260,41 @@ export async function classifyPrompt(prompt, context, opts = {}) {
|
|
|
171
260
|
}
|
|
172
261
|
return result;
|
|
173
262
|
}
|
|
263
|
+
/**
|
|
264
|
+
* BYOK / ai-hub branch of classifyPrompt (2026-10-02). Same contract as the managed
|
|
265
|
+
* path — a result, a low-confidence local-rules result when the router could
|
|
266
|
+
* not be reached, ClassifyHttpError for an HTTP failure, AbortError on cancel
|
|
267
|
+
* — so every caller (Ink REPL, classic REPL, one-shot) is unchanged.
|
|
268
|
+
*/
|
|
269
|
+
async function classifyPromptByok(prompt, context, opts, target) {
|
|
270
|
+
if (opts.signal?.aborted)
|
|
271
|
+
throw routingCancelled();
|
|
272
|
+
const images = await promptImages(prompt, opts.cwd ?? process.cwd());
|
|
273
|
+
if (opts.signal?.aborted)
|
|
274
|
+
throw routingCancelled();
|
|
275
|
+
try {
|
|
276
|
+
const result = await classifyByok(prompt, { sap_system: context.sap_system, last_skill: context.last_skill }, images, { signal: opts.signal, target });
|
|
277
|
+
return normaliseResult({ ...result });
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
if (opts.signal?.aborted)
|
|
281
|
+
throw routingCancelled();
|
|
282
|
+
if (err instanceof ByokRouterFailure) {
|
|
283
|
+
const f = err.failure;
|
|
284
|
+
if (f.kind === 'http')
|
|
285
|
+
throw new ClassifyHttpError(f.status);
|
|
286
|
+
// The model API was not reached. 'failed', not 'network': the network
|
|
287
|
+
// notice names the CSPeach service, which BYOK routing never calls.
|
|
288
|
+
return {
|
|
289
|
+
...localRulesResult(prompt),
|
|
290
|
+
offline: true,
|
|
291
|
+
transport_error: f.detail,
|
|
292
|
+
transport_kind: f.kind === 'timeout' ? 'timeout' : 'failed',
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
throw err;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
174
298
|
export function parseShortcut(input) {
|
|
175
299
|
// `/skill-name rest` → { skill: 'abap-skill-name', prompt: 'rest' }
|
|
176
300
|
// abap- prefix auto-prepended if missing (all skills in KV use abap-* namespace).
|