@adecore/agents 0.31.5 → 0.31.7
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/chat/acp-backend.js +10 -3
- package/dist/chat/acp-protocol.d.ts +3 -0
- package/dist/chat/acp-protocol.js +26 -2
- package/dist/chat/chat-session.js +5 -2
- package/dist/chat/fixtures/copilot-1.0.101-turn.json +677 -0
- package/dist/testing/fake-acp-capture.d.ts +11 -0
- package/dist/testing/fake-acp-capture.js +50 -0
- package/dist/usage/aggregate.js +1 -1
- package/dist/usage/index-file.d.ts +2 -0
- package/dist/usage/index-file.js +19 -10
- package/dist/usage/pricing.d.ts +3 -2
- package/dist/usage/pricing.js +42 -8
- package/dist/usage/readers/codex.d.ts +9 -0
- package/dist/usage/readers/codex.js +75 -14
- package/dist/usage/record.d.ts +1 -0
- package/dist/usage/scanner.js +32 -8
- package/package.json +3 -3
- package/src/chat/acp-backend.ts +9 -3
- package/src/chat/acp-protocol.ts +30 -2
- package/src/chat/chat-session.ts +5 -2
- package/src/chat/fixtures/copilot-1.0.101-turn.json +677 -0
- package/src/testing/fake-acp-capture.ts +70 -0
- package/src/usage/aggregate.ts +1 -1
- package/src/usage/index-file.ts +25 -18
- package/src/usage/pricing.ts +46 -8
- package/src/usage/readers/codex.ts +86 -14
- package/src/usage/record.ts +1 -0
- package/src/usage/scanner.ts +35 -8
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* An ACP agent that answers with the frames a real one sent, recorded in a capture: its `initialize`, its
|
|
3
|
+
* `session/new`, and every update of one prompt before the response. Every prompt gets the same turn, except
|
|
4
|
+
* that a `session_info_update` carries the prompt's own text as its title, as Copilot CLI names a session.
|
|
5
|
+
*/
|
|
6
|
+
import type { FakeCli } from './fake-cli.ts';
|
|
7
|
+
|
|
8
|
+
type Frame = Record<string, unknown>;
|
|
9
|
+
|
|
10
|
+
export interface AcpCapture {
|
|
11
|
+
sessionId: string;
|
|
12
|
+
initialize: unknown;
|
|
13
|
+
sessionNew: unknown;
|
|
14
|
+
// The `available_commands_update` the agent sends before it answers `session/new`.
|
|
15
|
+
commands: unknown[];
|
|
16
|
+
setConfigOption: unknown;
|
|
17
|
+
promptUpdates: unknown[];
|
|
18
|
+
promptResponse: unknown;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function fakeAcpCapture(capture: AcpCapture): FakeCli {
|
|
22
|
+
return (io) => {
|
|
23
|
+
const send = (frame: Frame): void => {
|
|
24
|
+
io.out({ jsonrpc: '2.0', ...frame });
|
|
25
|
+
};
|
|
26
|
+
const update = (fields: unknown): void => {
|
|
27
|
+
send({ method: 'session/update', params: { sessionId: capture.sessionId, update: fields } });
|
|
28
|
+
};
|
|
29
|
+
return {
|
|
30
|
+
onLine: (line) => {
|
|
31
|
+
const frame = JSON.parse(line) as Frame;
|
|
32
|
+
const id = frame.id;
|
|
33
|
+
switch (frame.method) {
|
|
34
|
+
case undefined:
|
|
35
|
+
return;
|
|
36
|
+
case 'initialize':
|
|
37
|
+
send({ id, result: capture.initialize });
|
|
38
|
+
return;
|
|
39
|
+
case 'session/new':
|
|
40
|
+
update({ sessionUpdate: 'available_commands_update', availableCommands: capture.commands });
|
|
41
|
+
send({ id, result: capture.sessionNew });
|
|
42
|
+
return;
|
|
43
|
+
case 'session/set_config_option':
|
|
44
|
+
send({ id, result: capture.setConfigOption });
|
|
45
|
+
return;
|
|
46
|
+
case 'session/set_mode':
|
|
47
|
+
case 'session/close':
|
|
48
|
+
send({ id, result: {} });
|
|
49
|
+
return;
|
|
50
|
+
case 'session/prompt': {
|
|
51
|
+
const blocks = Array.isArray((frame.params as Frame | undefined)?.prompt) ? ((frame.params as Frame).prompt as Frame[]) : [];
|
|
52
|
+
const text = blocks.find((block) => block.type === 'text')?.text;
|
|
53
|
+
for (const fields of capture.promptUpdates) {
|
|
54
|
+
const named = (fields as Frame).sessionUpdate === 'session_info_update';
|
|
55
|
+
update(named ? { ...(fields as Frame), title: text } : fields);
|
|
56
|
+
}
|
|
57
|
+
send({ id, result: capture.promptResponse });
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
60
|
+
case 'session/cancel':
|
|
61
|
+
return;
|
|
62
|
+
default:
|
|
63
|
+
if (id !== undefined) {
|
|
64
|
+
send({ id, error: { code: -32601, message: `Method not found: ${String(frame.method)}` } });
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
};
|
|
70
|
+
}
|
package/src/usage/aggregate.ts
CHANGED
|
@@ -186,7 +186,7 @@ export async function aggregate(
|
|
|
186
186
|
if (day < payload.from || day > payload.to) {
|
|
187
187
|
continue;
|
|
188
188
|
}
|
|
189
|
-
const lookup = prices.look(record.model);
|
|
189
|
+
const lookup = prices.look(record.model, record.serviceTier, record.timestampMs);
|
|
190
190
|
const cost = record.costUsd ?? (lookup.price === null ? 0 : costOf(record.totals, lookup.price));
|
|
191
191
|
foldBucket(buckets, slot, record, account, lookup.price, cost);
|
|
192
192
|
foldModel(models, record, account, lookup, cost);
|
package/src/usage/index-file.ts
CHANGED
|
@@ -2,8 +2,8 @@ import { UsageProviderSchema, type UsageProvider } from '@adecore/agent-contract
|
|
|
2
2
|
import type { UsageRecord } from './record.ts';
|
|
3
3
|
import type { CodexParserState } from './readers/codex.ts';
|
|
4
4
|
|
|
5
|
-
/*
|
|
6
|
-
const INDEX_VERSION =
|
|
5
|
+
/* Old records survive deleted transcripts; the scanner reparses existing Codex files with its current reader. */
|
|
6
|
+
const INDEX_VERSION = 3;
|
|
7
7
|
/* Before accounts: every record was the default account's, so it reads as that without a scan. */
|
|
8
8
|
const ACCOUNTLESS_VERSION = 1;
|
|
9
9
|
|
|
@@ -21,18 +21,14 @@ export interface IndexedFile {
|
|
|
21
21
|
codex: CodexParserState | null;
|
|
22
22
|
/* A versioned hash of all bytes before the offset; absent in indexes that cannot verify an append. */
|
|
23
23
|
head: string | null;
|
|
24
|
+
parserVersion?: number;
|
|
25
|
+
compressed?: boolean;
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export type UsageIndex = Map<string, IndexedFile>;
|
|
27
29
|
|
|
28
|
-
/*
|
|
29
|
-
|
|
30
|
-
* directory and account strings over and over. Interning those and writing a record as a tuple is
|
|
31
|
-
* what keeps the file at a few megabytes instead of tens of them. The account is one past its place
|
|
32
|
-
* in its table, and 0 for the default account, which is also what a version 1 row without it means.
|
|
33
|
-
* A cost the CLI reported itself rides last, only on a row that has one, so every other row keeps its length.
|
|
34
|
-
*/
|
|
35
|
-
type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?];
|
|
30
|
+
/* Interned names keep the index compact; optional cost and tier fields preserve per-call pricing. */
|
|
31
|
+
type RecordRow = [number, number, number, number, number, number, number, number, number, number, number, string | 0, number?, number?, string?];
|
|
36
32
|
|
|
37
33
|
interface FileRow {
|
|
38
34
|
p: UsageProvider;
|
|
@@ -43,6 +39,8 @@ interface FileRow {
|
|
|
43
39
|
t: RecordRow[];
|
|
44
40
|
c: CodexParserState | null;
|
|
45
41
|
h?: string;
|
|
42
|
+
v?: number;
|
|
43
|
+
z?: boolean;
|
|
46
44
|
}
|
|
47
45
|
|
|
48
46
|
interface IndexFile {
|
|
@@ -92,8 +90,11 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
92
90
|
record.dedupeKey ?? 0,
|
|
93
91
|
record.account === undefined ? 0 : accounts.index(record.account) + 1
|
|
94
92
|
];
|
|
95
|
-
if (record.costUsd !== undefined) {
|
|
96
|
-
counted
|
|
93
|
+
if (record.costUsd !== undefined || record.serviceTier !== undefined) {
|
|
94
|
+
counted[13] = record.costUsd;
|
|
95
|
+
}
|
|
96
|
+
if (record.serviceTier !== undefined) {
|
|
97
|
+
counted[14] = record.serviceTier;
|
|
97
98
|
}
|
|
98
99
|
return counted;
|
|
99
100
|
};
|
|
@@ -106,7 +107,9 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
106
107
|
c: file.codex,
|
|
107
108
|
r: file.records.map(row),
|
|
108
109
|
t: file.tail.map(row),
|
|
109
|
-
...(file.head === null ? {} : { h: file.head })
|
|
110
|
+
...(file.head === null ? {} : { h: file.head }),
|
|
111
|
+
...(file.parserVersion === undefined ? {} : { v: file.parserVersion }),
|
|
112
|
+
...(file.compressed === undefined ? {} : { z: file.compressed })
|
|
110
113
|
};
|
|
111
114
|
}
|
|
112
115
|
const document: IndexFile = {
|
|
@@ -123,11 +126,12 @@ export function encodeIndex(index: UsageIndex): string {
|
|
|
123
126
|
function isRow(row: unknown, version: number): row is RecordRow {
|
|
124
127
|
return (
|
|
125
128
|
Array.isArray(row) &&
|
|
126
|
-
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length
|
|
129
|
+
(version === ACCOUNTLESS_VERSION ? row.length === 12 : row.length >= 13 && row.length <= (version === INDEX_VERSION ? 15 : 14)) &&
|
|
127
130
|
row.slice(0, 11).every((value) => typeof value === 'number') &&
|
|
128
131
|
(typeof row[11] === 'string' || row[11] === 0) &&
|
|
129
132
|
(version === ACCOUNTLESS_VERSION || typeof row[12] === 'number') &&
|
|
130
|
-
(row.length < 14 || typeof row[13] === 'number')
|
|
133
|
+
(row.length < 14 || row[13] === null || row[13] === undefined || typeof row[13] === 'number') &&
|
|
134
|
+
(row.length < 15 || typeof row[14] === 'string')
|
|
131
135
|
);
|
|
132
136
|
}
|
|
133
137
|
|
|
@@ -145,7 +149,7 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
145
149
|
}
|
|
146
150
|
const version = document?.version;
|
|
147
151
|
if (
|
|
148
|
-
(version !== INDEX_VERSION && version !== ACCOUNTLESS_VERSION) ||
|
|
152
|
+
(version !== INDEX_VERSION && version !== 2 && version !== ACCOUNTLESS_VERSION) ||
|
|
149
153
|
!Array.isArray(document.models) ||
|
|
150
154
|
!Array.isArray(document.sessions) ||
|
|
151
155
|
!Array.isArray(document.folders)
|
|
@@ -184,7 +188,8 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
184
188
|
cwd,
|
|
185
189
|
...(account === undefined ? {} : { account }),
|
|
186
190
|
totals: { calls: row[4], input: row[5], cacheRead: row[6], cacheWrite: row[7], cacheWrite1h: row[8], output: row[9], reasoning: row[10] },
|
|
187
|
-
...(row[13] ===
|
|
191
|
+
...(typeof row[13] === 'number' ? { costUsd: row[13] } : {}),
|
|
192
|
+
...(typeof row[14] === 'string' ? { serviceTier: row[14] } : {}),
|
|
188
193
|
dedupeKey: row[11] === 0 ? null : row[11]
|
|
189
194
|
});
|
|
190
195
|
}
|
|
@@ -201,7 +206,9 @@ export function decodeIndex(text: string): UsageIndex {
|
|
|
201
206
|
records,
|
|
202
207
|
tail,
|
|
203
208
|
codex: file.c ?? null,
|
|
204
|
-
head: typeof file.h === 'string' ? file.h : null
|
|
209
|
+
head: typeof file.h === 'string' ? file.h : null,
|
|
210
|
+
...(typeof file.v === 'number' ? { parserVersion: file.v } : {}),
|
|
211
|
+
...(typeof file.z === 'boolean' ? { compressed: file.z } : {})
|
|
205
212
|
});
|
|
206
213
|
}
|
|
207
214
|
}
|
package/src/usage/pricing.ts
CHANGED
|
@@ -22,6 +22,7 @@ export interface ModelPrice {
|
|
|
22
22
|
cacheRead: number;
|
|
23
23
|
cacheWrite: number;
|
|
24
24
|
cacheWrite1h: number;
|
|
25
|
+
tiers?: Partial<Record<string, ModelPrice>>;
|
|
25
26
|
}
|
|
26
27
|
|
|
27
28
|
export type PriceTable = Map<string, ModelPrice>;
|
|
@@ -40,6 +41,7 @@ const PROVIDERS = new Set(['anthropic', 'openai']);
|
|
|
40
41
|
const MODES = new Set(['chat', 'responses']);
|
|
41
42
|
|
|
42
43
|
interface LiteLlmEntry {
|
|
44
|
+
[key: string]: unknown;
|
|
43
45
|
input_cost_per_token?: unknown;
|
|
44
46
|
output_cost_per_token?: unknown;
|
|
45
47
|
cache_read_input_token_cost?: unknown;
|
|
@@ -84,6 +86,23 @@ export function parsePriceTable(document: unknown): PriceTable {
|
|
|
84
86
|
cacheWrite: rate(value.cache_creation_input_token_cost) ?? input * CACHE_WRITE_SHARE,
|
|
85
87
|
cacheWrite1h: input * CACHE_WRITE_1H_SHARE
|
|
86
88
|
};
|
|
89
|
+
const tiers: Partial<Record<string, ModelPrice>> = {};
|
|
90
|
+
for (const [tier, multiplier] of [
|
|
91
|
+
['priority', 2],
|
|
92
|
+
['flex', 0.5],
|
|
93
|
+
['ultrafast', 6]
|
|
94
|
+
] as const) {
|
|
95
|
+
tiers[tier] = {
|
|
96
|
+
input: rate(value[`input_cost_per_token_${tier}`]) ?? input * multiplier,
|
|
97
|
+
output: rate(value[`output_cost_per_token_${tier}`]) ?? output * multiplier,
|
|
98
|
+
cacheRead: rate(value[`cache_read_input_token_cost_${tier}`]) ?? price.cacheRead * multiplier,
|
|
99
|
+
cacheWrite: rate(value[`cache_creation_input_token_cost_${tier}`]) ?? price.cacheWrite * multiplier,
|
|
100
|
+
cacheWrite1h: price.cacheWrite1h * multiplier
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
if (value.litellm_provider === 'openai') {
|
|
104
|
+
price.tiers = tiers;
|
|
105
|
+
}
|
|
87
106
|
const key = name.trim().toLowerCase();
|
|
88
107
|
table.set(key, price);
|
|
89
108
|
const bare = key.slice(key.lastIndexOf('/') + 1);
|
|
@@ -100,12 +119,25 @@ function withoutDate(key: string): string | null {
|
|
|
100
119
|
return match === null ? null : match[1]!;
|
|
101
120
|
}
|
|
102
121
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
122
|
+
// The internal review alias has no published price; expose ccusage's dated estimate as a family match.
|
|
123
|
+
// https://github.com/ccusage/ccusage/blob/main/rust/adapters/codex/src/codex-auto-review-fallbacks.json
|
|
124
|
+
const REVIEW_MODELS = [
|
|
125
|
+
['2026-07-30', 'gpt-5.6-luna'],
|
|
126
|
+
['2026-03-05', 'gpt-5.4'],
|
|
127
|
+
['2026-02-05', 'gpt-5.3-codex'],
|
|
128
|
+
['2025-12-11', 'gpt-5.2-codex'],
|
|
129
|
+
['2025-11-13', 'gpt-5.1-codex'],
|
|
130
|
+
['2025-09-15', 'gpt-5-codex'],
|
|
131
|
+
['2025-08-07', 'gpt-5']
|
|
132
|
+
] as const;
|
|
133
|
+
|
|
134
|
+
export function lookupPrice(table: PriceTable, model: string, serviceTier?: string, timestampMs?: number): PriceLookup {
|
|
135
|
+
const found = lookupBasePrice(table, model, timestampMs);
|
|
136
|
+
const tier = serviceTier === 'fast' ? 'priority' : serviceTier;
|
|
137
|
+
return { ...found, price: (tier === undefined ? undefined : found.price?.tiers?.[tier]) ?? found.price };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function lookupBasePrice(table: PriceTable, model: string, timestampMs?: number): PriceLookup {
|
|
109
141
|
const key = model.trim().toLowerCase().split('[')[0]!;
|
|
110
142
|
const bare = key.slice(key.lastIndexOf('/') + 1);
|
|
111
143
|
if (bare === '' || AMBIGUOUS.has(bare)) {
|
|
@@ -115,6 +147,12 @@ export function lookupPrice(table: PriceTable, model: string): PriceLookup {
|
|
|
115
147
|
if (exact !== undefined) {
|
|
116
148
|
return { price: exact, basis: 'exact', pricedAs: null };
|
|
117
149
|
}
|
|
150
|
+
if (bare === 'codex-auto-review' && timestampMs !== undefined && Number.isFinite(timestampMs)) {
|
|
151
|
+
const date = new Date(timestampMs).toISOString().slice(0, 10);
|
|
152
|
+
const fallback = REVIEW_MODELS.find(([released]) => date >= released)?.[1] ?? 'gpt-5';
|
|
153
|
+
const found = lookupBasePrice(table, fallback);
|
|
154
|
+
return { price: found.price, basis: found.price === null ? 'unknown' : 'family', pricedAs: found.price === null ? null : fallback };
|
|
155
|
+
}
|
|
118
156
|
const dated = withoutDate(bare);
|
|
119
157
|
const undated = dated === null ? undefined : table.get(dated);
|
|
120
158
|
if (undated !== undefined && dated !== null) {
|
|
@@ -177,8 +215,8 @@ export class PriceBook {
|
|
|
177
215
|
return { source: this.table.size === 0 ? 'none' : this.source, fetchedAt: this.fetchedAt, models: this.table.size };
|
|
178
216
|
}
|
|
179
217
|
|
|
180
|
-
look(model: string): PriceLookup {
|
|
181
|
-
return lookupPrice(this.table, model);
|
|
218
|
+
look(model: string, serviceTier?: string, timestampMs?: number): PriceLookup {
|
|
219
|
+
return lookupPrice(this.table, model, serviceTier, timestampMs);
|
|
182
220
|
}
|
|
183
221
|
|
|
184
222
|
/* Reads the table an earlier run stored, once; asking LiteLLM is left to `ensure`. */
|
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
import { asObject, asString, int, type UsageRecord } from '../record.ts';
|
|
2
2
|
|
|
3
|
-
/* A `token_count` line carries the counts; the other two carry the model and the directory they belong to. */
|
|
4
3
|
export function codexMightCarryUsage(line: string): boolean {
|
|
5
|
-
return
|
|
4
|
+
return ['token_count', 'turn_context', 'session_meta', 'thread_settings_applied', 'token_usage_record', 'compacted'].some((type) => line.includes(type));
|
|
6
5
|
}
|
|
7
6
|
|
|
8
7
|
/* A fork replays its ancestors' events into the new file the moment it is created, so anything this
|
|
@@ -34,6 +33,11 @@ export interface CodexParserState {
|
|
|
34
33
|
anchorMs: number;
|
|
35
34
|
/* The last counts as they were written, so a line repeated verbatim is not counted twice. */
|
|
36
35
|
lastSignature: string | null;
|
|
36
|
+
serviceTier: string | null;
|
|
37
|
+
pendingCompactions: Record<string, { record: UsageRecord; counts: CodexCounts; threadTotal: CodexCounts | null }>;
|
|
38
|
+
compacted: string[];
|
|
39
|
+
handledResponses: string[];
|
|
40
|
+
latestResponse: string | null;
|
|
37
41
|
}
|
|
38
42
|
|
|
39
43
|
export function createCodexState(): CodexParserState {
|
|
@@ -45,12 +49,17 @@ export function createCodexState(): CodexParserState {
|
|
|
45
49
|
sawSessionMeta: false,
|
|
46
50
|
suppressing: false,
|
|
47
51
|
anchorMs: 0,
|
|
48
|
-
lastSignature: null
|
|
52
|
+
lastSignature: null,
|
|
53
|
+
serviceTier: null,
|
|
54
|
+
pendingCompactions: {},
|
|
55
|
+
compacted: [],
|
|
56
|
+
handledResponses: [],
|
|
57
|
+
latestResponse: null
|
|
49
58
|
};
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
export function cloneCodexState(state: CodexParserState): CodexParserState {
|
|
53
|
-
return
|
|
62
|
+
return structuredClone(state);
|
|
54
63
|
}
|
|
55
64
|
|
|
56
65
|
function countsOf(usage: Record<string, unknown>): CodexCounts {
|
|
@@ -87,13 +96,8 @@ export function isForkOrSubagent(payload: Record<string, unknown>): boolean {
|
|
|
87
96
|
return typeof asObject(spawn)?.parent_thread_id === 'string';
|
|
88
97
|
}
|
|
89
98
|
|
|
90
|
-
/*
|
|
91
|
-
|
|
92
|
-
* reports a cumulative total per turn, so the call is the difference with the line before it; when
|
|
93
|
-
* that difference falls the run started over and `last_token_usage` is the turn on its own. Codex
|
|
94
|
-
* counts cache reads and writes inside `input_tokens`, unlike Anthropic, so what is left after
|
|
95
|
-
* taking both out is the input that was actually paid for at the full rate.
|
|
96
|
-
*/
|
|
99
|
+
/* Codex includes cache reads and writes in input_tokens. Compaction can appear separately from
|
|
100
|
+
cumulative token_count events, so response identities prevent charging the same request twice. */
|
|
97
101
|
export function parseCodexLine(line: string, state: CodexParserState): UsageRecord | null {
|
|
98
102
|
let parsed: unknown;
|
|
99
103
|
try {
|
|
@@ -134,6 +138,18 @@ export function parseCodexLine(line: string, state: CodexParserState): UsageReco
|
|
|
134
138
|
return null;
|
|
135
139
|
}
|
|
136
140
|
|
|
141
|
+
if (payload.type === 'thread_settings_applied') {
|
|
142
|
+
const settings = asObject(payload.thread_settings);
|
|
143
|
+
if (settings !== null && typeof settings.service_tier === 'string') {
|
|
144
|
+
state.serviceTier = settings.service_tier;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (record.type === 'token_usage_record' || record.type === 'compacted') {
|
|
150
|
+
return parseCompaction(record, payload, state);
|
|
151
|
+
}
|
|
152
|
+
|
|
137
153
|
if (payload.type !== 'token_count') {
|
|
138
154
|
return null;
|
|
139
155
|
}
|
|
@@ -170,6 +186,62 @@ export function parseCodexLine(line: string, state: CodexParserState): UsageReco
|
|
|
170
186
|
state.suppressing = false;
|
|
171
187
|
}
|
|
172
188
|
|
|
189
|
+
let dedupeKey: string | null = null;
|
|
190
|
+
const pending = state.latestResponse === null ? undefined : state.pendingCompactions[state.latestResponse];
|
|
191
|
+
if (pending !== undefined && (sameCounts(pending.counts, counts) || sameCounts(pending.threadTotal, cumulative))) {
|
|
192
|
+
dedupeKey = `codex:response:${state.latestResponse}`;
|
|
193
|
+
delete state.pendingCompactions[state.latestResponse!];
|
|
194
|
+
state.handledResponses.push(state.latestResponse!);
|
|
195
|
+
}
|
|
196
|
+
return usageRecord(counts, timestampMs, state.model, state, dedupeKey);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function sameCounts(first: CodexCounts | null, second: CodexCounts): boolean {
|
|
200
|
+
return (
|
|
201
|
+
first !== null &&
|
|
202
|
+
first.input === second.input &&
|
|
203
|
+
first.cached === second.cached &&
|
|
204
|
+
first.cacheWrite === second.cacheWrite &&
|
|
205
|
+
first.output === second.output &&
|
|
206
|
+
first.reasoning === second.reasoning
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseCompaction(record: Record<string, unknown>, payload: Record<string, unknown>, state: CodexParserState): UsageRecord | null {
|
|
211
|
+
const id = asString(record.type === 'compacted' ? payload.compaction_response_id : payload.response_id);
|
|
212
|
+
if (id === '' || state.handledResponses.includes(id)) {
|
|
213
|
+
return null;
|
|
214
|
+
}
|
|
215
|
+
if (record.type === 'compacted') {
|
|
216
|
+
if (!state.compacted.includes(id)) {
|
|
217
|
+
state.compacted.push(id);
|
|
218
|
+
}
|
|
219
|
+
} else {
|
|
220
|
+
const usage = asObject(payload.usage);
|
|
221
|
+
const model = asString(payload.model) || state.model;
|
|
222
|
+
const timestampMs = Date.parse(asString(record.timestamp));
|
|
223
|
+
if (usage === null || model === null || !Number.isFinite(timestampMs)) {
|
|
224
|
+
return null;
|
|
225
|
+
}
|
|
226
|
+
const counts = countsOf(usage);
|
|
227
|
+
const parsed = usageRecord(counts, timestampMs, model, state, `codex:response:${id}`);
|
|
228
|
+
if (parsed === null) {
|
|
229
|
+
return null;
|
|
230
|
+
}
|
|
231
|
+
state.latestResponse = id;
|
|
232
|
+
const threadTotal = asObject(payload.thread_token_usage);
|
|
233
|
+
state.pendingCompactions[id] ??= { record: parsed, counts, threadTotal: threadTotal === null ? null : countsOf(threadTotal) };
|
|
234
|
+
}
|
|
235
|
+
const pending = state.pendingCompactions[id];
|
|
236
|
+
if (!state.compacted.includes(id) || pending === undefined) {
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
state.handledResponses.push(id);
|
|
240
|
+
delete state.pendingCompactions[id];
|
|
241
|
+
return pending.record;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function usageRecord(counts: CodexCounts, timestampMs: number, model: string, state: CodexParserState, dedupeKey: string | null): UsageRecord | null {
|
|
173
245
|
const uncached = Math.max(0, counts.input - counts.cached - counts.cacheWrite);
|
|
174
246
|
const totals = {
|
|
175
247
|
calls: 1,
|
|
@@ -186,11 +258,11 @@ export function parseCodexLine(line: string, state: CodexParserState): UsageReco
|
|
|
186
258
|
return {
|
|
187
259
|
provider: 'codex',
|
|
188
260
|
timestampMs,
|
|
189
|
-
model
|
|
261
|
+
model,
|
|
190
262
|
sessionId: state.sessionId,
|
|
191
263
|
cwd: state.cwd,
|
|
192
264
|
totals,
|
|
193
|
-
|
|
194
|
-
dedupeKey
|
|
265
|
+
...(state.serviceTier === null ? {} : { serviceTier: state.serviceTier }),
|
|
266
|
+
dedupeKey
|
|
195
267
|
};
|
|
196
268
|
}
|
package/src/usage/record.ts
CHANGED
|
@@ -13,6 +13,7 @@ export interface UsageRecord {
|
|
|
13
13
|
totals: UsageTotals;
|
|
14
14
|
/* What the CLI itself says the call cost, which wins over a price from the table; absent where it says nothing. */
|
|
15
15
|
costUsd?: number;
|
|
16
|
+
serviceTier?: string;
|
|
16
17
|
/* What identifies the message this line reports on, or null when nothing does. */
|
|
17
18
|
dedupeKey: string | null;
|
|
18
19
|
}
|
package/src/usage/scanner.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { createHash, type Hash } from 'node:crypto';
|
|
2
2
|
import { mkdir, open, readFile, readdir, stat } from 'node:fs/promises';
|
|
3
3
|
import { join } from 'node:path';
|
|
4
|
+
import { zstdDecompressSync } from 'node:zlib';
|
|
4
5
|
import type { UsageProvider, UsageRoot } from '@adecore/agent-contracts';
|
|
5
6
|
import { errorText } from '../error-text.ts';
|
|
6
7
|
import { isNotFound, writeAtomic } from '../fs.ts';
|
|
@@ -13,6 +14,7 @@ import { cloneCodexState, codexMightCarryUsage, createCodexState, parseCodexLine
|
|
|
13
14
|
import { usageRoots, type UsageRootPath } from './roots.ts';
|
|
14
15
|
|
|
15
16
|
const HASH_CHUNK_BYTES = 65536;
|
|
17
|
+
const CODEX_PARSER_VERSION = 2;
|
|
16
18
|
|
|
17
19
|
interface HashedRead {
|
|
18
20
|
/* Everything from `from` to the end of the file, as it is at the read. */
|
|
@@ -82,7 +84,7 @@ function readsWhole(provider: UsageProvider): boolean {
|
|
|
82
84
|
}
|
|
83
85
|
|
|
84
86
|
function isUsageFile(provider: UsageProvider, name: string): boolean {
|
|
85
|
-
return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl');
|
|
87
|
+
return readsWhole(provider) ? name === GROK_USAGE_FILE : name.endsWith('.jsonl') || (provider === 'codex' && name.endsWith('.jsonl.zst'));
|
|
86
88
|
}
|
|
87
89
|
|
|
88
90
|
async function listUsageFiles(dir: string, provider: UsageProvider, out: string[] = []): Promise<string[]> {
|
|
@@ -255,7 +257,12 @@ export class UsageScanner {
|
|
|
255
257
|
}
|
|
256
258
|
let failure: string | null = null;
|
|
257
259
|
const accountOf = accountResolver(root, sessionAccounts);
|
|
258
|
-
|
|
260
|
+
const paths = await listUsageFiles(root.path, root.provider);
|
|
261
|
+
const plain = new Set(paths);
|
|
262
|
+
for (const path of paths) {
|
|
263
|
+
if (root.provider === 'codex' && path.endsWith('.jsonl.zst') && plain.has(path.slice(0, -4))) {
|
|
264
|
+
continue;
|
|
265
|
+
}
|
|
259
266
|
files += 1;
|
|
260
267
|
try {
|
|
261
268
|
if (await this.readFileInto(path, root.provider, accountOf)) {
|
|
@@ -288,15 +295,34 @@ export class UsageScanner {
|
|
|
288
295
|
|
|
289
296
|
private async readFileInto(path: string, provider: UsageProvider, accountOf: AccountOf): Promise<boolean> {
|
|
290
297
|
const info = await stat(path);
|
|
291
|
-
const
|
|
292
|
-
|
|
298
|
+
const compressed = provider === 'codex' && path.endsWith('.jsonl.zst');
|
|
299
|
+
const key = compressed ? path.slice(0, -4) : path;
|
|
300
|
+
const known = this.index.get(key);
|
|
301
|
+
const currentParser = provider !== 'codex' || known?.parserVersion === CODEX_PARSER_VERSION;
|
|
302
|
+
if (
|
|
303
|
+
currentParser &&
|
|
304
|
+
known !== undefined &&
|
|
305
|
+
known.provider === provider &&
|
|
306
|
+
(known.compressed ?? false) === compressed &&
|
|
307
|
+
known.size === info.size &&
|
|
308
|
+
known.mtimeMs === info.mtimeMs
|
|
309
|
+
) {
|
|
293
310
|
return false;
|
|
294
311
|
}
|
|
295
312
|
if (readsWhole(provider)) {
|
|
296
313
|
return this.readWholeInto(path, provider, accountOf, info);
|
|
297
314
|
}
|
|
298
|
-
const appended =
|
|
299
|
-
|
|
315
|
+
const appended =
|
|
316
|
+
!compressed &&
|
|
317
|
+
currentParser &&
|
|
318
|
+
known?.compressed !== true &&
|
|
319
|
+
known !== undefined &&
|
|
320
|
+
known.provider === provider &&
|
|
321
|
+
info.size > known.size &&
|
|
322
|
+
known.head?.startsWith('sha256:') === true;
|
|
323
|
+
let read = compressed
|
|
324
|
+
? { bytes: zstdDecompressSync(await readFile(path)), hash: createHash('sha256') }
|
|
325
|
+
: await readHashed(path, appended ? known.offset : 0);
|
|
300
326
|
// A file only counts as grown while it starts the way it did; one rewritten larger is read again from the start.
|
|
301
327
|
const grown = appended && headOf(read.hash) === known.head;
|
|
302
328
|
if (appended && !grown) {
|
|
@@ -315,9 +341,10 @@ export class UsageScanner {
|
|
|
315
341
|
records: foldByKey([...before, ...withAccount(parsed.records, accountOf)]),
|
|
316
342
|
tail: withAccount(parsed.tail, accountOf),
|
|
317
343
|
codex: parsed.codex,
|
|
318
|
-
head: headOf(read.hash)
|
|
344
|
+
head: headOf(read.hash),
|
|
345
|
+
...(provider === 'codex' ? { parserVersion: CODEX_PARSER_VERSION, compressed } : {})
|
|
319
346
|
};
|
|
320
|
-
this.index.set(
|
|
347
|
+
this.index.set(key, entry);
|
|
321
348
|
this.dirty = true;
|
|
322
349
|
return true;
|
|
323
350
|
}
|