@cspeach/cli 1.1.24 → 1.1.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agent/loop.js +20 -8
- 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/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/models/server-config.js +21 -1
- package/dist/one-shot.js +12 -2
- package/dist/projects/detail-path.js +117 -0
- package/dist/projects/merge-cca.js +19 -0
- package/dist/projects/merge-upgrade.js +19 -1
- 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/promote.js +7 -1
- package/dist/projects/save-command.js +3 -2
- package/dist/register/records.js +7 -2
- package/dist/renderer/footer-line.js +10 -4
- package/dist/renderer/status-footer.js +5 -4
- 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/skills/bundled-skills.js +45 -45
- package/dist/tools/_filesystem-shared.js +363 -33
- package/dist/tools/cap/metadata-import.js +10 -4
- package/dist/tools/filesystem/file-write.js +12 -39
- package/dist/tools/filesystem/glob.js +25 -2
- package/dist/tools/filesystem/grep.js +25 -2
- package/dist/tools/fiori/apply.js +30 -21
- package/dist/tools/fiori/commit-gate.js +58 -0
- 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/fe-extend.js +6 -1
- package/dist/tools/fiori/fe-scaffold.js +6 -1
- package/dist/tools/fiori/path-params.js +78 -0
- package/dist/tools/fiori/scaffold.js +2 -1
- package/dist/tools/fiori/tools.js +170 -19
- package/dist/tools/local-build.js +3 -0
- package/dist/tools/project/_merge-shared.js +36 -1
- package/dist/tools/project/cca_merge.js +4 -0
- package/dist/tools/project/upgrade_merge_progress.js +21 -10
- package/dist/tools/subagent/read_agent.js +4 -2
- package/dist/tools/tool-loading.js +1 -1
- package/package.json +1 -1
|
@@ -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).
|
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
// GENERATED COPY — do not edit here. Edit cspeach-proxy/src/classifier/router-core.ts,
|
|
2
|
+
// then run `npx tsx scripts/sync-router-core.ts` (prebuild runs it too).
|
|
3
|
+
// src/router/__tests__/router-core-pin.test.ts fails when this copy drifts.
|
|
4
|
+
// ---- verbatim copy of cspeach-proxy/src/classifier/router-core.ts below ----
|
|
5
|
+
// ROUTER CORE — the single source of the skill router's instructions and call
|
|
6
|
+
// behaviour (2026-10-02, BYOK local routing).
|
|
7
|
+
//
|
|
8
|
+
// Why this file exists: in BYOK mode the customer's prompt must never reach a
|
|
9
|
+
// CSPeach server, so the CLI classifies on the laptop, direct to the model
|
|
10
|
+
// with the customer's own key. To keep that routing exactly as good as the
|
|
11
|
+
// managed /v1/classify route, both sides run THIS code:
|
|
12
|
+
// - the proxy imports it (classifier/haiku.ts, and skills/skill-manifest.ts,
|
|
13
|
+
// which publishes ROUTER_PROMPT inside the signed skill manifest);
|
|
14
|
+
// - the CLI carries a byte-identical copy at
|
|
15
|
+
// cspeach-cli/src/router/router-core.ts (scripts/sync-router-core.ts
|
|
16
|
+
// writes it; a CLI test fails on any drift).
|
|
17
|
+
//
|
|
18
|
+
// RULES for this file: NO imports (it must compile, unchanged, in both
|
|
19
|
+
// packages); keep it plain TypeScript that passes both tsconfigs. Editing the
|
|
20
|
+
// skill list or routing notes here changes managed routing (after a proxy
|
|
21
|
+
// deploy), the signed manifest (after publish-skill-manifest) and the CLI's
|
|
22
|
+
// bundled fallback (after sync-router-core + a CLI release).
|
|
23
|
+
/**
|
|
24
|
+
* Version of the router entry's SHAPE in the signed manifest
|
|
25
|
+
* ({ schema_version, system, screenshot_note }). A CLI uses a manifest router
|
|
26
|
+
* entry only when it knows this version; otherwise it keeps its bundled copy.
|
|
27
|
+
* Bump it only when the shape or the request contract changes — not for text
|
|
28
|
+
* edits.
|
|
29
|
+
*/
|
|
30
|
+
export const ROUTER_SCHEMA_VERSION = 1;
|
|
31
|
+
/**
|
|
32
|
+
* Revision of the router TEXT and request (2026-10-02, review item 2). Bump
|
|
33
|
+
* it on EVERY change to SKILL_DESCRIPTIONS, the routing notes, ROUTER_SYSTEM,
|
|
34
|
+
* SCREENSHOT_NOTE or buildRouterRequest. A BYOK CLI prefers the signed
|
|
35
|
+
* manifest's router entry only when its revision is the same or newer than
|
|
36
|
+
* its own bundled copy — so a stale published manifest never rolls a newer
|
|
37
|
+
* CLI back to older routing. router-revision-pin.test.ts fails until bumped.
|
|
38
|
+
*/
|
|
39
|
+
export const ROUTER_REVISION = 1;
|
|
40
|
+
// 2026-06-12: keep in lockstep with .claude/skills (35 abap skills).
|
|
41
|
+
// abap-dump replaced abap-dump-analyze + abap-dump-triage (portfolio merge);
|
|
42
|
+
// added previously-missing abap-plan, abap-fiori-build, abap-cca-merge, abap-upgrade-merge.
|
|
43
|
+
// 2026-06-28 (M1-M4 portfolio merges): removed 4 donor skills — abap-radar → abap-explain
|
|
44
|
+
// (technical orientation mode); abap-document → abap-handover (object scope); abap-performance
|
|
45
|
+
// → abap-review (performance lens); abap-cloud-check → abap-review (cloud lens). Skill count
|
|
46
|
+
// drops from 35 to 31. abap-preflight is NOT removed (chained by abap-transport-analysis).
|
|
47
|
+
// 2026-06-15 (Bug #1): pair every skill with a one-line description. Haiku was
|
|
48
|
+
// routing on bare NAMES — "color the lines on a custom Z report" matched the word
|
|
49
|
+
// "enhance" (82%) when it should have been abap-refactor; Haiku never saw that
|
|
50
|
+
// abap-enhance = extend SAP STANDARD only. The one-liners + routing notes below
|
|
51
|
+
// encode the key disambiguations directly. These keys ARE the skill list — there
|
|
52
|
+
// is no separate name array to drift out of sync.
|
|
53
|
+
// 2026-09-25: added abap-cap (CAP phase 1) — "build a CAP app" had no home and
|
|
54
|
+
// fell back to abap-generate at low confidence.
|
|
55
|
+
// 2026-10-01: added abap-app (app lane routing slice 1) — one entry from a spec
|
|
56
|
+
// document to a running app; the CAP routing note below is unchanged.
|
|
57
|
+
export const SKILL_DESCRIPTIONS = {
|
|
58
|
+
'abap-app': 'Build an app FROM a spec or requirements document (Word, PDF, Excel or text) — finds the spec gaps, picks the app lane (backend + UI) and confirms it with the developer, then plans and builds the app.',
|
|
59
|
+
'abap-atc-fix': 'Run ATC checks on an object and auto-fix findings where safe.',
|
|
60
|
+
'abap-cap': 'Build a CAP (SAP Cloud Application Programming Model, Node.js / CDS) app on the laptop — domain model, service, handlers, Fiori Elements app — run it locally and prove it with the Fiori checks. Side-by-side (BTP) extensions, not the ABAP core.',
|
|
61
|
+
'abap-cca': 'Custom Code Analysis — inventory + classify a whole custom-code estate (Keep/Fix/Retire) for S/4HANA migration / Clean Core.',
|
|
62
|
+
'abap-cca-merge': 'Merge multiple parallel CCA assessment files into one consolidated assessment.',
|
|
63
|
+
'abap-data-model': 'Design and create DDIC objects — domains, data elements, tables, structures.',
|
|
64
|
+
'abap-design': 'Design solution architecture — object list, dependency sequence, pattern selection. After spec-gap, before coding.',
|
|
65
|
+
'abap-dump': 'Diagnose ABAP short dumps (ST22 runtime errors) and fix them.',
|
|
66
|
+
'abap-eml': 'Write and test EML (Entity Manipulation Language) for RAP business objects.',
|
|
67
|
+
'abap-enhance': 'Extend SAP-delivered STANDARD objects via BAdI / enhancement spots / exits. ONLY for standard objects — never for editing your own custom Z/Y code.',
|
|
68
|
+
'abap-estimate': 'Effort estimation for ABAP tickets — component breakdown, hour ranges, risk.',
|
|
69
|
+
'abap-explain': 'Understand an existing object — plain-language explanation (default) or a `technical` developer orientation (deps, tables, risks, modernization notes).',
|
|
70
|
+
'abap-fiori-build': 'Build a freestyle SAPUI5 app on the laptop from already-published OData services.',
|
|
71
|
+
'abap-generate': 'Create NEW ABAP code/objects from a requirement (greenfield).',
|
|
72
|
+
'abap-handover': 'Documentation for an object (`object` scope) or a whole package (`package` scope) — purpose, interfaces, deps, catalog, architecture, config.',
|
|
73
|
+
'abap-impact': 'Analyze the impact of changing an object before touching it — where-used, dependents, risk.',
|
|
74
|
+
'abap-incident': 'End-to-end incident pipeline — from short dump to fix deployed and documented.',
|
|
75
|
+
'abap-migrate': 'ECC→S/4HANA migration assessment for custom objects — API analysis, successor lookup, per-object effort.',
|
|
76
|
+
'abap-modernize': 'Transform a CLASSIC program into a modern architecture (e.g. report → Fiori Elements / RAP) for the release level.',
|
|
77
|
+
'abap-plan': 'Plan and run a multi-session SAP project — phase envelope carries state across sessions, one phase per session.',
|
|
78
|
+
'abap-preflight': 'Pre-release checklist for a change set — dependencies, tests, rollback, readiness.',
|
|
79
|
+
'abap-rap': 'Scaffold a complete RAP stack from scratch — table, CDS interface+projection, BDEF, behavior class, service definition+binding.',
|
|
80
|
+
'abap-refactor': 'Modify / improve / add a feature to EXISTING custom ABAP code in place — restructure, clean up, or change a Z/Y program/class (incl. small feature changes like adding output coloring).',
|
|
81
|
+
'abap-review': 'Read-only code review across lenses: `quality` (default, 7 criteria), `performance` (N+1/SELECT*/indexes), `cloud` (clean-core / ABAP Cloud readiness).',
|
|
82
|
+
'abap-segw': 'Implement SEGW OData service methods (MPC/DPC) for an existing gateway project.',
|
|
83
|
+
'abap-spec-gap': "Find what's missing before coding — business questions, technical gaps, edge cases, test scenarios.",
|
|
84
|
+
'abap-test': 'Generate ABAP Unit test classes for existing code.',
|
|
85
|
+
'abap-transport': 'Transport management — create requests, validate object lists, release transports.',
|
|
86
|
+
'abap-transport-analysis': 'Pre-release transport analysis — list objects, ATC each, cross-dependency + conflict checks.',
|
|
87
|
+
'abap-upgrade-fix': 'Interactive upgrade remediation — fix ATC findings object-by-object with successor-API lookup and approval.',
|
|
88
|
+
'abap-upgrade-merge': 'Merge multiple parallel upgrade-fix progress files into one consolidated progress.',
|
|
89
|
+
'abap-upgrade-scan': 'Scan custom code for S/4HANA upgrade findings — ATC readiness variant, categorize, remediation plan.',
|
|
90
|
+
'abap-upgrade-verify': 'Verify upgrade remediation — re-run ATC, compare against baseline, flag regressions.',
|
|
91
|
+
};
|
|
92
|
+
export const SKILL_LIST = Object.keys(SKILL_DESCRIPTIONS);
|
|
93
|
+
// Screenshot-aware routing (2026-09-23) — appended ONLY when images are sent,
|
|
94
|
+
// so text-only routing keeps the exact pre-feature instructions.
|
|
95
|
+
export const SCREENSHOT_NOTE = `
|
|
96
|
+
|
|
97
|
+
A screenshot is attached. Read it (error text, transaction code such as ST22/ATC/SE80, object names) and classify on the prompt AND the screenshot together — a vague prompt ("can you see this?") with an ST22 dump is abap-dump. Also add "screenshot": a one-line reading of it (e.g. "ST22 dump: CX_SY_ZERODIVIDE in ZCL_X, method CALC").`;
|
|
98
|
+
export const ROUTER_SYSTEM = `You classify user prompts into ABAP-development skills. Given a prompt and context, return JSON: {"skill": "<name>", "confidence": 0-1, "alternates": ["<name>"], "reasoning": "..."}.
|
|
99
|
+
|
|
100
|
+
Skills (name — what it is for):
|
|
101
|
+
${SKILL_LIST.map((n) => `- ${n} — ${SKILL_DESCRIPTIONS[n]}`).join('\n')}
|
|
102
|
+
|
|
103
|
+
Routing notes:
|
|
104
|
+
- Editing, changing, or adding a feature to an EXISTING custom (Z*/Y*) object → abap-refactor (in-place change) or abap-generate (new objects). abap-enhance is ONLY for SAP-delivered STANDARD objects — never for custom code.
|
|
105
|
+
- "Make a classic report modern / turn it into Fiori" → abap-modernize. "Build a full RAP stack from scratch" → abap-rap.
|
|
106
|
+
- CAP / cds / Node.js / a side-by-side BTP app or extension → abap-cap. On-stack RAP inside the ABAP system → abap-rap. A UI on an already-published OData service → abap-fiori-build.
|
|
107
|
+
- Building an app FROM a spec or requirements document (Word / PDF / Excel, 'build the app in this spec') → abap-app. A direct request without a spec document keeps its own skill.
|
|
108
|
+
- Quick cold-read / orientation of an object → abap-explain (technical audience).
|
|
109
|
+
- Performance issues / N+1 / SELECT * → abap-review (performance lens). Clean-core / ABAP Cloud readiness of one object → abap-review (cloud lens); estate-wide clean-core → abap-cca.
|
|
110
|
+
- Developer documentation of an object → abap-handover (object scope).
|
|
111
|
+
|
|
112
|
+
Pick the single best skill. Confidence is 0.75+ if the intent clearly matches one skill, 0.6-0.75 if two skills are plausible, <0.6 if none are clearly right.`;
|
|
113
|
+
export const ROUTER_PROMPT = {
|
|
114
|
+
schema_version: ROUTER_SCHEMA_VERSION,
|
|
115
|
+
revision: ROUTER_REVISION,
|
|
116
|
+
system: ROUTER_SYSTEM,
|
|
117
|
+
screenshot_note: SCREENSHOT_NOTE,
|
|
118
|
+
};
|
|
119
|
+
/**
|
|
120
|
+
* A router entry read from outside (the signed manifest), or null when it is
|
|
121
|
+
* not one this code can use — unknown schema version, missing revision,
|
|
122
|
+
* missing or empty text.
|
|
123
|
+
* Callers then keep ROUTER_PROMPT. Never throws.
|
|
124
|
+
*/
|
|
125
|
+
export function asRouterPrompt(raw) {
|
|
126
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
127
|
+
return null;
|
|
128
|
+
const r = raw;
|
|
129
|
+
if (r.schema_version !== ROUTER_SCHEMA_VERSION)
|
|
130
|
+
return null;
|
|
131
|
+
if (typeof r.revision !== 'number' || !Number.isInteger(r.revision) || r.revision < 0)
|
|
132
|
+
return null;
|
|
133
|
+
if (typeof r.system !== 'string' || r.system.trim() === '')
|
|
134
|
+
return null;
|
|
135
|
+
if (typeof r.screenshot_note !== 'string')
|
|
136
|
+
return null;
|
|
137
|
+
return { schema_version: ROUTER_SCHEMA_VERSION, revision: r.revision, system: r.system, screenshot_note: r.screenshot_note };
|
|
138
|
+
}
|
|
139
|
+
const tok = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
140
|
+
// Live fix 2026-10-02: some prompts made the router answer in prose instead of
|
|
141
|
+
// JSON (e.g. a read-only question ending "Answer in a short list." — the router
|
|
142
|
+
// followed the user's format request). classifyWithUsage threw 'Classifier
|
|
143
|
+
// produced non-JSON output' and /v1/classify returned HTTP 500, which the CLI
|
|
144
|
+
// one-shot surfaced as "unhandled error". Three layers now:
|
|
145
|
+
// 1. Structured outputs (output_config.format, json_schema) ask the API for
|
|
146
|
+
// JSON. Chosen over an assistant prefill of "{" — the router role is
|
|
147
|
+
// admin-tunable and prefill is rejected (400) on every 4.6+ model — and
|
|
148
|
+
// over forced tool use — tool_choice "tool" is rejected on Opus 5.5 /
|
|
149
|
+
// Sonnet 5.5 / Fable 5.1. Structured outputs run on the default router
|
|
150
|
+
// (claude-haiku-4-5) and the current 5.x models. SYSTEM is unchanged, so
|
|
151
|
+
// routing for normal prompts is the same.
|
|
152
|
+
// 2. A strict parse; an unparseable answer is retried ONCE.
|
|
153
|
+
// 3. Still unparseable → ROUTER_UNPARSEABLE_RESULT (confidence 0 ⇒ the CLI's
|
|
154
|
+
// low-confidence path: picker in the REPL, top-1 + notice in one-shot),
|
|
155
|
+
// flagged `unparseable` so the route does not cache it. Never a throw.
|
|
156
|
+
// Upstream API errors (network / 4xx / 5xx from the provider) still throw; the
|
|
157
|
+
// route turns them into a JSON 502 and the CLI degrades to local rules.
|
|
158
|
+
/** abap-explain: read-only and generic — the safest default when the router gave no answer. */
|
|
159
|
+
export const ROUTER_UNPARSEABLE_RESULT = Object.freeze({
|
|
160
|
+
skill: 'abap-explain',
|
|
161
|
+
confidence: 0,
|
|
162
|
+
alternates: [],
|
|
163
|
+
reasoning: 'router_unparseable',
|
|
164
|
+
});
|
|
165
|
+
/** JSON schema for the router answer — the same fields SYSTEM asks for. */
|
|
166
|
+
export function resultSchema(withScreenshot) {
|
|
167
|
+
return {
|
|
168
|
+
type: 'object',
|
|
169
|
+
properties: {
|
|
170
|
+
skill: { type: 'string' },
|
|
171
|
+
confidence: { type: 'number' },
|
|
172
|
+
alternates: { type: 'array', items: { type: 'string' } },
|
|
173
|
+
reasoning: { type: 'string' },
|
|
174
|
+
// Only offered when a screenshot was sent (SCREENSHOT_NOTE asks for it).
|
|
175
|
+
...(withScreenshot ? { screenshot: { type: 'string' } } : {}),
|
|
176
|
+
},
|
|
177
|
+
required: ['skill', 'confidence', 'alternates', 'reasoning'],
|
|
178
|
+
additionalProperties: false,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
/** The router's text → a RouterResult, or null when it is not one. Never throws. */
|
|
182
|
+
export function parseRouterText(text) {
|
|
183
|
+
const candidates = [text.trim()];
|
|
184
|
+
const m = text.match(/\{[\s\S]*\}/);
|
|
185
|
+
if (m && m[0] !== candidates[0])
|
|
186
|
+
candidates.push(m[0]);
|
|
187
|
+
for (const c of candidates) {
|
|
188
|
+
let v;
|
|
189
|
+
try {
|
|
190
|
+
v = JSON.parse(c);
|
|
191
|
+
}
|
|
192
|
+
catch {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
196
|
+
continue;
|
|
197
|
+
const r = v;
|
|
198
|
+
if (typeof r.skill !== 'string' || r.skill.trim() === '')
|
|
199
|
+
continue;
|
|
200
|
+
return {
|
|
201
|
+
...r,
|
|
202
|
+
confidence: typeof r.confidence === 'number' && Number.isFinite(r.confidence) ? r.confidence : 0,
|
|
203
|
+
alternates: Array.isArray(r.alternates) ? r.alternates.filter((a) => typeof a === 'string') : [],
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
return null;
|
|
207
|
+
}
|
|
208
|
+
/** The user turn the router sees: the prompt plus the routing context. */
|
|
209
|
+
export function routerUserMessage(prompt, context) {
|
|
210
|
+
return `Prompt: ${prompt}\n\nContext: sap_system=${context.sap_system ?? 'unknown'}, last_skill=${context.last_skill ?? 'none'}`;
|
|
211
|
+
}
|
|
212
|
+
/** The router request body (Messages API), exactly as the proxy has sent it since 2026-10-02. */
|
|
213
|
+
export function buildRouterRequest(model, prompt, context, images = [], routerPrompt = ROUTER_PROMPT) {
|
|
214
|
+
const userMsg = routerUserMessage(prompt, context);
|
|
215
|
+
return {
|
|
216
|
+
model,
|
|
217
|
+
// Root cause found live 2026-10-02 (v54): the router model (claude-sonnet-5)
|
|
218
|
+
// thinks by default and, on harder prompts, spent all 300 tokens on a
|
|
219
|
+
// thinking block — stop_reason max_tokens, no text, unparseable. Routing is
|
|
220
|
+
// a quick pick: thinking off, and headroom for the JSON answer.
|
|
221
|
+
max_tokens: 600,
|
|
222
|
+
thinking: { type: 'disabled' },
|
|
223
|
+
system: images.length === 0 ? routerPrompt.system : routerPrompt.system + routerPrompt.screenshot_note,
|
|
224
|
+
// 2026-10-02: JSON via structured outputs (see the note above).
|
|
225
|
+
output_config: { format: { type: 'json_schema', schema: resultSchema(images.length > 0) } },
|
|
226
|
+
// Screenshot-aware routing (2026-09-23): images ride ahead of the text in
|
|
227
|
+
// the same message; with none, the plain string is sent as before.
|
|
228
|
+
messages: [{
|
|
229
|
+
role: 'user',
|
|
230
|
+
content: images.length === 0
|
|
231
|
+
? userMsg
|
|
232
|
+
: [
|
|
233
|
+
...images.map((im) => ({
|
|
234
|
+
type: 'image',
|
|
235
|
+
source: { type: 'base64', media_type: im.media_type, data: im.data },
|
|
236
|
+
})),
|
|
237
|
+
{ type: 'text', text: userMsg },
|
|
238
|
+
],
|
|
239
|
+
}],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
/**
|
|
243
|
+
* Send the router request: the self-heal, parse, one retry. `create` is the
|
|
244
|
+
* caller's Messages API call (the proxy's org-key client, or the CLI's BYOK
|
|
245
|
+
* client) — injected so this file stays import-free. Provider errors other
|
|
246
|
+
* than the self-heal case are rethrown for the caller to map.
|
|
247
|
+
*/
|
|
248
|
+
export async function runRouterCalls(create, request, opts = {}) {
|
|
249
|
+
const now = opts.now ?? Date.now;
|
|
250
|
+
// 2026-10-02 (self-healing): the router role is admin-tunable, and not every
|
|
251
|
+
// model accepts structured outputs (e.g. Sonnet 4.6 / Opus 4.6–4.7 are not on
|
|
252
|
+
// the supported list). Sending output_config to one of those is a 400 on
|
|
253
|
+
// EVERY classify — all customers would drop to the CLI's local rules. So a
|
|
254
|
+
// 400 while output_config is sent re-sends the same call ONCE without it;
|
|
255
|
+
// the parse / retry / fallback below then works as before (SYSTEM still
|
|
256
|
+
// asks for JSON). A 400 without output_config, and any non-400, surface.
|
|
257
|
+
let req = request;
|
|
258
|
+
const send = async () => {
|
|
259
|
+
try {
|
|
260
|
+
return await create(req);
|
|
261
|
+
}
|
|
262
|
+
catch (err) {
|
|
263
|
+
if ('output_config' in req && err?.status === 400) {
|
|
264
|
+
opts.warn?.(`[classify] router model ${request.model} rejected output_config (400) — retrying without structured outputs:`, err instanceof Error ? err.message : String(err));
|
|
265
|
+
const { output_config: _dropped, ...rest } = request;
|
|
266
|
+
req = rest;
|
|
267
|
+
return await create(req);
|
|
268
|
+
}
|
|
269
|
+
throw err;
|
|
270
|
+
}
|
|
271
|
+
};
|
|
272
|
+
const calls = [];
|
|
273
|
+
let result = null;
|
|
274
|
+
// At most two parsed answers: the first, and ONE retry when it is unparseable.
|
|
275
|
+
for (let attempt = 0; attempt < 2 && result === null; attempt++) {
|
|
276
|
+
const startedAt = now();
|
|
277
|
+
const res = await send();
|
|
278
|
+
const u = res?.usage ?? {};
|
|
279
|
+
const call = {
|
|
280
|
+
usage: {
|
|
281
|
+
input: tok(u.input_tokens),
|
|
282
|
+
output: tok(u.output_tokens),
|
|
283
|
+
cacheRead: tok(u.cache_read_input_tokens),
|
|
284
|
+
cacheCreate: tok(u.cache_creation_input_tokens),
|
|
285
|
+
},
|
|
286
|
+
latencyMs: now() - startedAt,
|
|
287
|
+
};
|
|
288
|
+
calls.push(call);
|
|
289
|
+
opts.onCall?.(call);
|
|
290
|
+
const content = Array.isArray(res?.content) ? res.content : [];
|
|
291
|
+
const text = content
|
|
292
|
+
.filter((b) => b.type === 'text')
|
|
293
|
+
.map((b) => b.text)
|
|
294
|
+
.join('\n');
|
|
295
|
+
result = parseRouterText(text);
|
|
296
|
+
}
|
|
297
|
+
return { result, calls };
|
|
298
|
+
}
|
|
299
|
+
/** Token counts summed over every call made. */
|
|
300
|
+
export function sumRouterUsage(calls) {
|
|
301
|
+
return calls.reduce((s, c) => ({
|
|
302
|
+
input: s.input + c.usage.input,
|
|
303
|
+
output: s.output + c.usage.output,
|
|
304
|
+
cacheRead: s.cacheRead + c.usage.cacheRead,
|
|
305
|
+
cacheCreate: s.cacheCreate + c.usage.cacheCreate,
|
|
306
|
+
}), { input: 0, output: 0, cacheRead: 0, cacheCreate: 0 });
|
|
307
|
+
}
|