niahere 0.5.2 → 0.5.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/package.json +1 -1
- package/src/agent/backends/claude-normalize.ts +16 -1
- package/src/agent/backends/codex-normalize.ts +9 -2
- package/src/chat/engine.ts +5 -1
- package/src/cli/index.ts +7 -0
- package/src/cli/usage.ts +189 -0
- package/src/core/runner.ts +17 -2
- package/src/db/models/session.ts +7 -0
- package/src/db/models/usage.ts +0 -0
package/package.json
CHANGED
|
@@ -17,6 +17,21 @@ const DEAD_TURNS: Record<string, FailoverScope | undefined> = {
|
|
|
17
17
|
turn_setup_failed: undefined,
|
|
18
18
|
};
|
|
19
19
|
|
|
20
|
+
/**
|
|
21
|
+
* Stamp the chain's provider over the SDK's own, which names the deployment
|
|
22
|
+
* target (firstParty/bedrock/vertex) — a different axis from which backend ran
|
|
23
|
+
* the turn, and the one `providers_used` has to answer.
|
|
24
|
+
*/
|
|
25
|
+
function attribute(modelUsage: unknown): Record<string, unknown> | undefined {
|
|
26
|
+
if (!modelUsage || typeof modelUsage !== "object") return undefined;
|
|
27
|
+
return Object.fromEntries(
|
|
28
|
+
Object.entries(modelUsage as Record<string, unknown>).map(([model, usage]) => [
|
|
29
|
+
model,
|
|
30
|
+
{ ...(usage as object), provider: "claude" },
|
|
31
|
+
]),
|
|
32
|
+
);
|
|
33
|
+
}
|
|
34
|
+
|
|
20
35
|
/**
|
|
21
36
|
* Pure reducer: Claude Agent SDK messages → normalized `AgentEvent`s.
|
|
22
37
|
*
|
|
@@ -151,7 +166,7 @@ export class SdkNormalizer implements Normalizer {
|
|
|
151
166
|
session_id: msg.session_id,
|
|
152
167
|
subtype: msg.subtype,
|
|
153
168
|
usage: msg.usage,
|
|
154
|
-
model_usage: msg.modelUsage,
|
|
169
|
+
model_usage: attribute(msg.modelUsage),
|
|
155
170
|
},
|
|
156
171
|
};
|
|
157
172
|
}
|
|
@@ -40,7 +40,11 @@ export class CodexNormalizer implements Normalizer {
|
|
|
40
40
|
case "turn.failed":
|
|
41
41
|
return this.fail(typeof e.error?.message === "string" ? e.error.message : "");
|
|
42
42
|
case "turn.completed": {
|
|
43
|
-
|
|
43
|
+
// Codex counts like OpenAI: `input_tokens` is the whole prompt with
|
|
44
|
+
// `cached_input_tokens` a slice of it. Claude reports the two as
|
|
45
|
+
// siblings and one accumulator sums both, so the slice comes out here.
|
|
46
|
+
const cacheRead = e.usage?.cached_input_tokens ?? 0;
|
|
47
|
+
const input = Math.max(0, (e.usage?.input_tokens ?? 0) - cacheRead);
|
|
44
48
|
const output = e.usage?.output_tokens ?? 0;
|
|
45
49
|
return [
|
|
46
50
|
{
|
|
@@ -50,13 +54,16 @@ export class CodexNormalizer implements Normalizer {
|
|
|
50
54
|
backendSessionId: this.threadId,
|
|
51
55
|
// Same shape the Claude path emits, so one accumulator serves both
|
|
52
56
|
// and a failed-over turn is attributable to the provider that ran it.
|
|
57
|
+
// No cost: codex reports none, and an invented zero would read as a
|
|
58
|
+
// free turn.
|
|
53
59
|
metadata: {
|
|
54
60
|
model_usage: {
|
|
55
61
|
[this.model || "default"]: {
|
|
56
62
|
provider: "codex",
|
|
57
63
|
inputTokens: input,
|
|
58
64
|
outputTokens: output,
|
|
59
|
-
cacheReadInputTokens:
|
|
65
|
+
cacheReadInputTokens: cacheRead,
|
|
66
|
+
cacheCreationInputTokens: e.usage?.cache_write_input_tokens ?? 0,
|
|
60
67
|
},
|
|
61
68
|
},
|
|
62
69
|
},
|
package/src/chat/engine.ts
CHANGED
|
@@ -323,7 +323,11 @@ export async function createChatEngine(opts: EngineOptions): Promise<ChatEngine>
|
|
|
323
323
|
};
|
|
324
324
|
try {
|
|
325
325
|
messageId = await Message.save(saveParams);
|
|
326
|
-
} catch {
|
|
326
|
+
} catch (e) {
|
|
327
|
+
// Saving the reply matters more than saving its usage, but a
|
|
328
|
+
// silent drop cost eight days of the ledger before anyone
|
|
329
|
+
// looked.
|
|
330
|
+
log.error({ room, error: errMsg(e) }, "dropping turn metadata; message saved without it");
|
|
327
331
|
messageId = await Message.save({ ...saveParams, metadata: undefined });
|
|
328
332
|
}
|
|
329
333
|
await Session.touch(sessionId);
|
package/src/cli/index.ts
CHANGED
|
@@ -203,6 +203,12 @@ switch (command) {
|
|
|
203
203
|
break;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
case "usage": {
|
|
207
|
+
const { usageCommand } = await import("./usage");
|
|
208
|
+
await usageCommand(process.argv.slice(3));
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
|
|
206
212
|
case "seed": {
|
|
207
213
|
await import("../db/seed");
|
|
208
214
|
break;
|
|
@@ -337,6 +343,7 @@ Daemon:
|
|
|
337
343
|
model [name] Show or set global Claude model
|
|
338
344
|
health Check daemon, db, channels, config
|
|
339
345
|
logs [-f] [--channel ch] Daemon logs (filter by channel)
|
|
346
|
+
usage [--days N --by dim --room R --json] Tokens and cost per day/model/provider/room
|
|
340
347
|
|
|
341
348
|
Chat:
|
|
342
349
|
chat [-c] [-r] [--employee|--agent|--job name] Interactive chat
|
package/src/cli/usage.ts
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import { withDb } from "../db/with-db";
|
|
2
|
+
import { queryUsage, type UsageRow } from "../db/models/usage";
|
|
3
|
+
import { getConfig } from "../utils/config";
|
|
4
|
+
import { BOLD, DIM, RESET, fail } from "../utils/cli";
|
|
5
|
+
import { errMsg } from "../utils/errors";
|
|
6
|
+
|
|
7
|
+
export type Dimension = "day" | "model" | "provider" | "room";
|
|
8
|
+
|
|
9
|
+
const DIMENSIONS: Dimension[] = ["day", "model", "provider", "room"];
|
|
10
|
+
|
|
11
|
+
export interface UsageOptions {
|
|
12
|
+
days: number;
|
|
13
|
+
by: Dimension;
|
|
14
|
+
room?: string;
|
|
15
|
+
json: boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface UsageBucket {
|
|
19
|
+
key: string;
|
|
20
|
+
inputTokens: number;
|
|
21
|
+
outputTokens: number;
|
|
22
|
+
cacheReadTokens: number;
|
|
23
|
+
cacheWriteTokens: number;
|
|
24
|
+
/** null when nothing in the bucket carried a cost. */
|
|
25
|
+
costUsd: number | null;
|
|
26
|
+
turns: number;
|
|
27
|
+
unpricedTurns: number;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function parseUsageArgs(argv: string[]): UsageOptions {
|
|
31
|
+
const opts: UsageOptions = { days: 7, by: "day", json: false };
|
|
32
|
+
for (let i = 0; i < argv.length; i++) {
|
|
33
|
+
const arg = argv[i]!;
|
|
34
|
+
const value = argv[i + 1];
|
|
35
|
+
if (arg === "--json") {
|
|
36
|
+
opts.json = true;
|
|
37
|
+
} else if (arg === "--days" && value) {
|
|
38
|
+
const parsed = Number.parseInt(value, 10);
|
|
39
|
+
if (Number.isInteger(parsed) && parsed > 0) opts.days = parsed;
|
|
40
|
+
i += 1;
|
|
41
|
+
} else if (arg === "--by" && value) {
|
|
42
|
+
if (!DIMENSIONS.includes(value as Dimension)) throw new Error(`--by must be one of: ${DIMENSIONS.join(", ")}`);
|
|
43
|
+
opts.by = value as Dimension;
|
|
44
|
+
i += 1;
|
|
45
|
+
} else if (arg === "--room" && value) {
|
|
46
|
+
opts.room = value;
|
|
47
|
+
i += 1;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return opts;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function rollup(rows: UsageRow[], by: Dimension): UsageBucket[] {
|
|
54
|
+
const byKey = new Map<string, UsageBucket>();
|
|
55
|
+
for (const row of rows) {
|
|
56
|
+
const key = row[by];
|
|
57
|
+
const seen = byKey.get(key);
|
|
58
|
+
if (!seen) {
|
|
59
|
+
byKey.set(key, {
|
|
60
|
+
key,
|
|
61
|
+
inputTokens: row.inputTokens,
|
|
62
|
+
outputTokens: row.outputTokens,
|
|
63
|
+
cacheReadTokens: row.cacheReadTokens,
|
|
64
|
+
cacheWriteTokens: row.cacheWriteTokens,
|
|
65
|
+
costUsd: row.costUsd,
|
|
66
|
+
turns: row.turns,
|
|
67
|
+
unpricedTurns: row.unpricedTurns,
|
|
68
|
+
});
|
|
69
|
+
continue;
|
|
70
|
+
}
|
|
71
|
+
seen.inputTokens += row.inputTokens;
|
|
72
|
+
seen.outputTokens += row.outputTokens;
|
|
73
|
+
seen.cacheReadTokens += row.cacheReadTokens;
|
|
74
|
+
seen.cacheWriteTokens += row.cacheWriteTokens;
|
|
75
|
+
seen.turns += row.turns;
|
|
76
|
+
seen.unpricedTurns += row.unpricedTurns;
|
|
77
|
+
// A known cost survives an unknown one beside it; only an all-unknown
|
|
78
|
+
// bucket reports nothing.
|
|
79
|
+
seen.costUsd = seen.costUsd === null && row.costUsd === null ? null : (seen.costUsd ?? 0) + (row.costUsd ?? 0);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const buckets = [...byKey.values()];
|
|
83
|
+
return by === "day"
|
|
84
|
+
? buckets.sort((a, b) => a.key.localeCompare(b.key))
|
|
85
|
+
: buckets.sort((a, b) => (b.costUsd ?? 0) - (a.costUsd ?? 0) || b.turns - a.turns);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function compactTokens(n: number): string {
|
|
89
|
+
if (n >= 1_000_000) return `${round(n / 1_000_000)}M`;
|
|
90
|
+
if (n >= 1_000) return `${round(n / 1_000)}K`;
|
|
91
|
+
return String(n);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function round(n: number): string {
|
|
95
|
+
return n.toFixed(1).replace(/\.0$/, "");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatCost(cost: number | null): string {
|
|
99
|
+
if (cost === null) return "—";
|
|
100
|
+
// Sub-cent turns are common; rounding them to $0.00 hides real spend.
|
|
101
|
+
return cost > 0 && cost < 0.01 ? `$${cost.toFixed(4)}` : `$${cost.toFixed(2)}`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const COLUMNS: [string, (b: UsageBucket) => string][] = [
|
|
105
|
+
["Turns", (b) => String(b.turns)],
|
|
106
|
+
["Input", (b) => compactTokens(b.inputTokens)],
|
|
107
|
+
["Output", (b) => compactTokens(b.outputTokens)],
|
|
108
|
+
["Cache rd", (b) => compactTokens(b.cacheReadTokens)],
|
|
109
|
+
["Cache wr", (b) => compactTokens(b.cacheWriteTokens)],
|
|
110
|
+
["Cost", (b) => formatCost(b.costUsd)],
|
|
111
|
+
];
|
|
112
|
+
|
|
113
|
+
export function renderTable(buckets: UsageBucket[], label: string): string[] {
|
|
114
|
+
const header = [label, ...COLUMNS.map(([name]) => name)];
|
|
115
|
+
const body = buckets.map((b) => [b.key, ...COLUMNS.map(([, read]) => read(b))]);
|
|
116
|
+
const total = buckets.reduce<UsageBucket>((sum, b) => {
|
|
117
|
+
sum.inputTokens += b.inputTokens;
|
|
118
|
+
sum.outputTokens += b.outputTokens;
|
|
119
|
+
sum.cacheReadTokens += b.cacheReadTokens;
|
|
120
|
+
sum.cacheWriteTokens += b.cacheWriteTokens;
|
|
121
|
+
sum.turns += b.turns;
|
|
122
|
+
sum.unpricedTurns += b.unpricedTurns;
|
|
123
|
+
sum.costUsd = sum.costUsd === null && b.costUsd === null ? null : (sum.costUsd ?? 0) + (b.costUsd ?? 0);
|
|
124
|
+
return sum;
|
|
125
|
+
}, emptyBucket("Total"));
|
|
126
|
+
|
|
127
|
+
const rows = [header, ...body, [total.key, ...COLUMNS.map(([, read]) => read(total))]];
|
|
128
|
+
const widths = header.map((_, col) => Math.max(...rows.map((r) => r[col]!.length)));
|
|
129
|
+
const line = (cells: string[]) =>
|
|
130
|
+
cells.map((cell, col) => (col === 0 ? cell.padEnd(widths[col]!) : cell.padStart(widths[col]!))).join(" ");
|
|
131
|
+
|
|
132
|
+
const out = [`${BOLD}${line(header)}${RESET}`, ...body.map(line), `${BOLD}${line(rows.at(-1)!)}${RESET}`];
|
|
133
|
+
if (total.unpricedTurns > 0) {
|
|
134
|
+
out.push(`${DIM}${total.unpricedTurns} of ${total.turns} turns report no cost (codex bills no per-token price)${RESET}`);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function emptyBucket(key: string): UsageBucket {
|
|
140
|
+
return {
|
|
141
|
+
key,
|
|
142
|
+
inputTokens: 0,
|
|
143
|
+
outputTokens: 0,
|
|
144
|
+
cacheReadTokens: 0,
|
|
145
|
+
cacheWriteTokens: 0,
|
|
146
|
+
costUsd: null,
|
|
147
|
+
turns: 0,
|
|
148
|
+
unpricedTurns: 0,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export async function usageCommand(argv: string[]): Promise<void> {
|
|
153
|
+
let opts: UsageOptions;
|
|
154
|
+
try {
|
|
155
|
+
opts = parseUsageArgs(argv);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
fail(errMsg(e));
|
|
158
|
+
}
|
|
159
|
+
const since = new Date(Date.now() - opts.days * 24 * 60 * 60 * 1000);
|
|
160
|
+
const timezone = getConfig().timezone || "UTC";
|
|
161
|
+
|
|
162
|
+
const rows = await withDb(() =>
|
|
163
|
+
queryUsage({ since, ...(opts.room !== undefined ? { room: opts.room } : {}), timezone }),
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
if (opts.json) {
|
|
167
|
+
console.log(JSON.stringify({ since: since.toISOString(), timezone, rows }, null, 2));
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
if (rows.length === 0) {
|
|
172
|
+
console.log(`No recorded usage in the last ${opts.days} days${opts.room ? ` for ${opts.room}` : ""}.`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const scope = opts.room ? ` · ${opts.room}` : "";
|
|
177
|
+
console.log(`\nUsage — last ${opts.days} days${scope} (${timezone})\n`);
|
|
178
|
+
for (const line of renderTable(rollup(rows, opts.by), title(opts.by))) console.log(line);
|
|
179
|
+
|
|
180
|
+
if (opts.by === "day") {
|
|
181
|
+
console.log("\nBy model");
|
|
182
|
+
for (const line of renderTable(rollup(rows, "model"), "Model")) console.log(` ${line}`);
|
|
183
|
+
}
|
|
184
|
+
console.log();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function title(by: Dimension): string {
|
|
188
|
+
return by === "day" ? "Day" : by[0]!.toUpperCase() + by.slice(1);
|
|
189
|
+
}
|
package/src/core/runner.ts
CHANGED
|
@@ -125,15 +125,30 @@ export async function runJobAcrossChain(
|
|
|
125
125
|
): Promise<RunnerOutput> {
|
|
126
126
|
const cursor = new ChainCursor(chain);
|
|
127
127
|
let output: RunnerOutput = { agentText: "", sessionId: "", error: "no model configured" };
|
|
128
|
+
const abandoned: string[] = [];
|
|
129
|
+
|
|
130
|
+
// The last entry's message alone hides why the chain started walking, which
|
|
131
|
+
// is usually the more useful half.
|
|
132
|
+
const withTrail = (out: RunnerOutput): RunnerOutput =>
|
|
133
|
+
out.error && abandoned.length > 0 ? { ...out, error: `${out.error} (after ${abandoned.join("; ")})` } : out;
|
|
128
134
|
|
|
129
135
|
for (let entry = cursor.current; entry; ) {
|
|
130
136
|
output = await runEntry(entry, sessionCtx, jobPrompt, onActivity, activeRoom);
|
|
131
|
-
if (!output.failover) return output;
|
|
137
|
+
if (!output.failover) return withTrail(output);
|
|
132
138
|
|
|
133
139
|
const from = describeEntry(entry);
|
|
140
|
+
if (output.error) abandoned.push(`${from}: ${output.error}`);
|
|
134
141
|
entry = cursor.advance(output.failover);
|
|
135
|
-
if (entry)
|
|
142
|
+
if (entry) {
|
|
143
|
+
log.warn(
|
|
144
|
+
{ from, to: describeEntry(entry), scope: output.failover, err: output.error, terminal_reason: output.terminalReason },
|
|
145
|
+
"failing over to next model",
|
|
146
|
+
);
|
|
147
|
+
}
|
|
136
148
|
}
|
|
149
|
+
|
|
150
|
+
// The chain ran out: the final entry's own failure is already the last trail
|
|
151
|
+
// entry, so reporting it twice adds nothing.
|
|
137
152
|
return output;
|
|
138
153
|
}
|
|
139
154
|
|
package/src/db/models/session.ts
CHANGED
|
@@ -182,8 +182,14 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
|
|
|
182
182
|
// Bind deltas as jsonb via sql.json — pre-stringifying would store a
|
|
183
183
|
// double-encoded string scalar, making every `->>` extraction return NULL
|
|
184
184
|
// and silently zeroing all accumulated totals.
|
|
185
|
+
// Codex reports tokens but no cost. Folding that in as $0 would make a
|
|
186
|
+
// session that failed over look cheaper than one that never left Claude, so
|
|
187
|
+
// the unknown is counted rather than absorbed.
|
|
188
|
+
const priced = typeof resultMeta.cost_usd === "number";
|
|
189
|
+
|
|
185
190
|
const delta = sql.json({
|
|
186
191
|
total_cost_usd: (resultMeta.cost_usd as number) || 0,
|
|
192
|
+
unpriced_turns: priced ? 0 : 1,
|
|
187
193
|
total_turns: (resultMeta.turns as number) || 0,
|
|
188
194
|
total_duration_ms: (resultMeta.duration_ms as number) || 0,
|
|
189
195
|
total_duration_api_ms: (resultMeta.duration_api_ms as number) || 0,
|
|
@@ -203,6 +209,7 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
|
|
|
203
209
|
await sql`
|
|
204
210
|
UPDATE sessions SET metadata = jsonb_build_object(
|
|
205
211
|
'total_cost_usd', COALESCE((metadata->>'total_cost_usd')::real, 0) + (${delta}->>'total_cost_usd')::real,
|
|
212
|
+
'unpriced_turns', COALESCE((metadata->>'unpriced_turns')::int, 0) + (${delta}->>'unpriced_turns')::int,
|
|
206
213
|
'total_turns', COALESCE((metadata->>'total_turns')::int, 0) + (${delta}->>'total_turns')::int,
|
|
207
214
|
'total_duration_ms', COALESCE((metadata->>'total_duration_ms')::real, 0) + (${delta}->>'total_duration_ms')::real,
|
|
208
215
|
'total_duration_api_ms', COALESCE((metadata->>'total_duration_api_ms')::real, 0) + (${delta}->>'total_duration_api_ms')::real,
|
|
Binary file
|