@cspeach/cli 1.1.3 → 1.1.5
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 +9 -0
- package/dist/agent/tool-dispatch.js +27 -2
- package/dist/commands/config-set.js +16 -0
- 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 +10 -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/doctor/checks/credits.js +48 -0
- package/dist/doctor/run.js +2 -0
- package/dist/one-shot.js +7 -0
- 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 +12 -2
- package/dist/sap/standalone-profile.js +2 -2
- package/dist/session/audit-export.js +23 -4
- 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 +1 -1
package/dist/agent/loop.js
CHANGED
|
@@ -20,6 +20,7 @@ import { turnStatusEmitter } from '../ui/turn-status-emitter.js';
|
|
|
20
20
|
import { buildRetryCapPausePayload, buildSkippedSiblingResults } from './retry-cap.js';
|
|
21
21
|
import { appendCostLine, buildEntry as buildCostEntry } from '../cost/cost-log.js';
|
|
22
22
|
import { computeSessionCost } from '../cost/session-cost.js';
|
|
23
|
+
import { refreshCreditsAfterTurn, creditsForCostLog } from '../cost/credits-wire.js';
|
|
23
24
|
import { globalStore } from '../ui/sap-state-store.js';
|
|
24
25
|
import { lastAssistantIsAwaitingAnswer } from '../session/awaiting-answer.js';
|
|
25
26
|
import { enrichUserMessage } from '../router/intent-extractor.js';
|
|
@@ -823,6 +824,11 @@ export async function runTurn(params) {
|
|
|
823
824
|
// never crash a turn. Runs for both happy and interrupted paths so
|
|
824
825
|
// the user's spend is recorded even when a turn dies mid-stream.
|
|
825
826
|
{
|
|
827
|
+
// Credits (2026-09-16): sample the customer's balance BEFORE building
|
|
828
|
+
// the cost line so the JSONL records what the LEDGER charged alongside
|
|
829
|
+
// our COGS, and so the status footer the REPL prints next reads a fresh
|
|
830
|
+
// figure. No-op + no request outside credits mode; capped at 2.5 s.
|
|
831
|
+
await refreshCreditsAfterTurn();
|
|
826
832
|
const turnTokens = {
|
|
827
833
|
input: (session.usage?.input_tokens ?? 0) - turnStartTokens.input,
|
|
828
834
|
output: (session.usage?.output_tokens ?? 0) - turnStartTokens.output,
|
|
@@ -838,6 +844,9 @@ export async function runTurn(params) {
|
|
|
838
844
|
model: params.modelOverride ?? session.model,
|
|
839
845
|
tokens: turnTokens,
|
|
840
846
|
duration_ms: turnDurationMs,
|
|
847
|
+
// Additive: present only in credits mode (undefined in USD mode, so
|
|
848
|
+
// the emitted line is byte-identical to a pre-credits one).
|
|
849
|
+
credits: creditsForCostLog(),
|
|
841
850
|
});
|
|
842
851
|
void appendCostLine(session.id, entry);
|
|
843
852
|
}
|
|
@@ -25,7 +25,13 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
25
25
|
// Rule 8 — no batch without plan. 2nd+ mutating call this turn requires
|
|
26
26
|
// explicit confirmation. Fires regardless of /safety-mode toggle (batch
|
|
27
27
|
// detection is unconditional per Plan 3 §6.6).
|
|
28
|
-
|
|
28
|
+
// Scratch-output exemption (2026-09-16): files under ./cspeach-out/ are the
|
|
29
|
+
// standalone Manual Implementation Guide outputs (NN-<OBJECT>.<ext>,
|
|
30
|
+
// GUIDE.md) — local scratch, not SAP objects. Rule 8 protects SAP batches;
|
|
31
|
+
// applying it here made the 2nd+ file (GUIDE.md) hit the batch card and,
|
|
32
|
+
// headless, get refused. Exempt from the gate AND from the batch count.
|
|
33
|
+
const scratchWrite = isScratchOutputWrite(tool, args);
|
|
34
|
+
if (tool.isMutating && !scratchWrite && shouldGateRule8()) {
|
|
29
35
|
const r = await presentSafetyConfirmation({
|
|
30
36
|
rule: 'RULE_8_BATCH_PLAN',
|
|
31
37
|
op: tool.name,
|
|
@@ -67,7 +73,7 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
67
73
|
// Always record AFTER successful mutating dispatch — applies to 1st and
|
|
68
74
|
// 2nd+ writes alike. The 1st write seeds the counter; without this the
|
|
69
75
|
// gate would never fire on the 2nd write.
|
|
70
|
-
if (tool.isMutating && !(result.is_error ?? false)) {
|
|
76
|
+
if (tool.isMutating && !scratchWrite && !(result.is_error ?? false)) {
|
|
71
77
|
recordWriteOp(tool.name, args);
|
|
72
78
|
}
|
|
73
79
|
return { content: result.content, is_error: result.is_error ?? false };
|
|
@@ -76,3 +82,22 @@ export async function dispatchTool(name, args, ctx) {
|
|
|
76
82
|
return { content: JSON.stringify({ error: String(err) }), is_error: true };
|
|
77
83
|
}
|
|
78
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* True for a mutating filesystem tool whose target sits under ./cspeach-out/
|
|
87
|
+
* (relative to cwd). Accepts `cspeach-out/…`, `./cspeach-out/…` and the
|
|
88
|
+
* Windows backslash form; rejects anything that climbs out (`..`) or is
|
|
89
|
+
* absolute. Deliberately narrow: only this one folder is exempt.
|
|
90
|
+
*/
|
|
91
|
+
export function isScratchOutputWrite(tool, args) {
|
|
92
|
+
if (!tool.isMutating || tool.category !== 'filesystem')
|
|
93
|
+
return false;
|
|
94
|
+
const raw = args?.path;
|
|
95
|
+
if (typeof raw !== 'string' || raw.length === 0)
|
|
96
|
+
return false;
|
|
97
|
+
const p = raw.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
98
|
+
if (p.startsWith('/') || /^[A-Za-z]:\//.test(p))
|
|
99
|
+
return false;
|
|
100
|
+
if (p.split('/').some((seg) => seg === '..'))
|
|
101
|
+
return false;
|
|
102
|
+
return p === 'cspeach-out' || p.startsWith('cspeach-out/');
|
|
103
|
+
}
|
|
@@ -15,6 +15,7 @@ const MUTABLE_KEYS = new Set([
|
|
|
15
15
|
'classifier.safe_mode',
|
|
16
16
|
'local_build',
|
|
17
17
|
'local_files', // deprecated alias for local_build (2026-06-13) — still accepted
|
|
18
|
+
'show_cogs',
|
|
18
19
|
'write_mode',
|
|
19
20
|
'plan_mode',
|
|
20
21
|
'plan_audit',
|
|
@@ -502,6 +503,20 @@ export async function runConfigSet(args) {
|
|
|
502
503
|
console.log(chalk.dim(' → takes effect on the next `cspeach` launch.'));
|
|
503
504
|
break;
|
|
504
505
|
}
|
|
506
|
+
case 'show_cogs': {
|
|
507
|
+
// Operator escape hatch — render OUR Anthropic COGS in USD even on a
|
|
508
|
+
// managed, metered account (where the customer-facing unit is credits).
|
|
509
|
+
// Off/absent is the product default; see cost/display-mode.ts.
|
|
510
|
+
const b = coerceBool(value);
|
|
511
|
+
if (b === null) {
|
|
512
|
+
err(`${key} must be on/off (also accepts true/false, 1/0).`);
|
|
513
|
+
}
|
|
514
|
+
cfg.show_cogs = b;
|
|
515
|
+
console.log(chalk.dim(b
|
|
516
|
+
? ' → cost figures will show USD (our Anthropic cost), not credits.'
|
|
517
|
+
: ' → cost figures will show credits on a managed, metered account.'));
|
|
518
|
+
break;
|
|
519
|
+
}
|
|
505
520
|
case 'write_mode':
|
|
506
521
|
if (!isValidWriteMode(value)) {
|
|
507
522
|
err(`Invalid value for write_mode. Must be one of: auto, approval-gated, advisory-only`);
|
|
@@ -632,6 +647,7 @@ export function validateValue(key, value) {
|
|
|
632
647
|
return value === 'true' || value === 'false';
|
|
633
648
|
case 'local_build':
|
|
634
649
|
case 'local_files': // deprecated alias — same on/off coercion
|
|
650
|
+
case 'show_cogs':
|
|
635
651
|
return coerceBool(value) !== null;
|
|
636
652
|
case 'write_mode':
|
|
637
653
|
return isValidWriteMode(value);
|
package/dist/commands/cost.js
CHANGED
|
@@ -11,7 +11,24 @@
|
|
|
11
11
|
import chalk from 'chalk';
|
|
12
12
|
import { readCostLog, summarise } from '../cost/cost-log.js';
|
|
13
13
|
import { formatCost } from '../cost/pricing.js';
|
|
14
|
-
|
|
14
|
+
import { creditsActive, formatCredits, globalCredits, CREDITS_PENDING, } from '../cost/credits.js';
|
|
15
|
+
/**
|
|
16
|
+
* Sum the credits the proxy charged across the logged turns. Entries written
|
|
17
|
+
* before credits existed (or on turns whose balance fetch failed) carry no
|
|
18
|
+
* number — they are skipped, not counted as zero, and the caller says so.
|
|
19
|
+
*/
|
|
20
|
+
function summariseCredits(entries) {
|
|
21
|
+
let total = 0;
|
|
22
|
+
let unknown = 0;
|
|
23
|
+
for (const e of entries) {
|
|
24
|
+
if (typeof e.credits_charged === 'number')
|
|
25
|
+
total += e.credits_charged;
|
|
26
|
+
else
|
|
27
|
+
unknown++;
|
|
28
|
+
}
|
|
29
|
+
return { total, unknown };
|
|
30
|
+
}
|
|
31
|
+
export async function runCostCommand(sessionId, credits = globalCredits.snapshot()) {
|
|
15
32
|
const entries = await readCostLog(sessionId);
|
|
16
33
|
if (entries.length === 0) {
|
|
17
34
|
console.log('');
|
|
@@ -20,9 +37,27 @@ export async function runCostCommand(sessionId) {
|
|
|
20
37
|
return;
|
|
21
38
|
}
|
|
22
39
|
const s = summarise(entries);
|
|
40
|
+
// Credits mode: the customer is billed in credits at a multiplier we do not
|
|
41
|
+
// disclose, so NOTHING derived from cost/pricing.ts may be printed here —
|
|
42
|
+
// not the total, not the per-model split, not the per-turn figures. Tokens
|
|
43
|
+
// stay: they are a usage fact, not a COGS leak.
|
|
44
|
+
const inCredits = creditsActive();
|
|
45
|
+
const perTurn = summariseCredits(entries);
|
|
23
46
|
// Header
|
|
24
47
|
console.log('');
|
|
25
|
-
|
|
48
|
+
if (inCredits) {
|
|
49
|
+
const unit = credits?.unitLabel ?? 'credits';
|
|
50
|
+
// Prefer the live session tracker (balance deltas observed this run);
|
|
51
|
+
// fall back to the logged per-turn charges when it has nothing yet.
|
|
52
|
+
const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : perTurn.total;
|
|
53
|
+
console.log(chalk.bold(`Session usage so far: ${formatCredits(total, unit)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
|
|
54
|
+
if (credits?.balance != null) {
|
|
55
|
+
console.log(chalk.dim(`Remaining balance: ${formatCredits(credits.balance, unit)}`));
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
else {
|
|
59
|
+
console.log(chalk.bold(`Session cost so far: ${formatCost(s.totalCost)}`) + chalk.dim(` (${s.turns} turn${s.turns === 1 ? '' : 's'})`));
|
|
60
|
+
}
|
|
26
61
|
// Token breakdown
|
|
27
62
|
console.log('');
|
|
28
63
|
console.log(chalk.dim('Tokens:'));
|
|
@@ -35,7 +70,10 @@ export async function runCostCommand(sessionId) {
|
|
|
35
70
|
console.log('');
|
|
36
71
|
console.log(chalk.dim('By model:'));
|
|
37
72
|
for (const m of s.byModel) {
|
|
38
|
-
|
|
73
|
+
// Credits mode: turn counts only. A per-model dollar split is a direct
|
|
74
|
+
// read on our per-model COGS.
|
|
75
|
+
const tail = inCredits ? '' : ` ${formatCost(m.cost)}`;
|
|
76
|
+
console.log(` ${m.model.padEnd(24)} ${m.turns} turn${m.turns === 1 ? '' : 's'}${tail}`);
|
|
39
77
|
}
|
|
40
78
|
}
|
|
41
79
|
else if (s.byModel[0]) {
|
|
@@ -48,7 +86,21 @@ export async function runCostCommand(sessionId) {
|
|
|
48
86
|
console.log(chalk.dim(`Recent turns (last ${recent.length}):`));
|
|
49
87
|
for (const e of recent) {
|
|
50
88
|
const tok = (e.tokens.input + e.tokens.output).toLocaleString();
|
|
51
|
-
|
|
89
|
+
// Credits mode: the per-turn figure is what the LEDGER charged (a balance
|
|
90
|
+
// delta recorded at turn end), never our computed USD. A turn whose
|
|
91
|
+
// balance sample failed shows the pending marker rather than a number we
|
|
92
|
+
// would have to invent.
|
|
93
|
+
const tail = inCredits
|
|
94
|
+
? (typeof e.credits_charged === 'number'
|
|
95
|
+
? formatCredits(e.credits_charged, credits?.unitLabel ?? 'credits')
|
|
96
|
+
: CREDITS_PENDING)
|
|
97
|
+
: formatCost(e.cost);
|
|
98
|
+
console.log(` turn ${String(e.turn).padStart(3)} ${tok.padStart(10)} tok ${tail}`);
|
|
99
|
+
}
|
|
100
|
+
if (inCredits && perTurn.unknown > 0) {
|
|
101
|
+
console.log('');
|
|
102
|
+
console.log(chalk.dim(`${perTurn.unknown} turn${perTurn.unknown === 1 ? '' : 's'} had no balance reading — `
|
|
103
|
+
+ `their usage is included in a later turn's figure.`));
|
|
52
104
|
}
|
|
53
105
|
console.log('');
|
|
54
106
|
console.log(chalk.dim(`Detailed JSONL: ~/.cspeach/sessions/${sessionId}-cost.jsonl`));
|
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import fs from 'node:fs/promises';
|
|
16
16
|
import path from 'node:path';
|
|
17
17
|
import { buildAuditReport } from '../session/audit-export.js';
|
|
18
|
+
import { globalCredits } from '../cost/credits.js';
|
|
18
19
|
import { readCostLog } from '../cost/cost-log.js';
|
|
19
20
|
/** Parse the text after `/export` into a subcommand + optional filename. */
|
|
20
21
|
export function parseExportArgs(rest) {
|
|
@@ -30,7 +31,9 @@ export async function runExportAuditCommand(opts) {
|
|
|
30
31
|
const { session, transport, emit } = opts;
|
|
31
32
|
const cwd = opts.cwd ?? process.cwd();
|
|
32
33
|
const costEntries = opts.costEntries ?? (await readCostLog(session.id));
|
|
33
|
-
|
|
34
|
+
// The exported .md is a file the customer opens and forwards, so it carries
|
|
35
|
+
// their credits (not our COGS) whenever credits mode is active.
|
|
36
|
+
const report = buildAuditReport({ session, costEntries, transport, credits: globalCredits.snapshot() });
|
|
34
37
|
const name = opts.filename && opts.filename.trim().length > 0
|
|
35
38
|
? opts.filename.trim()
|
|
36
39
|
: defaultAuditFilename(session);
|
|
@@ -30,6 +30,7 @@ import { applyAuditResult, applyPhaseResolution, buildResumeCommand, offerNextPh
|
|
|
30
30
|
import { appendCostLine, buildEntry } from '../cost/cost-log.js';
|
|
31
31
|
import { startThinkingHeartbeat, AUDIT_HEARTBEAT_THRESHOLDS } from '../renderer/thinking-heartbeat.js';
|
|
32
32
|
import { computeCost, formatCost } from '../cost/pricing.js';
|
|
33
|
+
import { creditsActive } from '../cost/credits.js';
|
|
33
34
|
/** Two consecutive failed verdicts on the same phase force status 'blocked'. */
|
|
34
35
|
export const MAX_CONSECUTIVE_AUDIT_FAILURES = 2;
|
|
35
36
|
/** Infra error strings are model/HTTP noise — clamp when rendering (Task 5 review). */
|
|
@@ -659,7 +660,13 @@ function renderAuditCost(args, cost) {
|
|
|
659
660
|
try {
|
|
660
661
|
const tok = cost.tokens.input + cost.tokens.output;
|
|
661
662
|
const usd = computeCost(cost.model, cost.tokens);
|
|
662
|
-
|
|
663
|
+
// Credits mode: the audit turn's USD is our COGS. There is no per-audit
|
|
664
|
+
// credit figure to substitute (the ledger charges the turn, not this
|
|
665
|
+
// sub-step), so the dim line drops the cost fragment and keeps tokens —
|
|
666
|
+
// never a dollar on a managed customer's screen.
|
|
667
|
+
args.log(chalk.dim(creditsActive()
|
|
668
|
+
? `[audit] ${tok.toLocaleString()} tok (${cost.model})`
|
|
669
|
+
: `[audit] ${tok.toLocaleString()} tok · ${formatCost(usd)} (${cost.model})`));
|
|
663
670
|
void appendCostLine(args.sessionId, {
|
|
664
671
|
...buildEntry({ turn: 0, model: cost.model, tokens: cost.tokens, duration_ms: cost.durationMs }),
|
|
665
672
|
label: 'audit',
|
package/dist/config/loader.js
CHANGED
|
@@ -155,6 +155,11 @@ export async function loadConfig() {
|
|
|
155
155
|
// standalone: whitelist-coerced profile table. An unrecognised platform
|
|
156
156
|
// drops the whole table (undefined → key deleted below).
|
|
157
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,
|
|
158
163
|
write_mode: isValidWriteMode(parsed.write_mode) ? parsed.write_mode : DEFAULT_CONFIG.write_mode,
|
|
159
164
|
// plan_mode: whitelist coercion — anything but the two valid literals
|
|
160
165
|
// (typos, wrong types) collapses to undefined, which callers treat as
|
|
@@ -232,6 +237,11 @@ export async function loadConfig() {
|
|
|
232
237
|
delete merged.no_sap;
|
|
233
238
|
if (merged.standalone === undefined)
|
|
234
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;
|
|
235
245
|
// Same for `plan_mode`: absent-or-invalid must not serialise as
|
|
236
246
|
// `plan_mode = undefined` — drop the key so `config show` stays clean and
|
|
237
247
|
// callers see a genuine "absent" shape (treated as 'step').
|
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
|
+
}
|
|
@@ -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
|
+
}
|
package/dist/doctor/run.js
CHANGED
|
@@ -10,6 +10,7 @@ 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';
|
|
15
16
|
import { checkStandards } from './checks/standards.js';
|
|
@@ -20,6 +21,7 @@ export async function runDoctor() {
|
|
|
20
21
|
checks.push(await checkProxy());
|
|
21
22
|
checks.push(await checkAuth());
|
|
22
23
|
checks.push(await checkLlmMode(cfg));
|
|
24
|
+
checks.push(await checkCredits(cfg));
|
|
23
25
|
checks.push(await checkWriteMode(cfg));
|
|
24
26
|
checks.push(await checkSystemRoles(cfg));
|
|
25
27
|
checks.push(await checkKeychain());
|
package/dist/one-shot.js
CHANGED
|
@@ -19,6 +19,7 @@ import { createClassicOutputEmitter } from './renderer/notices.js';
|
|
|
19
19
|
import { todoEmitter } from './ui/todo-emitter.js';
|
|
20
20
|
import { applyLocalBuildConfig } from './tools/local-build.js';
|
|
21
21
|
import { getEffectiveWriteMode } from './repl/mode-cycle.js';
|
|
22
|
+
import { initCredits } from './cost/credits-wire.js';
|
|
22
23
|
// Side-effect imports for tool registration.
|
|
23
24
|
import './tools/sap-read.js';
|
|
24
25
|
import './tools/sap-write.js';
|
|
@@ -97,6 +98,12 @@ export async function runOneShot(prompt, opts) {
|
|
|
97
98
|
console.error('No API key configured. Run `cspeach` (interactive) first to set one.');
|
|
98
99
|
return 1;
|
|
99
100
|
}
|
|
101
|
+
// Credits (2026-09-16) — resolve the display mode before the turn runs, so
|
|
102
|
+
// the per-turn cost JSONL records the ledger charge and nothing downstream
|
|
103
|
+
// can print a dollar to a managed customer. One-shot prints no cost figure
|
|
104
|
+
// of its own today, but the agent loop's cost-log path is shared with the
|
|
105
|
+
// REPL and must not diverge. Capped + fail-safe; BYOK/local skip the call.
|
|
106
|
+
await initCredits(cfg, async () => (await store.get()) ?? '');
|
|
100
107
|
const aliases = Object.keys(cfg.sap);
|
|
101
108
|
let alias;
|
|
102
109
|
let adt;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import chalk from 'chalk';
|
|
2
2
|
import { formatCost } from '../cost/pricing.js';
|
|
3
|
+
import { creditsActive, formatCreditsLine, CREDITS_PENDING } from '../cost/credits.js';
|
|
3
4
|
const RULE_WIDTH = 72;
|
|
4
5
|
const RULE = chalk.dim('─'.repeat(RULE_WIDTH));
|
|
5
6
|
/**
|
|
@@ -40,15 +41,24 @@ export const LARGE_SESSION_THRESHOLD = 100_000;
|
|
|
40
41
|
* direct stdout (which would corrupt the live Ink frame).
|
|
41
42
|
*/
|
|
42
43
|
export function formatStatusFooter(params) {
|
|
43
|
-
const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant } = params;
|
|
44
|
+
const { sessionId, sapAlias, skill, turnCount, tokens, cachedTokens, elapsedMs, costThisTurn, costSession, sessionInputTokens, variant, credits } = params;
|
|
44
45
|
const shortSession = sessionId.slice(0, 8);
|
|
45
46
|
const skillDisplay = skill.startsWith('/') ? skill : `/${skill}`;
|
|
46
47
|
const turns = `${turnCount} turn${turnCount === 1 ? '' : 's'}`;
|
|
47
48
|
const tokStr = tokens.toLocaleString() + ' tok this turn';
|
|
48
49
|
const cachedStr = cachedTokens > 0 ? `${cachedTokens.toLocaleString()} cached` : '';
|
|
49
50
|
const elapsed = elapsedMs > 0 ? `${(elapsedMs / 1000).toFixed(1)}s` : '';
|
|
50
|
-
|
|
51
|
-
|
|
51
|
+
// Credits mode: the whole spend story collapses into ONE customer-facing
|
|
52
|
+
// segment (turn · session · balance) in place of the two dollar segments.
|
|
53
|
+
// Both variants read `costTurnStr`, so putting the combined line there
|
|
54
|
+
// covers the slim Ink receipt and the full classic receipt alike.
|
|
55
|
+
const inCredits = creditsActive();
|
|
56
|
+
const costTurnStr = inCredits
|
|
57
|
+
? (credits ? formatCreditsLine(credits) : CREDITS_PENDING)
|
|
58
|
+
: (costThisTurn != null ? formatCost(costThisTurn) : '');
|
|
59
|
+
const costSessStr = inCredits
|
|
60
|
+
? ''
|
|
61
|
+
: (costSession != null ? `session: ${formatCost(costSession)}` : '');
|
|
52
62
|
// Ink turn-receipt (see the variant doc on StatusFooterParams):
|
|
53
63
|
// turn-only facts — everything session-scoped lives in <StatusLine />.
|
|
54
64
|
const parts = variant === 'turn'
|
|
@@ -20,6 +20,7 @@
|
|
|
20
20
|
import { computeCost } from '../cost/pricing.js';
|
|
21
21
|
import { computeSessionCost } from '../cost/session-cost.js';
|
|
22
22
|
import { globalStore } from '../ui/sap-state-store.js';
|
|
23
|
+
import { globalCredits } from '../cost/credits.js';
|
|
23
24
|
import { printStatusFooter, formatStatusFooter } from '../renderer/status-footer.js';
|
|
24
25
|
import { shouldUseInk } from '../renderer/tty.js';
|
|
25
26
|
export function snapshotTurnStart(session) {
|
|
@@ -71,6 +72,10 @@ export function pushPostTurnStatus(params) {
|
|
|
71
72
|
costThisTurn: turnCost,
|
|
72
73
|
costSession: sessionCost,
|
|
73
74
|
sessionInputTokens: session.usage.input_tokens,
|
|
75
|
+
// Credits (2026-09-16): the agent loop already sampled the post-turn
|
|
76
|
+
// balance, so this snapshot is fresh. formatStatusFooter ignores it
|
|
77
|
+
// entirely in USD mode — the dollar receipt stays byte-identical.
|
|
78
|
+
credits: globalCredits.snapshot(),
|
|
74
79
|
};
|
|
75
80
|
if (shouldUseInk() && chunkEmitter) {
|
|
76
81
|
// Push through chunkEmitter so the line lands in Body's <Static>
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `spend:` row of the /exit session summary (2026-09-16).
|
|
3
|
+
*
|
|
4
|
+
* Extracted from repl.tsx's printGoodbye so the credits/USD switch has one
|
|
5
|
+
* testable home. USD output is byte-identical to the line printed before
|
|
6
|
+
* credits existed: ` spend: $1.37`.
|
|
7
|
+
*/
|
|
8
|
+
import { formatCost } from '../cost/pricing.js';
|
|
9
|
+
import { creditsActive, formatCredits, CREDITS_PENDING } from '../cost/credits.js';
|
|
10
|
+
export function formatSessionSpendLine(sessionCostUsd, credits) {
|
|
11
|
+
if (!creditsActive())
|
|
12
|
+
return ` spend: ${formatCost(sessionCostUsd)}`;
|
|
13
|
+
if (!credits)
|
|
14
|
+
return ` spend: ${CREDITS_PENDING}`;
|
|
15
|
+
const value = formatCredits(credits.sessionTotal, credits.unitLabel);
|
|
16
|
+
const balance = credits.balance !== null
|
|
17
|
+
? ` · balance ${Math.round(credits.balance).toLocaleString('en-US')}`
|
|
18
|
+
: '';
|
|
19
|
+
return ` spend: ${value}${balance}`;
|
|
20
|
+
}
|
package/dist/repl.js
CHANGED
|
@@ -22,7 +22,10 @@ import { makeSessionId, saveSession } from './session/store.js';
|
|
|
22
22
|
import { newSession } from './session/schema.js';
|
|
23
23
|
import { resolveModelRole } from './models/resolve.js';
|
|
24
24
|
import { loadServerModelConfig } from './models/server-config.js';
|
|
25
|
-
import { computeCost
|
|
25
|
+
import { computeCost } from './cost/pricing.js';
|
|
26
|
+
import { formatSessionSpendLine } from './repl/session-spend-line.js';
|
|
27
|
+
import { globalCredits } from './cost/credits.js';
|
|
28
|
+
import { initCredits } from './cost/credits-wire.js';
|
|
26
29
|
import { createProviderForMode } from './agent/providers/factory.js';
|
|
27
30
|
import { assertModeLicensed, CspeachLicenseError } from './agent/providers/license-gate.js';
|
|
28
31
|
import { runTurn } from './agent/loop.js';
|
|
@@ -242,7 +245,7 @@ function printGoodbye(session) {
|
|
|
242
245
|
console.log(chalk.dim(` turns: ${userTurns}`));
|
|
243
246
|
console.log(chalk.dim(` last skill: ${session.skill ?? '(none)'}`));
|
|
244
247
|
console.log(chalk.dim(` tokens: ${totalTokens.toLocaleString()} (input+output+cache)`));
|
|
245
|
-
console.log(chalk.dim(
|
|
248
|
+
console.log(chalk.dim(formatSessionSpendLine(sessionCost, globalCredits.snapshot())));
|
|
246
249
|
console.log('');
|
|
247
250
|
console.log(chalk.cyan(`Resume with: cspeach --resume ${session.id}`));
|
|
248
251
|
console.log(chalk.dim(`Full log: ~/.cspeach/streams/${session.id}.md`));
|
|
@@ -404,6 +407,13 @@ export async function runRepl(opts) {
|
|
|
404
407
|
// fetch lands simply uses the built-ins (fail-safe). Precedence when it does
|
|
405
408
|
// land: env > local config > server > built-in.
|
|
406
409
|
void loadServerModelConfig(cfg);
|
|
410
|
+
// Credits (2026-09-16) — resolve ONCE, before any cost figure can render.
|
|
411
|
+
// A managed customer is billed in credits at a multiplier we do not
|
|
412
|
+
// disclose, so the dollar figures (our Anthropic COGS) must never reach
|
|
413
|
+
// their screen. Awaited, but hard-capped at 2.5s inside fetchBalance and
|
|
414
|
+
// fail-safe: any failure leaves the process in USD mode, i.e. exactly the
|
|
415
|
+
// pre-credits behaviour. BYOK/ai-hub/local skip the round-trip entirely.
|
|
416
|
+
await initCredits(cfg, async () => apiKey ?? '');
|
|
407
417
|
// Task C.10 — surface the current write_mode prominently. advisory-only gets
|
|
408
418
|
// a bold yellow banner (no-writes is a surprising-enough state that the user
|
|
409
419
|
// should not miss it). Other modes get a quiet one-liner so the state is
|
|
@@ -223,8 +223,8 @@ export const STANDALONE_NOTE = '<standalone_note>No SAP system is connected (sta
|
|
|
223
223
|
'numbered steps in dependency order, one object per step, each stating the object type and name, where to create it (ADT wizard path, or SE80/SE11/SE24/SE38 transaction for SE80 teams), ' +
|
|
224
224
|
'the exact source to paste, the activation step, and any prerequisite (package, transport, number range, message class). ' +
|
|
225
225
|
'Finish with a short verification checklist (syntax check, activation, ATC, unit test) the user can run themselves.\n' +
|
|
226
|
-
'File outputs: when file tools are available (local build is on),
|
|
227
|
-
'Step failures: when the user reports a failure at a step ("step 3 failed" plus an error message or screenshot), ask only for what is missing to diagnose it, fix only the affected object, rewrite its file, re-issue that step and any step that depends on it, and state which steps are unchanged. Never restart the whole guide.</standalone_note>';
|
|
226
|
+
'File outputs: when file tools are available (local build is on), you MUST write every produced object to the working folder as ./cspeach-out/<yyyy-mm-dd>-<short-task-name>/NN-<OBJECT_NAME>.<abap|ddls|bdef|srvd|srvb|ddlx|dcls|txt> in dependency order, and you MUST write the Manual Implementation Guide to GUIDE.md in the same folder before finishing the reply — each step pointing at its file and carrying a status (pending, done, failed, fixed). Tell the user the folder path once. If file tools are not available, say so once and keep everything in the reply.\n' +
|
|
227
|
+
'Step failures: when the user reports a failure at a step ("step 3 failed" plus an error message or screenshot), first re-read GUIDE.md and the affected object file from disk so you work from the exact current state, then ask only for what is missing to diagnose it, fix only the affected object, rewrite its file, update that step in GUIDE.md (status and what changed), re-issue that step and any step that depends on it, and state which steps are unchanged. If the user pastes an error without naming a step, identify the object from the error and confirm it in one line. Never restart the whole guide.</standalone_note>';
|
|
228
228
|
/**
|
|
229
229
|
* The full standalone contribution to `<session_context>`: the sap_system
|
|
230
230
|
* element followed by the hand-off note.
|
|
@@ -31,6 +31,7 @@
|
|
|
31
31
|
// never an invented approval.
|
|
32
32
|
import { summarise } from '../cost/cost-log.js';
|
|
33
33
|
import { CLI_VERSION } from '../lib/version.js';
|
|
34
|
+
import { creditsActive, formatCredits } from '../cost/credits.js';
|
|
34
35
|
/**
|
|
35
36
|
* Object-mutating tools — one row per completed call in the Writes table.
|
|
36
37
|
* Kept as a literal name set (not the tool registry) so this pure builder
|
|
@@ -319,7 +320,7 @@ function transportsSeen(toolCalls, sessionTransport) {
|
|
|
319
320
|
}
|
|
320
321
|
// ── Main builder ──────────────────────────────────────────────────────────
|
|
321
322
|
export function buildAuditReport(input) {
|
|
322
|
-
const { session, costEntries, transport } = input;
|
|
323
|
+
const { session, costEntries, transport, credits } = input;
|
|
323
324
|
const calls = session.toolCalls;
|
|
324
325
|
const completed = calls.filter((c) => c.completed === true);
|
|
325
326
|
const approvals = parseApprovals(calls);
|
|
@@ -437,15 +438,33 @@ export function buildAuditReport(input) {
|
|
|
437
438
|
const cost = summarise(costEntries);
|
|
438
439
|
const auditEntries = costEntries.filter((e) => e.label);
|
|
439
440
|
const auditCost = Math.round(auditEntries.reduce((s, e) => s + e.cost, 0) * 10000) / 10000;
|
|
440
|
-
|
|
441
|
+
const inCredits = creditsActive();
|
|
442
|
+
if (inCredits) {
|
|
443
|
+
const unit = credits?.unitLabel ?? 'credits';
|
|
444
|
+
const logged = costEntries.reduce((s, e) => s + (typeof e.credits_charged === 'number' ? e.credits_charged : 0), 0);
|
|
445
|
+
const total = credits && credits.sessionTotal > 0 ? credits.sessionTotal : logged;
|
|
446
|
+
L(`- **Total:** ${formatCredits(total, unit)} over ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
|
|
447
|
+
}
|
|
448
|
+
else {
|
|
449
|
+
L(`- **Total:** $${cost.totalCost.toFixed(4)} over ${cost.turns} turn${cost.turns === 1 ? '' : 's'}`);
|
|
450
|
+
}
|
|
441
451
|
L(`- **Tokens:** ${cost.totalTokens.input.toLocaleString('en-US')} in · ${cost.totalTokens.output.toLocaleString('en-US')} out · ${cost.totalTokens.cacheRead.toLocaleString('en-US')} cache-read · ${cost.totalTokens.cacheCreate.toLocaleString('en-US')} cache-write`);
|
|
452
|
+
if (inCredits && credits?.balance != null) {
|
|
453
|
+
L(`- **Remaining:** balance ${Math.round(credits.balance).toLocaleString('en-US')}`);
|
|
454
|
+
}
|
|
442
455
|
if (auditEntries.length > 0) {
|
|
443
|
-
|
|
456
|
+
// Credits mode: the labelled-audit turn COUNT is still useful provenance;
|
|
457
|
+
// its USD cost is not ours to publish to a customer.
|
|
458
|
+
L(inCredits
|
|
459
|
+
? `- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}`
|
|
460
|
+
: `- **Includes** ${auditEntries.length} labelled audit turn${auditEntries.length === 1 ? '' : 's'}: $${auditCost.toFixed(4)}`);
|
|
444
461
|
}
|
|
445
462
|
if (cost.byModel.length > 1) {
|
|
446
463
|
L('- **By model:**');
|
|
447
464
|
for (const m of cost.byModel) {
|
|
448
|
-
L(
|
|
465
|
+
L(inCredits
|
|
466
|
+
? ` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'}`
|
|
467
|
+
: ` - ${m.model}: ${m.turns} turn${m.turns === 1 ? '' : 's'} · $${m.cost.toFixed(4)}`);
|
|
449
468
|
}
|
|
450
469
|
}
|
|
451
470
|
else if (cost.byModel[0]) {
|
package/dist/ui/header.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { jsxs as _jsxs, jsx as _jsx, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { Box, Text } from 'ink';
|
|
3
3
|
import { useSapState } from './sap-state-store.js';
|
|
4
|
+
import { formatSpendSegment } from './status-line.js';
|
|
4
5
|
import { CLI_VERSION } from '../lib/version.js';
|
|
5
6
|
const PEACH = '#FFCBA4';
|
|
6
7
|
/**
|
|
@@ -34,6 +35,7 @@ export function Header({ writeMode } = {}) {
|
|
|
34
35
|
const tokDisplay = s.tokenUsageToday >= 1000
|
|
35
36
|
? `${(s.tokenUsageToday / 1000).toFixed(1)}k tok`
|
|
36
37
|
: `${s.tokenUsageToday} tok`;
|
|
37
|
-
|
|
38
|
+
// Shared with <StatusLine /> so the credits/dollar switch has one home.
|
|
39
|
+
const spendDisplay = formatSpendSegment(s);
|
|
38
40
|
return (_jsxs(Box, { borderStyle: "single", borderColor: "gray", paddingX: 1, children: [_jsxs(Text, { bold: true, color: PEACH, children: ["\uD83C\uDF51 CSPeach ", CLI_VERSION] }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "cyan", children: s.system || 'no system' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "white", children: s.user || '—' }), _jsx(Text, { color: "gray", children: " \u2502 tr " }), _jsx(Text, { color: "yellow", children: s.transport ?? 'none' }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: tokDisplay }), _jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: "green", children: spendDisplay }), modeLabel && (_jsxs(_Fragment, { children: [_jsx(Text, { color: "gray", children: " \u2502 " }), _jsx(Text, { color: modeColor, bold: isAdvisory, children: modeLabel })] }))] }));
|
|
39
41
|
}
|
|
@@ -15,6 +15,10 @@ const DEFAULT_STATE = {
|
|
|
15
15
|
system: '', user: '', transport: null, locksHeld: [],
|
|
16
16
|
tokenUsageToday: 0, dollarSpendToday: 0, dollarCap: 100, recentTurns: [],
|
|
17
17
|
writeMode: null, lastWriteTransport: null,
|
|
18
|
+
// Credits default OFF: BYOK/local/ai-hub and any pre-resolution frame keep
|
|
19
|
+
// the dollar rendering. Only an explicit managed+metered resolution flips it.
|
|
20
|
+
creditsMode: false, creditsSessionTotal: 0, creditsBalance: null,
|
|
21
|
+
creditsUnitLabel: 'credits', creditsPending: false,
|
|
18
22
|
};
|
|
19
23
|
/**
|
|
20
24
|
* Rate limiter: calls `fn` at most once every `minIntervalMs`. Trailing-only:
|
package/dist/ui/status-line.js
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
2
|
import { Text } from 'ink';
|
|
3
3
|
import { useSapState } from './sap-state-store.js';
|
|
4
|
+
import { formatCredits, CREDITS_PENDING } from '../cost/credits.js';
|
|
5
|
+
/**
|
|
6
|
+
* The spend segment shared by <StatusLine /> and <Header />.
|
|
7
|
+
*
|
|
8
|
+
* Single home for the credits/dollar switch so the two chrome widgets can
|
|
9
|
+
* never drift apart — a drift here is exactly how our COGS would leak onto a
|
|
10
|
+
* managed customer's screen from one of them.
|
|
11
|
+
*/
|
|
12
|
+
export function formatSpendSegment(s) {
|
|
13
|
+
if (!s.creditsMode)
|
|
14
|
+
return `$${s.dollarSpendToday.toFixed(2)}`;
|
|
15
|
+
if (s.creditsPending && s.creditsSessionTotal === 0)
|
|
16
|
+
return CREDITS_PENDING;
|
|
17
|
+
return formatCredits(s.creditsSessionTotal, s.creditsUnitLabel);
|
|
18
|
+
}
|
|
4
19
|
/**
|
|
5
20
|
* Persistent SAP statusline (UX Wave 1, Task 4) — Header reborn,
|
|
6
21
|
* bottom-anchored. Single borderless line rendered between <TurnStatus />
|
|
@@ -25,7 +40,11 @@ const MODE_COLORS = {
|
|
|
25
40
|
};
|
|
26
41
|
export function StatusLine() {
|
|
27
42
|
const s = useSapState();
|
|
28
|
-
|
|
43
|
+
// Credits mode (2026-09-16): a managed customer sees what THEY are charged.
|
|
44
|
+
// The segment keeps its meaning — "what this session has cost me" — so it
|
|
45
|
+
// carries the session credit total, not the balance (the balance has its
|
|
46
|
+
// own home in the post-turn receipt and `cspeach doctor`).
|
|
47
|
+
const spendDisplay = formatSpendSegment(s);
|
|
29
48
|
// Wave1 live-fix (Bug 1) — one TRUNCATING <Text> instead of a <Box> of
|
|
30
49
|
// sibling Texts. The statusline lives in the dynamic Ink frame, where a
|
|
31
50
|
// row that soft-wraps in the terminal breaks log-update's logical-line
|