niahere 0.5.1 → 0.5.3
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 +4 -4
- package/src/agent/backends/claude-normalize.ts +57 -2
- package/src/agent/backends/codex-normalize.ts +27 -6
- package/src/agent/backends/codex.ts +1 -1
- package/src/agent/failure.ts +3 -1
- package/src/chat/engine.ts +5 -1
- package/src/cli/index.ts +7 -0
- package/src/cli/usage.ts +189 -0
- package/src/db/models/session.ts +64 -15
- package/src/db/models/usage.ts +0 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "niahere",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.3",
|
|
4
4
|
"description": "A personal AI assistant daemon — chat, scheduled jobs, persona system, extensible via skills.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"scripts": {
|
|
@@ -44,9 +44,9 @@
|
|
|
44
44
|
"license": "MIT",
|
|
45
45
|
"private": false,
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@anthropic-ai/claude-agent-sdk": "
|
|
48
|
-
"@anthropic-ai/sdk": "
|
|
49
|
-
"@modelcontextprotocol/sdk": "
|
|
47
|
+
"@anthropic-ai/claude-agent-sdk": "0.3.220",
|
|
48
|
+
"@anthropic-ai/sdk": "0.115.0",
|
|
49
|
+
"@modelcontextprotocol/sdk": "1.30.0",
|
|
50
50
|
"@slack/bolt": "^4.6.0",
|
|
51
51
|
"cron-parser": "^5.5.0",
|
|
52
52
|
"grammy": "^1.41.1",
|
|
@@ -1,6 +1,36 @@
|
|
|
1
1
|
import type { AgentEvent, Normalizer } from "../types";
|
|
2
2
|
import { truncate } from "../../utils/format-activity";
|
|
3
3
|
import { isRetryable, scopeOf, parseFailure } from "../failure";
|
|
4
|
+
import type { FailoverScope } from "../types";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Terminal reasons that mean the turn died. The SDK used to report several of
|
|
8
|
+
* these as `completed`, so a dead turn landed in the audit as a successful run.
|
|
9
|
+
* The value is how far the chain should skip; undefined means stop.
|
|
10
|
+
*/
|
|
11
|
+
const DEAD_TURNS: Record<string, FailoverScope | undefined> = {
|
|
12
|
+
api_error: "provider",
|
|
13
|
+
budget_exhausted: undefined,
|
|
14
|
+
malformed_tool_use_exhausted: undefined,
|
|
15
|
+
structured_output_retry_exhausted: undefined,
|
|
16
|
+
tool_deferred_unavailable: undefined,
|
|
17
|
+
turn_setup_failed: undefined,
|
|
18
|
+
};
|
|
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
|
+
}
|
|
4
34
|
|
|
5
35
|
/**
|
|
6
36
|
* Pure reducer: Claude Agent SDK messages → normalized `AgentEvent`s.
|
|
@@ -41,6 +71,17 @@ export class SdkNormalizer implements Normalizer {
|
|
|
41
71
|
return [];
|
|
42
72
|
}
|
|
43
73
|
|
|
74
|
+
if (msg.type === "system" && msg.subtype === "compact_boundary") {
|
|
75
|
+
// Long sessions compact silently; surface it so the transcript records
|
|
76
|
+
// that history was summarized and how much was dropped.
|
|
77
|
+
const meta = msg.compact_metadata ?? {};
|
|
78
|
+
const trigger = meta.trigger === "manual" ? "manual" : "auto";
|
|
79
|
+
const pre = meta.pre_tokens ?? 0;
|
|
80
|
+
const post = meta.post_tokens;
|
|
81
|
+
const shrink = post !== undefined ? `${pre} → ${post} tokens` : `${pre} tokens`;
|
|
82
|
+
return [{ type: "thinking", delta: `context compacted (${trigger}, ${shrink})` }];
|
|
83
|
+
}
|
|
84
|
+
|
|
44
85
|
if (msg.type === "system") {
|
|
45
86
|
// Subagent/task lifecycle (subtype init handled above).
|
|
46
87
|
if (msg.subtype === "task_started" && msg.description) {
|
|
@@ -98,6 +139,16 @@ export class SdkNormalizer implements Normalizer {
|
|
|
98
139
|
}
|
|
99
140
|
|
|
100
141
|
private consumeResult(msg: any): AgentEvent {
|
|
142
|
+
const deadTurn = msg.terminal_reason as string | undefined;
|
|
143
|
+
if (!msg.is_error && deadTurn && deadTurn in DEAD_TURNS) {
|
|
144
|
+
return {
|
|
145
|
+
type: "error",
|
|
146
|
+
message: (msg.errors?.join(", ") as string) || `turn ended: ${deadTurn}`,
|
|
147
|
+
retryable: false,
|
|
148
|
+
failover: DEAD_TURNS[deadTurn],
|
|
149
|
+
terminalReason: deadTurn,
|
|
150
|
+
};
|
|
151
|
+
}
|
|
101
152
|
if (!msg.is_error) {
|
|
102
153
|
return {
|
|
103
154
|
type: "result",
|
|
@@ -115,7 +166,7 @@ export class SdkNormalizer implements Normalizer {
|
|
|
115
166
|
session_id: msg.session_id,
|
|
116
167
|
subtype: msg.subtype,
|
|
117
168
|
usage: msg.usage,
|
|
118
|
-
model_usage: msg.modelUsage,
|
|
169
|
+
model_usage: attribute(msg.modelUsage),
|
|
119
170
|
},
|
|
120
171
|
};
|
|
121
172
|
}
|
|
@@ -126,7 +177,11 @@ export class SdkNormalizer implements Normalizer {
|
|
|
126
177
|
type: "error",
|
|
127
178
|
message: raw,
|
|
128
179
|
retryable: isRetryable(raw),
|
|
129
|
-
|
|
180
|
+
// api_error_status is the reliable signal; prose is the fallback.
|
|
181
|
+
failover: scopeOf(
|
|
182
|
+
{ ...parseFailure(raw), status: typeof msg.api_error_status === "number" ? msg.api_error_status : undefined },
|
|
183
|
+
"provider",
|
|
184
|
+
),
|
|
130
185
|
terminalReason: msg.terminal_reason,
|
|
131
186
|
};
|
|
132
187
|
}
|
|
@@ -14,6 +14,10 @@ import { scopeOf, parseFailure } from "../failure";
|
|
|
14
14
|
* skill budget) and are dropped.
|
|
15
15
|
*/
|
|
16
16
|
export class CodexNormalizer implements Normalizer {
|
|
17
|
+
/** The model this run was launched on, for usage attribution — codex's own
|
|
18
|
+
* events don't name it. */
|
|
19
|
+
constructor(private readonly model?: string) {}
|
|
20
|
+
|
|
17
21
|
private threadId = "";
|
|
18
22
|
private agentText = "";
|
|
19
23
|
private failed = false;
|
|
@@ -35,20 +39,37 @@ export class CodexNormalizer implements Normalizer {
|
|
|
35
39
|
return this.fail(typeof e.message === "string" ? e.message : "");
|
|
36
40
|
case "turn.failed":
|
|
37
41
|
return this.fail(typeof e.error?.message === "string" ? e.error.message : "");
|
|
38
|
-
case "turn.completed":
|
|
42
|
+
case "turn.completed": {
|
|
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);
|
|
48
|
+
const output = e.usage?.output_tokens ?? 0;
|
|
39
49
|
return [
|
|
40
50
|
{
|
|
41
51
|
type: "result",
|
|
42
52
|
text: this.agentText,
|
|
43
|
-
usage: {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
53
|
+
usage: { tokens: { input, output } },
|
|
54
|
+
backendSessionId: this.threadId,
|
|
55
|
+
// Same shape the Claude path emits, so one accumulator serves both
|
|
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.
|
|
59
|
+
metadata: {
|
|
60
|
+
model_usage: {
|
|
61
|
+
[this.model || "default"]: {
|
|
62
|
+
provider: "codex",
|
|
63
|
+
inputTokens: input,
|
|
64
|
+
outputTokens: output,
|
|
65
|
+
cacheReadInputTokens: cacheRead,
|
|
66
|
+
cacheCreationInputTokens: e.usage?.cache_write_input_tokens ?? 0,
|
|
67
|
+
},
|
|
47
68
|
},
|
|
48
69
|
},
|
|
49
|
-
backendSessionId: this.threadId,
|
|
50
70
|
},
|
|
51
71
|
];
|
|
72
|
+
}
|
|
52
73
|
default:
|
|
53
74
|
return [];
|
|
54
75
|
}
|
|
@@ -201,7 +201,7 @@ class CodexSession implements AgentSession {
|
|
|
201
201
|
// Started now, not after exit: an undrained pipe blocks the child.
|
|
202
202
|
const stderr = drainStderr(proc.stderr);
|
|
203
203
|
|
|
204
|
-
const normalizer = new CodexNormalizer();
|
|
204
|
+
const normalizer = new CodexNormalizer(this.ctx.model);
|
|
205
205
|
const stdout = proc.stdout.getReader();
|
|
206
206
|
const lines = readLines(stdout)[Symbol.asyncIterator]();
|
|
207
207
|
let sawTerminal = false;
|
package/src/agent/failure.ts
CHANGED
|
@@ -82,8 +82,10 @@ function scopeOfStatus(status: number, message: string): FailoverScope | undefin
|
|
|
82
82
|
*/
|
|
83
83
|
export function scopeOf(failure: Failure, blank?: FailoverScope): FailoverScope | undefined {
|
|
84
84
|
const t = failure.message.trim();
|
|
85
|
-
|
|
85
|
+
// A status is authoritative even when the message is empty — the prose that
|
|
86
|
+
// accompanies a 429 or 529 is routinely unhelpful.
|
|
86
87
|
if (failure.status !== undefined) return scopeOfStatus(failure.status, t);
|
|
88
|
+
if (!t || t.toLowerCase() === "unknown error") return blank;
|
|
87
89
|
if (MODEL_SCOPED.some((p) => p.test(t))) return "model";
|
|
88
90
|
if (PROVIDER_SCOPED.some((p) => p.test(t))) return "provider";
|
|
89
91
|
return undefined;
|
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/db/models/session.ts
CHANGED
|
@@ -122,30 +122,74 @@ export async function getRecentSummaries(
|
|
|
122
122
|
}));
|
|
123
123
|
}
|
|
124
124
|
|
|
125
|
+
export interface UsageTotals {
|
|
126
|
+
inputTokens: number;
|
|
127
|
+
outputTokens: number;
|
|
128
|
+
cacheReadTokens: number;
|
|
129
|
+
cacheCreationTokens: number;
|
|
130
|
+
models: string[];
|
|
131
|
+
providers: string[];
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const num = (v: unknown): number => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
135
|
+
const str = (v: unknown): string | null => (typeof v === "string" && v ? v : null);
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fold a result's per-model usage into totals. `provider` matters because a
|
|
139
|
+
* chain that fails over makes the model name alone ambiguous about who served
|
|
140
|
+
* the turn.
|
|
141
|
+
*/
|
|
142
|
+
export function summarizeModelUsage(modelUsage: unknown): UsageTotals {
|
|
143
|
+
const totals: UsageTotals = {
|
|
144
|
+
inputTokens: 0,
|
|
145
|
+
outputTokens: 0,
|
|
146
|
+
cacheReadTokens: 0,
|
|
147
|
+
cacheCreationTokens: 0,
|
|
148
|
+
models: [],
|
|
149
|
+
providers: [],
|
|
150
|
+
};
|
|
151
|
+
if (!modelUsage || typeof modelUsage !== "object") return totals;
|
|
152
|
+
|
|
153
|
+
const models = new Set<string>();
|
|
154
|
+
const providers = new Set<string>();
|
|
155
|
+
for (const [key, raw] of Object.entries(modelUsage as Record<string, unknown>)) {
|
|
156
|
+
const usage = (raw && typeof raw === "object" ? raw : {}) as Record<string, unknown>;
|
|
157
|
+
models.add(str(usage.canonicalModel) ?? key);
|
|
158
|
+
const provider = str(usage.provider);
|
|
159
|
+
if (provider) providers.add(provider);
|
|
160
|
+
totals.inputTokens += num(usage.inputTokens);
|
|
161
|
+
totals.outputTokens += num(usage.outputTokens);
|
|
162
|
+
totals.cacheReadTokens += num(usage.cacheReadInputTokens);
|
|
163
|
+
totals.cacheCreationTokens += num(usage.cacheCreationInputTokens);
|
|
164
|
+
}
|
|
165
|
+
totals.models = [...models];
|
|
166
|
+
totals.providers = [...providers];
|
|
167
|
+
return totals;
|
|
168
|
+
}
|
|
169
|
+
|
|
125
170
|
export async function accumulateMetadata(id: string, resultMeta: Record<string, unknown>): Promise<void> {
|
|
126
171
|
const sql = getSql();
|
|
127
172
|
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
newModels.push(model);
|
|
137
|
-
inputTokens += usage.inputTokens || 0;
|
|
138
|
-
outputTokens += usage.outputTokens || 0;
|
|
139
|
-
cacheReadTokens += usage.cacheReadInputTokens || 0;
|
|
140
|
-
cacheCreationTokens += usage.cacheCreationInputTokens || 0;
|
|
141
|
-
}
|
|
142
|
-
}
|
|
173
|
+
const {
|
|
174
|
+
inputTokens,
|
|
175
|
+
outputTokens,
|
|
176
|
+
cacheReadTokens,
|
|
177
|
+
cacheCreationTokens,
|
|
178
|
+
models: newModels,
|
|
179
|
+
providers: newProviders,
|
|
180
|
+
} = summarizeModelUsage(resultMeta.model_usage);
|
|
143
181
|
|
|
144
182
|
// Bind deltas as jsonb via sql.json — pre-stringifying would store a
|
|
145
183
|
// double-encoded string scalar, making every `->>` extraction return NULL
|
|
146
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
|
+
|
|
147
190
|
const delta = sql.json({
|
|
148
191
|
total_cost_usd: (resultMeta.cost_usd as number) || 0,
|
|
192
|
+
unpriced_turns: priced ? 0 : 1,
|
|
149
193
|
total_turns: (resultMeta.turns as number) || 0,
|
|
150
194
|
total_duration_ms: (resultMeta.duration_ms as number) || 0,
|
|
151
195
|
total_duration_api_ms: (resultMeta.duration_api_ms as number) || 0,
|
|
@@ -155,14 +199,17 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
|
|
|
155
199
|
total_cache_creation_tokens: cacheCreationTokens,
|
|
156
200
|
message_count: 1,
|
|
157
201
|
models_used: newModels,
|
|
202
|
+
providers_used: newProviders,
|
|
158
203
|
channel: (resultMeta.channel as string) || null,
|
|
159
204
|
});
|
|
160
205
|
const modelsDelta = sql.json(newModels);
|
|
206
|
+
const providersDelta = sql.json(newProviders);
|
|
161
207
|
|
|
162
208
|
// Atomic accumulate — no read-then-write race
|
|
163
209
|
await sql`
|
|
164
210
|
UPDATE sessions SET metadata = jsonb_build_object(
|
|
165
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,
|
|
166
213
|
'total_turns', COALESCE((metadata->>'total_turns')::int, 0) + (${delta}->>'total_turns')::int,
|
|
167
214
|
'total_duration_ms', COALESCE((metadata->>'total_duration_ms')::real, 0) + (${delta}->>'total_duration_ms')::real,
|
|
168
215
|
'total_duration_api_ms', COALESCE((metadata->>'total_duration_api_ms')::real, 0) + (${delta}->>'total_duration_api_ms')::real,
|
|
@@ -173,6 +220,8 @@ export async function accumulateMetadata(id: string, resultMeta: Record<string,
|
|
|
173
220
|
'message_count', COALESCE((metadata->>'message_count')::int, 0) + 1,
|
|
174
221
|
'models_used', (SELECT COALESCE(jsonb_agg(DISTINCT e), '[]'::jsonb)
|
|
175
222
|
FROM jsonb_array_elements(COALESCE(metadata->'models_used', '[]'::jsonb) || ${modelsDelta}) AS e),
|
|
223
|
+
'providers_used', (SELECT COALESCE(jsonb_agg(DISTINCT e), '[]'::jsonb)
|
|
224
|
+
FROM jsonb_array_elements(COALESCE(metadata->'providers_used', '[]'::jsonb) || ${providersDelta}) AS e),
|
|
176
225
|
'channel', COALESCE(metadata->>'channel', ${(resultMeta.channel as string) || null})
|
|
177
226
|
)
|
|
178
227
|
WHERE id = ${id}
|
|
Binary file
|