@cspeach/cli 1.1.2 → 1.1.4
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 +31 -4
- package/dist/agent/session-context.js +24 -0
- package/dist/agent/tool-dispatch.js +17 -1
- package/dist/cli-args.js +24 -0
- package/dist/cli.js +33 -4
- package/dist/commands/config-set.js +155 -4
- package/dist/commands/cost.js +56 -4
- package/dist/commands/export-audit.js +4 -1
- package/dist/commands/plan-chain.js +8 -1
- package/dist/config/loader.js +44 -0
- package/dist/cost/cost-log.js +6 -1
- package/dist/cost/credits-wire.js +101 -0
- package/dist/cost/credits.js +199 -0
- package/dist/cost/display-mode.js +29 -0
- package/dist/cost/pricing.js +4 -0
- package/dist/doctor/checks/_standalone-skip.js +37 -0
- package/dist/doctor/checks/cert.js +5 -0
- package/dist/doctor/checks/credits.js +48 -0
- package/dist/doctor/checks/forge-rules.js +6 -0
- package/dist/doctor/checks/sap.js +5 -0
- package/dist/doctor/checks/standards.js +27 -0
- package/dist/doctor/checks/system-roles.js +5 -0
- package/dist/doctor/checks/zcspeach.js +5 -0
- package/dist/doctor/run.js +4 -0
- package/dist/one-shot.js +43 -11
- package/dist/renderer/status-footer.js +13 -3
- package/dist/repl/post-turn-status.js +5 -0
- package/dist/repl/session-spend-line.js +20 -0
- package/dist/repl.js +48 -9
- package/dist/sap/offline-adt-client.js +58 -0
- package/dist/sap/standalone-onboarding.js +118 -0
- package/dist/sap/standalone-profile.js +237 -0
- package/dist/session/audit-export.js +23 -4
- package/dist/standards/standards-file.js +107 -0
- package/dist/standards/standards-init.js +194 -0
- package/dist/tools/capability/tool.js +2 -0
- package/dist/tools/index.js +25 -0
- package/dist/tools/sap-read.js +18 -0
- package/dist/tools/sap-write.js +14 -0
- package/dist/tools/snapshot.js +3 -0
- package/dist/tools/transport.js +4 -0
- package/dist/ui/header.js +3 -1
- package/dist/ui/sap-state-store.js +4 -0
- package/dist/ui/status-line.js +20 -1
- package/package.json +109 -109
package/dist/config/loader.js
CHANGED
|
@@ -3,6 +3,7 @@ import { isDeepStrictEqual } from 'node:util';
|
|
|
3
3
|
import toml from '@iarna/toml';
|
|
4
4
|
import { configFile, cspeachRoot } from './paths.js';
|
|
5
5
|
import { DEFAULT_SESSION_MODEL } from './model-defaults.js';
|
|
6
|
+
import { sanitiseStandaloneProfile } from '../sap/standalone-profile.js';
|
|
6
7
|
/**
|
|
7
8
|
* Whitelist guard for WriteMode — rejects typos / stale values from disk so
|
|
8
9
|
* downstream code that trusts the WriteMode type doesn't see garbage.
|
|
@@ -141,6 +142,24 @@ export async function loadConfig() {
|
|
|
141
142
|
// resolveRenderSmoke reads as the default ON (fail-safe: a config typo can
|
|
142
143
|
// never silently disable the smoke).
|
|
143
144
|
render_smoke: typeof parsed.render_smoke === 'boolean' ? parsed.render_smoke : undefined,
|
|
145
|
+
// no_sap: same absent-preserving coercion as local_build (the key is
|
|
146
|
+
// dropped below when undefined, so it never serialises as
|
|
147
|
+
// `no_sap = undefined`). A present-but-invalid value collapses to
|
|
148
|
+
// `false` — fail-safe: a config typo can never silently disconnect a
|
|
149
|
+
// user from the SAP system they configured.
|
|
150
|
+
no_sap: parsed.no_sap === true
|
|
151
|
+
? true
|
|
152
|
+
: parsed.no_sap === undefined
|
|
153
|
+
? undefined
|
|
154
|
+
: false,
|
|
155
|
+
// standalone: whitelist-coerced profile table. An unrecognised platform
|
|
156
|
+
// drops the whole table (undefined → key deleted below).
|
|
157
|
+
standalone: sanitiseStandaloneProfile(parsed.standalone),
|
|
158
|
+
// show_cogs: plain boolean, DEFAULT off (absent). Preserve only a genuine
|
|
159
|
+
// boolean; a typo/wrong-type collapses to undefined, which reads as OFF —
|
|
160
|
+
// fail-safe in the customer's favour: a config typo can never leak our
|
|
161
|
+
// USD COGS onto a managed customer's screen.
|
|
162
|
+
show_cogs: typeof parsed.show_cogs === 'boolean' ? parsed.show_cogs : undefined,
|
|
144
163
|
write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
|
|
145
164
|
// plan_mode: whitelist coercion — anything but the two valid literals
|
|
146
165
|
// (typos, wrong types) collapses to undefined, which callers treat as
|
|
@@ -210,6 +229,19 @@ export async function loadConfig() {
|
|
|
210
229
|
// callers see a genuine "absent" shape (resolveRenderSmoke treats it as ON).
|
|
211
230
|
if (merged.render_smoke === undefined)
|
|
212
231
|
delete merged.render_smoke;
|
|
232
|
+
// Same for `no_sap` and `standalone`: absent-or-invalid must not serialise
|
|
233
|
+
// as `undefined` — drop the keys so `config show` stays clean and callers
|
|
234
|
+
// see a genuine "absent" shape (resolveStandalone treats missing as off,
|
|
235
|
+
// and an absent profile triggers the standalone wizard).
|
|
236
|
+
if (merged.no_sap === undefined)
|
|
237
|
+
delete merged.no_sap;
|
|
238
|
+
if (merged.standalone === undefined)
|
|
239
|
+
delete merged.standalone;
|
|
240
|
+
// Same for `show_cogs`: absent-or-invalid must not serialise as
|
|
241
|
+
// `show_cogs = undefined` — drop the key so `config show` stays clean and
|
|
242
|
+
// callers see a genuine "absent" shape (treated as OFF → credits).
|
|
243
|
+
if (merged.show_cogs === undefined)
|
|
244
|
+
delete merged.show_cogs;
|
|
213
245
|
// Same for `plan_mode`: absent-or-invalid must not serialise as
|
|
214
246
|
// `plan_mode = undefined` — drop the key so `config show` stays clean and
|
|
215
247
|
// callers see a genuine "absent" shape (treated as 'step').
|
|
@@ -305,6 +337,18 @@ export function resolveLocalBuild(cfg) {
|
|
|
305
337
|
export function resolveRenderSmoke(cfg) {
|
|
306
338
|
return cfg.render_smoke ?? true;
|
|
307
339
|
}
|
|
340
|
+
/**
|
|
341
|
+
* Resolve the effective standalone ("no SAP system") mode.
|
|
342
|
+
*
|
|
343
|
+
* effective = --no-sap flag OR config no_sap (default false)
|
|
344
|
+
*
|
|
345
|
+
* The flag can only turn standalone ON for a single run — there is no
|
|
346
|
+
* "--sap" counterpart — so the resolution is an OR, not a precedence chain.
|
|
347
|
+
* `no_sap` is already coerced to `true | false | undefined` by loadConfig.
|
|
348
|
+
*/
|
|
349
|
+
export function resolveStandalone(cfg, flagNoSap = false) {
|
|
350
|
+
return flagNoSap === true || cfg.no_sap === true;
|
|
351
|
+
}
|
|
308
352
|
export async function saveConfig(config) {
|
|
309
353
|
await fs.mkdir(cspeachRoot(), { recursive: true });
|
|
310
354
|
// The set of top-level keys the FILE carried (empty when it was absent).
|
package/dist/cost/cost-log.js
CHANGED
|
@@ -27,7 +27,7 @@ export function costLogPath(sessionId) {
|
|
|
27
27
|
* `appendCostLine` or accumulate in memory for the `/cost` command.
|
|
28
28
|
*/
|
|
29
29
|
export function buildEntry(opts) {
|
|
30
|
-
|
|
30
|
+
const entry = {
|
|
31
31
|
ts: new Date().toISOString(),
|
|
32
32
|
turn: opts.turn,
|
|
33
33
|
model: opts.model,
|
|
@@ -35,6 +35,11 @@ export function buildEntry(opts) {
|
|
|
35
35
|
cost: computeCost(opts.model, opts.tokens),
|
|
36
36
|
duration_ms: opts.duration_ms,
|
|
37
37
|
};
|
|
38
|
+
if (opts.credits) {
|
|
39
|
+
entry.credits_charged = opts.credits.charged;
|
|
40
|
+
entry.balance_credits = opts.credits.balance;
|
|
41
|
+
}
|
|
42
|
+
return entry;
|
|
38
43
|
}
|
|
39
44
|
/**
|
|
40
45
|
* Append a single line to the session's cost log. Best-effort:
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one place the credits meter is driven (2026-09-16).
|
|
3
|
+
*
|
|
4
|
+
* Two touch points, both best-effort and both no-ops outside credits mode:
|
|
5
|
+
*
|
|
6
|
+
* initCredits() — REPL / one-shot startup. ONE balance call,
|
|
7
|
+
* capped at BALANCE_TIMEOUT_MS. Resolves the
|
|
8
|
+
* display mode and seeds the tracker + store.
|
|
9
|
+
* refreshCreditsAfterTurn() — post-turn. One more capped call; the proxy
|
|
10
|
+
* commits a turn's charge BEFORE the SSE stream
|
|
11
|
+
* closes, so this sample already includes the
|
|
12
|
+
* turn that just ended.
|
|
13
|
+
*
|
|
14
|
+
* The post-turn refresh is awaited inside the agent loop's post-turn block
|
|
15
|
+
* rather than fired and forgotten, because two consumers need the fresh
|
|
16
|
+
* number synchronously: the JSONL cost line written moments later, and the
|
|
17
|
+
* post-turn status footer the REPL prints once runTurn returns. Awaiting a
|
|
18
|
+
* 2.5 s-capped call at the end of a multi-second turn is cheap; showing the
|
|
19
|
+
* previous turn's figure would be wrong.
|
|
20
|
+
*
|
|
21
|
+
* The cfg + bearer getter are captured at init so every later call site stays
|
|
22
|
+
* a one-liner and no module has to re-derive the display mode for itself.
|
|
23
|
+
*/
|
|
24
|
+
import { fetchBalance, globalCredits, setCostDisplayMode, creditsActive, } from './credits.js';
|
|
25
|
+
import { costDisplayMode } from './display-mode.js';
|
|
26
|
+
import { globalStore } from '../ui/sap-state-store.js';
|
|
27
|
+
let wiredCfg = null;
|
|
28
|
+
let wiredBearer = null;
|
|
29
|
+
/** Mirror the tracker's state onto the Ink store so the chrome re-renders. */
|
|
30
|
+
function publish() {
|
|
31
|
+
const s = globalCredits.snapshot();
|
|
32
|
+
globalStore.update({
|
|
33
|
+
creditsMode: creditsActive(),
|
|
34
|
+
creditsSessionTotal: s.sessionTotal,
|
|
35
|
+
creditsBalance: s.balance,
|
|
36
|
+
creditsUnitLabel: s.unitLabel,
|
|
37
|
+
creditsPending: s.pending,
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* Startup: resolve the display mode and seed the opening balance.
|
|
42
|
+
* Never throws; on any failure the process stays in USD mode, which is the
|
|
43
|
+
* pre-credits behaviour.
|
|
44
|
+
*/
|
|
45
|
+
export async function initCredits(cfg, getBearer) {
|
|
46
|
+
try {
|
|
47
|
+
// Non-managed modes can't be metered by us — skip the round-trip entirely.
|
|
48
|
+
if (cfg.llm?.mode !== 'managed') {
|
|
49
|
+
setCostDisplayMode('usd');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const info = await fetchBalance(cfg, getBearer);
|
|
53
|
+
const mode = costDisplayMode(cfg, info);
|
|
54
|
+
setCostDisplayMode(mode);
|
|
55
|
+
if (mode !== 'credits')
|
|
56
|
+
return;
|
|
57
|
+
wiredCfg = cfg;
|
|
58
|
+
wiredBearer = getBearer;
|
|
59
|
+
globalCredits.start(info);
|
|
60
|
+
publish();
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
// Startup must never be blocked by the meter.
|
|
64
|
+
setCostDisplayMode('usd');
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Post-turn: sample the balance again and fold the delta into the session
|
|
69
|
+
* total. Returns null (and makes no request) outside credits mode.
|
|
70
|
+
*/
|
|
71
|
+
export async function refreshCreditsAfterTurn() {
|
|
72
|
+
if (!creditsActive() || !wiredCfg || !wiredBearer)
|
|
73
|
+
return null;
|
|
74
|
+
try {
|
|
75
|
+
const info = await fetchBalance(wiredCfg, wiredBearer);
|
|
76
|
+
const r = globalCredits.afterTurn(info);
|
|
77
|
+
publish();
|
|
78
|
+
return r;
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
// Treat an unexpected throw exactly like a failed fetch: unknown, not zero.
|
|
82
|
+
const r = globalCredits.afterTurn(null);
|
|
83
|
+
publish();
|
|
84
|
+
return r;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* The credits payload for a cost-log line, or undefined in USD mode so the
|
|
89
|
+
* emitted JSONL keeps its pre-credits shape byte for byte.
|
|
90
|
+
*/
|
|
91
|
+
export function creditsForCostLog() {
|
|
92
|
+
if (!creditsActive())
|
|
93
|
+
return undefined;
|
|
94
|
+
const s = globalCredits.snapshot();
|
|
95
|
+
return { charged: s.chargedThisTurn, balance: s.balance };
|
|
96
|
+
}
|
|
97
|
+
/** Test seam — drop the captured cfg/bearer between cases. */
|
|
98
|
+
export function resetCreditsWire() {
|
|
99
|
+
wiredCfg = null;
|
|
100
|
+
wiredBearer = null;
|
|
101
|
+
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Customer-facing spend — credits, not dollars (2026-09-16).
|
|
3
|
+
*
|
|
4
|
+
* Every figure in `cost/pricing.ts` is OUR Anthropic COGS. A managed
|
|
5
|
+
* customer is billed in CREDITS at a multiplier we do not disclose, so
|
|
6
|
+
* showing them a `$` number is both wrong (it is not what they pay) and a
|
|
7
|
+
* business leak (it reveals our margin). This module is the customer-facing
|
|
8
|
+
* half of the split:
|
|
9
|
+
*
|
|
10
|
+
* cost/pricing.ts → USD, our cost, telemetry + BYOK/local/ai-hub display
|
|
11
|
+
* cost/credits.ts → credits, what the customer is actually charged
|
|
12
|
+
*
|
|
13
|
+
* The proxy is the only source of truth for credits. It commits a turn's
|
|
14
|
+
* charge BEFORE the SSE stream closes, so a balance fetched after the turn
|
|
15
|
+
* ends already reflects that turn — which is why the per-turn charge here is
|
|
16
|
+
* a BALANCE DELTA, not a locally computed number. We never price a turn in
|
|
17
|
+
* credits ourselves; we only observe what the ledger did.
|
|
18
|
+
*
|
|
19
|
+
* Everything in here is best-effort. A balance fetch that fails, times out,
|
|
20
|
+
* or returns garbage must never crash or block a turn — it degrades to
|
|
21
|
+
* "unknown", which the UI renders as `credits: updating…` (never a dollar
|
|
22
|
+
* fallback; see display-mode.ts).
|
|
23
|
+
*/
|
|
24
|
+
/** Hard cap on the balance round-trip — never block a turn on it. */
|
|
25
|
+
export const BALANCE_TIMEOUT_MS = 2_500;
|
|
26
|
+
const DEFAULT_UNIT_LABEL = 'credits';
|
|
27
|
+
function num(v) {
|
|
28
|
+
return typeof v === 'number' && Number.isFinite(v) ? v : null;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Fetch the customer's credit balance from the proxy.
|
|
32
|
+
*
|
|
33
|
+
* Uses the same bearer key the CLI already sends on every other proxy call.
|
|
34
|
+
* NEVER throws and NEVER hangs past BALANCE_TIMEOUT_MS — returns null on any
|
|
35
|
+
* failure (transport error, non-2xx, timeout, unparseable body, no key).
|
|
36
|
+
*/
|
|
37
|
+
export async function fetchBalance(cfg, getBearer) {
|
|
38
|
+
const controller = new AbortController();
|
|
39
|
+
const timer = setTimeout(() => controller.abort(), BALANCE_TIMEOUT_MS);
|
|
40
|
+
try {
|
|
41
|
+
const key = await getBearer();
|
|
42
|
+
if (!key)
|
|
43
|
+
return null;
|
|
44
|
+
const url = `${cfg.proxy_url.replace(/\/$/, '')}/v1/me/balance`;
|
|
45
|
+
const r = await fetch(url, {
|
|
46
|
+
headers: { Authorization: `Bearer ${key}` },
|
|
47
|
+
signal: controller.signal,
|
|
48
|
+
});
|
|
49
|
+
if (!r.ok)
|
|
50
|
+
return null;
|
|
51
|
+
const body = await r.json();
|
|
52
|
+
if (typeof body !== 'object' || body === null)
|
|
53
|
+
return null;
|
|
54
|
+
const b = body;
|
|
55
|
+
const info = {
|
|
56
|
+
metered: b.metered === true,
|
|
57
|
+
unit_label: typeof b.unit_label === 'string' && b.unit_label ? b.unit_label : DEFAULT_UNIT_LABEL,
|
|
58
|
+
balance_credits: num(b.balance_credits),
|
|
59
|
+
subscription_credits: num(b.subscription_credits),
|
|
60
|
+
purchased_credits: num(b.purchased_credits),
|
|
61
|
+
};
|
|
62
|
+
if (b.unprovisioned === true)
|
|
63
|
+
info.unprovisioned = true;
|
|
64
|
+
return info;
|
|
65
|
+
}
|
|
66
|
+
catch {
|
|
67
|
+
// Timeout, offline, DNS, malformed JSON — all the same to the caller.
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
finally {
|
|
71
|
+
clearTimeout(timer);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* In-memory, session-scoped credit meter.
|
|
76
|
+
*
|
|
77
|
+
* Honest-arithmetic contract:
|
|
78
|
+
* - A turn's charge is `previousBalance − currentBalance`, clamped at ≥ 0
|
|
79
|
+
* (a mid-session top-up must never subtract from the session total).
|
|
80
|
+
* - When a fetch fails we do NOT invent a charge and we do NOT move the
|
|
81
|
+
* baseline. The turn is reported as `null` (unknown) and the spend it
|
|
82
|
+
* incurred is folded into the next SUCCESSFUL fetch's delta — so the
|
|
83
|
+
* session total stays correct even across missed samples.
|
|
84
|
+
* - With no baseline at all (session opened offline) the first successful
|
|
85
|
+
* fetch only establishes the baseline; it cannot be a delta.
|
|
86
|
+
*/
|
|
87
|
+
export class CreditsTracker {
|
|
88
|
+
baseline = null;
|
|
89
|
+
state = {
|
|
90
|
+
chargedThisTurn: null,
|
|
91
|
+
sessionTotal: 0,
|
|
92
|
+
balance: null,
|
|
93
|
+
unitLabel: DEFAULT_UNIT_LABEL,
|
|
94
|
+
pending: false,
|
|
95
|
+
};
|
|
96
|
+
/** Seed the session's opening balance. */
|
|
97
|
+
start(balance) {
|
|
98
|
+
if (balance)
|
|
99
|
+
this.state.unitLabel = balance.unit_label;
|
|
100
|
+
const opening = balance ? balance.balance_credits : null;
|
|
101
|
+
this.baseline = opening;
|
|
102
|
+
this.state = {
|
|
103
|
+
...this.state,
|
|
104
|
+
chargedThisTurn: null,
|
|
105
|
+
sessionTotal: 0,
|
|
106
|
+
balance: opening,
|
|
107
|
+
pending: false,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
/** Record a post-turn balance sample and return this turn's charge. */
|
|
111
|
+
afterTurn(balance) {
|
|
112
|
+
if (balance)
|
|
113
|
+
this.state.unitLabel = balance.unit_label;
|
|
114
|
+
const current = balance ? balance.balance_credits : null;
|
|
115
|
+
if (current === null) {
|
|
116
|
+
// Unknown — keep the baseline so the next good sample covers both turns.
|
|
117
|
+
this.state = { ...this.state, chargedThisTurn: null, pending: true };
|
|
118
|
+
return { chargedThisTurn: null, sessionTotal: this.state.sessionTotal, balance: this.state.balance };
|
|
119
|
+
}
|
|
120
|
+
if (this.baseline === null) {
|
|
121
|
+
// First balance we have ever seen — establish the baseline only.
|
|
122
|
+
this.baseline = current;
|
|
123
|
+
this.state = { ...this.state, chargedThisTurn: null, balance: current, pending: false };
|
|
124
|
+
return { chargedThisTurn: null, sessionTotal: this.state.sessionTotal, balance: current };
|
|
125
|
+
}
|
|
126
|
+
const charged = Math.max(0, this.baseline - current);
|
|
127
|
+
this.baseline = current;
|
|
128
|
+
const sessionTotal = this.state.sessionTotal + charged;
|
|
129
|
+
this.state = { ...this.state, chargedThisTurn: charged, sessionTotal, balance: current, pending: false };
|
|
130
|
+
return { chargedThisTurn: charged, sessionTotal, balance: current };
|
|
131
|
+
}
|
|
132
|
+
snapshot() {
|
|
133
|
+
return { ...this.state };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Format a credit count for display: thousands-separated, unit-labelled.
|
|
138
|
+
* Credits are whole units to the customer, so fractions round.
|
|
139
|
+
*/
|
|
140
|
+
export function formatCredits(n, unitLabel = DEFAULT_UNIT_LABEL) {
|
|
141
|
+
return `${Math.round(n).toLocaleString('en-US')} ${unitLabel}`;
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* Shown instead of a number when the post-turn balance fetch failed.
|
|
145
|
+
* Deliberately NOT a dollar fallback: a managed customer must never see our
|
|
146
|
+
* COGS, and "updating" is the truth — the charge is committed server-side and
|
|
147
|
+
* will surface on the next successful sample.
|
|
148
|
+
*/
|
|
149
|
+
export const CREDITS_PENDING = 'credits: updating…';
|
|
150
|
+
/**
|
|
151
|
+
* The customer-facing spend line, e.g.
|
|
152
|
+
* `▲ 52 credits this turn · 519 this session · balance 9,481`
|
|
153
|
+
* Degrades gracefully: an unknown turn charge renders CREDITS_PENDING, and a
|
|
154
|
+
* missing balance simply drops that segment.
|
|
155
|
+
*/
|
|
156
|
+
export function formatCreditsLine(s) {
|
|
157
|
+
if (s.chargedThisTurn === null)
|
|
158
|
+
return CREDITS_PENDING;
|
|
159
|
+
const parts = [
|
|
160
|
+
`▲ ${formatCredits(s.chargedThisTurn, s.unitLabel)} this turn`,
|
|
161
|
+
`${Math.round(s.sessionTotal).toLocaleString('en-US')} this session`,
|
|
162
|
+
];
|
|
163
|
+
if (s.balance !== null) {
|
|
164
|
+
parts.push(`balance ${Math.round(s.balance).toLocaleString('en-US')}`);
|
|
165
|
+
}
|
|
166
|
+
return parts.join(' · ');
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Session-scoped singletons.
|
|
170
|
+
*
|
|
171
|
+
* The cost-render sites are scattered across chalk printers, Ink components,
|
|
172
|
+
* slash commands and the markdown audit exporter — threading config + balance
|
|
173
|
+
* through every one of them would be a far larger diff than the behaviour
|
|
174
|
+
* warrants, and would leave each site free to re-derive the mode differently
|
|
175
|
+
* (exactly how a leak happens). Instead the mode is decided ONCE at startup
|
|
176
|
+
* (see costDisplayMode) and published here; every render site asks.
|
|
177
|
+
*
|
|
178
|
+
* Tests inject explicitly via each site's optional parameter, so the singleton
|
|
179
|
+
* is a default, never a hard dependency.
|
|
180
|
+
*/
|
|
181
|
+
export const globalCredits = new CreditsTracker();
|
|
182
|
+
let activeMode = 'usd';
|
|
183
|
+
/** Publish the resolved display mode for the whole process. */
|
|
184
|
+
export function setCostDisplayMode(mode) {
|
|
185
|
+
activeMode = mode;
|
|
186
|
+
}
|
|
187
|
+
/** The resolved display mode. Defaults to 'usd' until startup resolves it —
|
|
188
|
+
* fail-safe for BYOK/local/one-shot paths that never call the setter. */
|
|
189
|
+
export function activeCostDisplayMode() {
|
|
190
|
+
return activeMode;
|
|
191
|
+
}
|
|
192
|
+
/** Convenience predicate for render sites. */
|
|
193
|
+
export function creditsActive() {
|
|
194
|
+
return activeMode === 'credits';
|
|
195
|
+
}
|
|
196
|
+
/** Test seam — reset the process-wide mode between cases. */
|
|
197
|
+
export function resetCostDisplayMode() {
|
|
198
|
+
activeMode = 'usd';
|
|
199
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one rule that decides whether a cost figure renders as credits or as
|
|
3
|
+
* dollars (2026-09-16).
|
|
4
|
+
*
|
|
5
|
+
* `'credits'` — a MANAGED customer on a METERED account. They pay us in
|
|
6
|
+
* credits; the USD number we compute locally is our Anthropic COGS and
|
|
7
|
+
* must never reach their screen.
|
|
8
|
+
*
|
|
9
|
+
* `'usd'` — everything else:
|
|
10
|
+
* - byok / ai-hub / local: the customer holds the provider relationship,
|
|
11
|
+
* so the dollar figure IS their bill. Showing it is the whole point.
|
|
12
|
+
* - managed but unmetered (or the balance fetch failed): we have no
|
|
13
|
+
* credit truth to show. Fall back to the existing dollar rendering so
|
|
14
|
+
* the meter is never blank.
|
|
15
|
+
* - `show_cogs = on`: our own operator escape hatch for reading real COGS
|
|
16
|
+
* on a managed account (margin analysis, pricing work). Never a default.
|
|
17
|
+
*
|
|
18
|
+
* Callers must treat this as the single source of truth — no site may
|
|
19
|
+
* re-derive the mode from `cfg.llm.mode` alone.
|
|
20
|
+
*/
|
|
21
|
+
export function costDisplayMode(cfg, balanceInfo) {
|
|
22
|
+
if (cfg.llm?.mode !== 'managed')
|
|
23
|
+
return 'usd';
|
|
24
|
+
if (balanceInfo?.metered !== true)
|
|
25
|
+
return 'usd';
|
|
26
|
+
if (cfg.show_cogs === true)
|
|
27
|
+
return 'usd';
|
|
28
|
+
return 'credits';
|
|
29
|
+
}
|
package/dist/cost/pricing.js
CHANGED
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
*/
|
|
25
25
|
import { getServerModelConfig } from '../models/server-config.js';
|
|
26
26
|
const PRICING = {
|
|
27
|
+
// Claude 5 family (2026-09-15) — Opus 5 keeps the Opus 4.5+ rate ($5/$25);
|
|
28
|
+
// Sonnet 5 is $2/$10. Cache read = 10% of input, cache write = 1.25x input.
|
|
29
|
+
'claude-opus-5': { inputPer1M: 5.00, outputPer1M: 25.00, cacheReadPer1M: 0.50, cacheCreatePer1M: 6.25 },
|
|
30
|
+
'claude-sonnet-5': { inputPer1M: 2.00, outputPer1M: 10.00, cacheReadPer1M: 0.20, cacheCreatePer1M: 2.50 },
|
|
27
31
|
// Claude Opus 4.5+ — input 5, output 25, cache_read 0.50, cache_write 6.25 (1.25x input).
|
|
28
32
|
// 2026-06-07 correction: 4.5/4.6/4.7 were carried at the LEGACY $15/$75; actual
|
|
29
33
|
// since Opus 4.5 is $5/$25. Only claude-opus-4 (4.0/4.1) genuinely was $15/$75.
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared "standalone mode is ON" result for the doctor's SAP checks.
|
|
3
|
+
*
|
|
4
|
+
* Standalone mode (2026-09-16) made "deliberately no SAP system" a SUPPORTED
|
|
5
|
+
* state, so with `no_sap = true` these checks report a neutral yellow `-` line
|
|
6
|
+
* that does not count toward the failed total.
|
|
7
|
+
*
|
|
8
|
+
* NOTHING else changes. With standalone OFF — including the plain "hasn't run
|
|
9
|
+
* `config add-sap` yet" case — every check keeps its original result, message
|
|
10
|
+
* and exit-code contribution, byte for byte.
|
|
11
|
+
*/
|
|
12
|
+
export const STANDALONE_SKIP_MESSAGE = 'standalone mode — SAP checks skipped';
|
|
13
|
+
/**
|
|
14
|
+
* True ONLY when the user has opted into standalone mode via `no_sap = true`.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately does NOT treat "no aliases configured" as standalone: for a
|
|
17
|
+
* user who simply hasn't added a system yet, a missing SAP connection is still
|
|
18
|
+
* a finding, and the pre-standalone output must be preserved exactly.
|
|
19
|
+
*
|
|
20
|
+
* Reads `cfg.no_sap` directly rather than calling `resolveStandalone` so this
|
|
21
|
+
* helper stays free of a runtime dependency on config/loader — several doctor
|
|
22
|
+
* tests replace that whole module with a mock. (`doctor` is its own process
|
|
23
|
+
* invocation, so the per-run `--no-sap` flag never reaches it anyway.)
|
|
24
|
+
*/
|
|
25
|
+
export function sapChecksSkipped(cfg) {
|
|
26
|
+
return cfg.no_sap === true;
|
|
27
|
+
}
|
|
28
|
+
/** The neutral skip result for a named check. */
|
|
29
|
+
export function standaloneSkip(name) {
|
|
30
|
+
return {
|
|
31
|
+
name,
|
|
32
|
+
ok: false,
|
|
33
|
+
skipped: true,
|
|
34
|
+
message: STANDALONE_SKIP_MESSAGE,
|
|
35
|
+
hint: 'Connect a system any time with: cspeach config add-sap',
|
|
36
|
+
};
|
|
37
|
+
}
|
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import tls from 'node:tls';
|
|
2
2
|
import { loadConfig, resolveSapTls } from '../../config/loader.js';
|
|
3
|
+
import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
|
|
3
4
|
export async function checkCert() {
|
|
4
5
|
const cfg = await loadConfig();
|
|
6
|
+
// Standalone mode ONLY — an opted-in paste-mode user has nothing to probe.
|
|
7
|
+
// Everything below (including the "n/a" no-TLS line) is unchanged.
|
|
8
|
+
if (sapChecksSkipped(cfg))
|
|
9
|
+
return standaloneSkip('cert');
|
|
5
10
|
const systems = Object.entries(cfg.sap).filter(([_, s]) => s.useSsl);
|
|
6
11
|
if (systems.length === 0)
|
|
7
12
|
return { name: 'cert', ok: true, message: 'n/a (no TLS SAP systems configured)' };
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `cspeach doctor` — credits line (2026-09-16).
|
|
3
|
+
*
|
|
4
|
+
* Purely informational: whether this account is billed in credits and, if so,
|
|
5
|
+
* what is left. It NEVER fails the run — a customer with no balance reading
|
|
6
|
+
* still has a perfectly working CLI, and an unreachable balance endpoint is a
|
|
7
|
+
* proxy problem the proxy check already reports.
|
|
8
|
+
*
|
|
9
|
+
* Deliberately dollar-free: doctor output is the first thing a customer pastes
|
|
10
|
+
* into a support thread.
|
|
11
|
+
*/
|
|
12
|
+
import { fetchBalance, formatCredits } from '../../cost/credits.js';
|
|
13
|
+
import { getStore } from '../../auth/api-key.js';
|
|
14
|
+
function defaultProbe(cfg) {
|
|
15
|
+
return () => fetchBalance(cfg, async () => {
|
|
16
|
+
const store = await getStore();
|
|
17
|
+
return (await store.get()) ?? '';
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
export async function checkCredits(cfg, probe = defaultProbe(cfg)) {
|
|
21
|
+
// BYOK / ai-hub / local never touch our ledger — no reason to call the proxy.
|
|
22
|
+
if (cfg.llm?.mode !== 'managed') {
|
|
23
|
+
return { name: 'credits', ok: true, message: 'not metered (BYOK)' };
|
|
24
|
+
}
|
|
25
|
+
const info = await probe().catch(() => null);
|
|
26
|
+
if (!info) {
|
|
27
|
+
return {
|
|
28
|
+
name: 'credits', ok: true, skipped: true, message: 'unavailable',
|
|
29
|
+
hint: 'Could not read your balance — check the proxy check above, then retry.',
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
if (!info.metered) {
|
|
33
|
+
return { name: 'credits', ok: true, message: 'not metered (BYOK)' };
|
|
34
|
+
}
|
|
35
|
+
if (info.balance_credits === null) {
|
|
36
|
+
return {
|
|
37
|
+
name: 'credits', ok: true, skipped: true, message: 'unavailable',
|
|
38
|
+
hint: info.unprovisioned
|
|
39
|
+
? 'Your account is metered but has no credit ledger yet — visit cspeach.dev/portal.'
|
|
40
|
+
: 'The server reported no balance figure.',
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
return {
|
|
44
|
+
name: 'credits',
|
|
45
|
+
ok: true,
|
|
46
|
+
message: `balance ${formatCredits(info.balance_credits, info.unit_label)}`,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
@@ -18,11 +18,17 @@
|
|
|
18
18
|
import { loadConfig } from '../../config/loader.js';
|
|
19
19
|
import { getConnection } from '../../sap/connection-manager.js';
|
|
20
20
|
import { AdtClient, snapshots } from '@cspeach/sap-client';
|
|
21
|
+
import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
|
|
21
22
|
const PROBE_NAME = 'Z_CSPEACH_DOCTOR_PROBE';
|
|
22
23
|
const PROBE_PACKAGE = '$TMP';
|
|
23
24
|
const PROBE_SOURCE = `REPORT ${PROBE_NAME}.\nWRITE / 'cspeach doctor probe — safe to delete'.`;
|
|
24
25
|
export async function checkForgeRules() {
|
|
25
26
|
const cfg = await loadConfig();
|
|
27
|
+
// Standalone mode ONLY — the Rule 7/10 probe needs a live system, and an
|
|
28
|
+
// opted-in paste-mode user has none. Everything below (including the
|
|
29
|
+
// no-alias skip) is unchanged.
|
|
30
|
+
if (sapChecksSkipped(cfg))
|
|
31
|
+
return standaloneSkip('forge-rules');
|
|
26
32
|
const aliases = Object.keys(cfg.sap);
|
|
27
33
|
if (aliases.length === 0) {
|
|
28
34
|
return {
|
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
2
|
import { loadConfig, resolveSapTls } from '../../config/loader.js';
|
|
3
3
|
import { probeGet } from './_http-probe.js';
|
|
4
|
+
import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
|
|
4
5
|
export async function checkSap() {
|
|
5
6
|
const cfg = await loadConfig();
|
|
7
|
+
// Standalone mode ONLY — an opted-in paste-mode user has nothing to probe.
|
|
8
|
+
// Everything below (including the no-alias failure) is unchanged.
|
|
9
|
+
if (sapChecksSkipped(cfg))
|
|
10
|
+
return standaloneSkip('sap');
|
|
6
11
|
const systems = Object.entries(cfg.sap);
|
|
7
12
|
if (systems.length === 0)
|
|
8
13
|
return { name: 'sap', ok: false, message: 'no SAP systems configured', hint: 'Run: cspeach config add-sap' };
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Doctor check: standards (2026-09-16).
|
|
3
|
+
*
|
|
4
|
+
* Purely informational — surfaces WHICH development-standards document the
|
|
5
|
+
* session context is carrying, so a team that thinks their rules are being
|
|
6
|
+
* followed can confirm it at a glance. Having none is a normal state (CSPeach's
|
|
7
|
+
* built-in conventions apply), so it renders as a neutral `-` line, never a
|
|
8
|
+
* failure.
|
|
9
|
+
*/
|
|
10
|
+
import { resolveStandardsFile, displayStandardsPath } from '../../standards/standards-file.js';
|
|
11
|
+
export async function checkStandards() {
|
|
12
|
+
const cwd = process.cwd();
|
|
13
|
+
const file = await resolveStandardsFile(cwd);
|
|
14
|
+
if (!file) {
|
|
15
|
+
return {
|
|
16
|
+
name: 'standards',
|
|
17
|
+
ok: false,
|
|
18
|
+
skipped: true,
|
|
19
|
+
message: 'none (run: cspeach standards init)',
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
name: 'standards',
|
|
24
|
+
ok: true,
|
|
25
|
+
message: displayStandardsPath(file.path, cwd),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
|
|
1
2
|
/**
|
|
2
3
|
* Doctor check: system-roles (UX Wave 1, Task 1).
|
|
3
4
|
*
|
|
@@ -11,6 +12,10 @@
|
|
|
11
12
|
* never counts toward the doctor's failed total. Pure — no I/O beyond cfg.
|
|
12
13
|
*/
|
|
13
14
|
export async function checkSystemRoles(cfg) {
|
|
15
|
+
// Standalone mode ONLY — nothing to put a write-mode ceiling on. Everything
|
|
16
|
+
// below (including the no-alias skip) is unchanged.
|
|
17
|
+
if (sapChecksSkipped(cfg))
|
|
18
|
+
return standaloneSkip('system-roles');
|
|
14
19
|
const entries = Object.entries(cfg.sap ?? {});
|
|
15
20
|
if (entries.length === 0) {
|
|
16
21
|
return {
|
|
@@ -2,9 +2,14 @@ import keytar from 'keytar';
|
|
|
2
2
|
import { readFile } from 'node:fs/promises';
|
|
3
3
|
import { loadConfig, resolveSapTls } from '../../config/loader.js';
|
|
4
4
|
import { probeGet } from './_http-probe.js';
|
|
5
|
+
import { sapChecksSkipped, standaloneSkip } from './_standalone-skip.js';
|
|
5
6
|
const SAP_SERVICE = 'cspeach-sap';
|
|
6
7
|
export async function checkZcspeach() {
|
|
7
8
|
const cfg = await loadConfig();
|
|
9
|
+
// Standalone mode ONLY — an opted-in paste-mode user has nothing to probe.
|
|
10
|
+
// Everything below (including the no-alias failure) is unchanged.
|
|
11
|
+
if (sapChecksSkipped(cfg))
|
|
12
|
+
return standaloneSkip('zcspeach');
|
|
8
13
|
const systems = Object.entries(cfg.sap);
|
|
9
14
|
if (systems.length === 0) {
|
|
10
15
|
return {
|
package/dist/doctor/run.js
CHANGED
|
@@ -10,8 +10,10 @@ import { checkSkill } from './checks/skill.js';
|
|
|
10
10
|
import { checkKeychainFallback } from './checks/keychain-fallback.js';
|
|
11
11
|
import { checkWriteMode } from './checks/write-mode.js';
|
|
12
12
|
import { checkLlmMode } from './checks/llm-mode.js';
|
|
13
|
+
import { checkCredits } from './checks/credits.js';
|
|
13
14
|
import { checkForgeRules } from './checks/forge-rules.js';
|
|
14
15
|
import { checkSystemRoles } from './checks/system-roles.js';
|
|
16
|
+
import { checkStandards } from './checks/standards.js';
|
|
15
17
|
export async function runDoctor() {
|
|
16
18
|
console.log(chalk.cyan.bold('\nCSPeach — doctor\n'));
|
|
17
19
|
const cfg = await loadConfig();
|
|
@@ -19,6 +21,7 @@ export async function runDoctor() {
|
|
|
19
21
|
checks.push(await checkProxy());
|
|
20
22
|
checks.push(await checkAuth());
|
|
21
23
|
checks.push(await checkLlmMode(cfg));
|
|
24
|
+
checks.push(await checkCredits(cfg));
|
|
22
25
|
checks.push(await checkWriteMode(cfg));
|
|
23
26
|
checks.push(await checkSystemRoles(cfg));
|
|
24
27
|
checks.push(await checkKeychain());
|
|
@@ -28,6 +31,7 @@ export async function runDoctor() {
|
|
|
28
31
|
checks.push(await checkSkill());
|
|
29
32
|
checks.push(await checkKeychainFallback());
|
|
30
33
|
checks.push(await checkForgeRules());
|
|
34
|
+
checks.push(await checkStandards());
|
|
31
35
|
let failed = 0;
|
|
32
36
|
for (const c of checks) {
|
|
33
37
|
const icon = c.skipped
|