@vimoxshah/tokenflow 1.1.0
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/CONTRIBUTING.md +84 -0
- package/LICENSE +21 -0
- package/README.md +250 -0
- package/Refresh & Open Dashboard.command +22 -0
- package/SECURITY.md +42 -0
- package/bin/tokenflow.js +1342 -0
- package/docs/architecture.md +193 -0
- package/docs/cli.md +390 -0
- package/docs/configuration.md +281 -0
- package/docs/creating-provider.md +262 -0
- package/docs/data-model.md +213 -0
- package/docs/getting-started.md +266 -0
- package/docs/live-mode.md +199 -0
- package/docs/media/architecture-hero.svg +86 -0
- package/docs/media/cost-editorial-dark.png +0 -0
- package/docs/media/health-terminal-light.png +0 -0
- package/docs/media/menubar-dark.png +0 -0
- package/docs/media/menubar-light.png +0 -0
- package/docs/media/models-terminal-dark.png +0 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/time-aurora-light.png +0 -0
- package/docs/providers.md +309 -0
- package/docs/skill.md +64 -0
- package/docs/troubleshooting.md +207 -0
- package/examples/config.example.yaml +92 -0
- package/examples/demo-data/README.md +38 -0
- package/examples/demo-data/sample-usage.csv +11 -0
- package/package.json +74 -0
- package/scripts/build-dmg.sh +33 -0
- package/scripts/build-menubar-app.sh +67 -0
- package/scripts/lint.js +111 -0
- package/scripts/validate-install.js +140 -0
- package/skills/tokenflow/SKILL.md +392 -0
- package/skills/tokenflow/examples/config.yaml +92 -0
- package/skills/tokenflow/examples/generic-mapping.json +26 -0
- package/skills/tokenflow/examples/session-transcript.md +191 -0
- package/skills/tokenflow/providers/adapter-template.js +135 -0
- package/skills/tokenflow/providers/detection-matrix.md +142 -0
- package/skills/tokenflow/schemas/config.schema.json +107 -0
- package/skills/tokenflow/schemas/normalized-record.json +63 -0
- package/src/analytics/aggregate.js +247 -0
- package/src/analytics/anomalies.js +222 -0
- package/src/analytics/capacity.js +278 -0
- package/src/analytics/comparison.js +96 -0
- package/src/analytics/dimensions.js +230 -0
- package/src/analytics/efficiency.js +138 -0
- package/src/analytics/forecast.js +202 -0
- package/src/analytics/index.js +327 -0
- package/src/analytics/insights.js +283 -0
- package/src/analytics/milestones.js +91 -0
- package/src/analytics/peak.js +106 -0
- package/src/analytics/productivity.js +166 -0
- package/src/analytics/token-usage.js +267 -0
- package/src/commands/diagnostics.js +88 -0
- package/src/commands/digest.js +155 -0
- package/src/commands/models-compare.js +96 -0
- package/src/core/budget.js +142 -0
- package/src/core/bundle.js +191 -0
- package/src/core/config.js +202 -0
- package/src/core/delivery.js +109 -0
- package/src/core/geo.js +99 -0
- package/src/core/ingest.js +457 -0
- package/src/core/interface-map.js +55 -0
- package/src/core/jsonl.js +124 -0
- package/src/core/live-status.js +417 -0
- package/src/core/model-map.js +157 -0
- package/src/core/notify.js +83 -0
- package/src/core/pricing.js +288 -0
- package/src/core/prompt-analytics.js +127 -0
- package/src/core/registry.js +107 -0
- package/src/core/restore.js +261 -0
- package/src/core/schedule.js +120 -0
- package/src/core/schema.js +316 -0
- package/src/core/sqlite.js +96 -0
- package/src/core/store.js +493 -0
- package/src/core/sync.js +151 -0
- package/src/core/units.js +147 -0
- package/src/core/validate.js +123 -0
- package/src/core/watch.js +287 -0
- package/src/core/yaml.js +209 -0
- package/src/export/bundler.js +107 -0
- package/src/export/csv.js +100 -0
- package/src/export/html-snapshot.js +101 -0
- package/src/export/menubar.js +158 -0
- package/src/index.js +18 -0
- package/src/providers/anthropic/index.js +294 -0
- package/src/providers/cline/index.js +120 -0
- package/src/providers/cursor/index.js +143 -0
- package/src/providers/generic/index.js +268 -0
- package/src/providers/git/index.js +188 -0
- package/src/providers/headroom/index.js +114 -0
- package/src/providers/hermes/index.js +299 -0
- package/src/providers/mock/index.js +117 -0
- package/src/providers/openai/index.js +370 -0
- package/src/providers/opencode/index.js +245 -0
- package/src/sdk.js +46 -0
- package/src/server/server.js +264 -0
- package/src/ui/app.js +2473 -0
- package/src/ui/charts.js +925 -0
- package/src/ui/index.html +42 -0
- package/src/ui/styles.css +644 -0
|
@@ -0,0 +1,370 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenAI — Codex CLI / Codex IDE / Codex Desktop rollout transcripts.
|
|
3
|
+
*
|
|
4
|
+
* Source: ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<id>.jsonl
|
|
5
|
+
* (+ ~/.codex/archived_sessions)
|
|
6
|
+
*
|
|
7
|
+
* ## Token semantics — the important difference from Anthropic
|
|
8
|
+
*
|
|
9
|
+
* Codex reports OpenAI's convention, where `input_tokens` is INCLUSIVE of
|
|
10
|
+
* `cached_input_tokens`. Copying it straight into the schema would count every
|
|
11
|
+
* cached prompt token twice: once as fresh input and once as cache read. So:
|
|
12
|
+
*
|
|
13
|
+
* input_tokens = input_tokens - cached_input_tokens (fresh only)
|
|
14
|
+
* cache_read_tokens = cached_input_tokens
|
|
15
|
+
* cache_write_tokens = cache_write_input_tokens, or **null** on older
|
|
16
|
+
* sessions that never emitted the field — not 0, because
|
|
17
|
+
* "the CLI didn't report it" and "no cache was written"
|
|
18
|
+
* are different facts
|
|
19
|
+
* reasoning_tokens = reasoning_output_tokens (a subset of output_tokens)
|
|
20
|
+
*
|
|
21
|
+
* ## One record per turn, and why the obvious reading is wrong
|
|
22
|
+
*
|
|
23
|
+
* Codex emits a `token_count` event repeatedly while a turn runs, and
|
|
24
|
+
* `last_token_usage` is **re-reported as the turn's context grows** — 27k, then
|
|
25
|
+
* 29k, then 30k, ... all describing the same growing conversation. Its
|
|
26
|
+
* `total_token_usage` is simply a running SUM of those re-reports, so it is not
|
|
27
|
+
* a usable cumulative counter: on this corpus it over-reports a heavy agent day
|
|
28
|
+
* by ~45x (82.8B claimed vs 1.8B real), and one 2.5-minute subagent session
|
|
29
|
+
* "spends" 7.5B tokens.
|
|
30
|
+
*
|
|
31
|
+
* The reliable structure is the same streaming-snapshot pattern Anthropic's
|
|
32
|
+
* transcripts have. Within a turn, the `last_token_usage` series is split into
|
|
33
|
+
* monotonically non-decreasing RUNS. A run is one growing context; a DROP means
|
|
34
|
+
* a new one (a context compaction, or a fresh call). The turn's usage is the
|
|
35
|
+
* sum of each run's maximum.
|
|
36
|
+
*
|
|
37
|
+
* That reduces to the identity for simple sessions (one event per turn -> the
|
|
38
|
+
* event itself) and was cross-checked against an independent gateway billing
|
|
39
|
+
* log, which agreed to within the same order of magnitude where the naive
|
|
40
|
+
* reading was off by 45x.
|
|
41
|
+
*
|
|
42
|
+
* `metadata.token_count_events` and `metadata.usage_segments` are recorded on
|
|
43
|
+
* every turn so this reconstruction is auditable rather than a hidden fudge.
|
|
44
|
+
*
|
|
45
|
+
* A `model_provider` that is not a known model vendor is recorded as a
|
|
46
|
+
* **gateway** (a proxy/router) rather than as the vendor, so "who served this"
|
|
47
|
+
* and "who made this model" stay separate dimensions.
|
|
48
|
+
*/
|
|
49
|
+
import fs from 'node:fs';
|
|
50
|
+
import path from 'node:path';
|
|
51
|
+
import os from 'node:os';
|
|
52
|
+
import { createProvider } from '../../core/registry.js';
|
|
53
|
+
import { readLines } from '../../core/jsonl.js';
|
|
54
|
+
import { walk } from '../../core/ingest.js';
|
|
55
|
+
import { MEASUREMENT } from '../../core/schema.js';
|
|
56
|
+
|
|
57
|
+
const MARKS = ['"token_count"', '"session_meta"', '"thread_settings_applied"', '"turn_context"', '"task_started"', '"task_complete"'];
|
|
58
|
+
const VENDORS = new Set(['openai', 'anthropic', 'google', 'gemini', 'deepseek', 'zai', 'z.ai', 'xai', 'mistral', 'azure', 'bedrock', 'vertex', 'ollama']);
|
|
59
|
+
|
|
60
|
+
const USAGE_FIELDS = ['input_tokens', 'cached_input_tokens', 'cache_write_input_tokens', 'output_tokens', 'reasoning_output_tokens'];
|
|
61
|
+
|
|
62
|
+
export function codexHomes(ctx) {
|
|
63
|
+
const configured = ctx?.config?.sources?.openai?.paths;
|
|
64
|
+
if (Array.isArray(configured) && configured.length) return configured.map(expand);
|
|
65
|
+
const home = ctx?.home || os.homedir();
|
|
66
|
+
const out = [];
|
|
67
|
+
if (process.env.CODEX_HOME) out.push(process.env.CODEX_HOME);
|
|
68
|
+
out.push(path.join(home, '.codex'));
|
|
69
|
+
return [...new Set(out)].filter((d) => {
|
|
70
|
+
try {
|
|
71
|
+
return fs.statSync(path.join(d, 'sessions')).isDirectory();
|
|
72
|
+
} catch {
|
|
73
|
+
return false;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function expand(p) {
|
|
79
|
+
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Accumulate a turn's `last_token_usage` series into disjoint totals.
|
|
84
|
+
*
|
|
85
|
+
* Fields are tracked independently but segmented by `total_tokens`, because
|
|
86
|
+
* that is the value whose monotonicity tells us whether we are still looking at
|
|
87
|
+
* the same growing context.
|
|
88
|
+
*
|
|
89
|
+
* A field that never appears in ANY event stays `null` — "the CLI didn't report
|
|
90
|
+
* cache writes" is a different fact from "no cache was written".
|
|
91
|
+
*/
|
|
92
|
+
export class TurnUsage {
|
|
93
|
+
constructor() {
|
|
94
|
+
this.acc = Object.fromEntries(USAGE_FIELDS.map((f) => [f, 0]));
|
|
95
|
+
this.seen = Object.fromEntries(USAGE_FIELDS.map((f) => [f, false]));
|
|
96
|
+
this.run = null;
|
|
97
|
+
this.prevTotal = -1;
|
|
98
|
+
this.events = 0;
|
|
99
|
+
this.segments = 0;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
add(last) {
|
|
103
|
+
if (!last) return;
|
|
104
|
+
const total = num(last.total_tokens);
|
|
105
|
+
this.events++;
|
|
106
|
+
if (this.run === null || total >= this.prevTotal) {
|
|
107
|
+
if (this.run === null) {
|
|
108
|
+
this.run = Object.fromEntries(USAGE_FIELDS.map((f) => [f, 0]));
|
|
109
|
+
this.segments++;
|
|
110
|
+
}
|
|
111
|
+
for (const f of USAGE_FIELDS) {
|
|
112
|
+
const v = last[f];
|
|
113
|
+
if (v === undefined || v === null) continue;
|
|
114
|
+
this.seen[f] = true;
|
|
115
|
+
this.run[f] = Math.max(this.run[f], Number(v));
|
|
116
|
+
}
|
|
117
|
+
} else {
|
|
118
|
+
// The context shrank: the previous run is finished.
|
|
119
|
+
this.closeRun();
|
|
120
|
+
this.segments++;
|
|
121
|
+
this.run = Object.fromEntries(USAGE_FIELDS.map((f) => [f, 0]));
|
|
122
|
+
for (const f of USAGE_FIELDS) {
|
|
123
|
+
const v = last[f];
|
|
124
|
+
if (v === undefined || v === null) continue;
|
|
125
|
+
this.seen[f] = true;
|
|
126
|
+
this.run[f] = Number(v);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
this.prevTotal = total;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
closeRun() {
|
|
133
|
+
if (!this.run) return;
|
|
134
|
+
for (const f of USAGE_FIELDS) this.acc[f] += this.run[f];
|
|
135
|
+
this.run = null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** @returns {Record<string, number|null>} */
|
|
139
|
+
finish() {
|
|
140
|
+
this.closeRun();
|
|
141
|
+
/** @type {Record<string, number|null>} */
|
|
142
|
+
const out = {};
|
|
143
|
+
for (const f of USAGE_FIELDS) out[f] = this.seen[f] ? this.acc[f] : null;
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
toJSON() {
|
|
148
|
+
return { acc: this.acc, seen: this.seen, run: this.run, prevTotal: this.prevTotal, events: this.events, segments: this.segments };
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
static from(o) {
|
|
152
|
+
const t = new TurnUsage();
|
|
153
|
+
if (!o) return t;
|
|
154
|
+
Object.assign(t, { acc: o.acc || t.acc, seen: o.seen || t.seen, run: o.run ?? null, prevTotal: o.prevTotal ?? -1, events: o.events || 0, segments: o.segments || 0 });
|
|
155
|
+
return t;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export default createProvider({
|
|
160
|
+
id: 'openai',
|
|
161
|
+
name: 'OpenAI (Codex CLI / IDE / Desktop)',
|
|
162
|
+
description: 'Per-turn token usage from Codex rollout transcripts, including gateway-routed models.',
|
|
163
|
+
measurement: MEASUREMENT.PRIMARY,
|
|
164
|
+
requires: ['~/.codex/sessions (Codex CLI 0.1x+)'],
|
|
165
|
+
|
|
166
|
+
async detect(ctx) {
|
|
167
|
+
const homes = codexHomes(ctx);
|
|
168
|
+
if (!homes.length) return { available: false, detail: 'No ~/.codex/sessions directory found' };
|
|
169
|
+
return { available: true, detail: homes.join(', '), paths: homes };
|
|
170
|
+
},
|
|
171
|
+
|
|
172
|
+
async discover(ctx) {
|
|
173
|
+
const files = [];
|
|
174
|
+
for (const home of codexHomes(ctx)) {
|
|
175
|
+
for (const sub of ['sessions', 'archived_sessions']) {
|
|
176
|
+
const root = path.join(home, sub);
|
|
177
|
+
if (!fs.existsSync(root)) continue;
|
|
178
|
+
for (const f of walk(root, (n) => n.endsWith('.jsonl'))) {
|
|
179
|
+
let stat;
|
|
180
|
+
try { stat = fs.statSync(f); } catch { continue; }
|
|
181
|
+
if (!stat.size) continue;
|
|
182
|
+
files.push({ key: `${sub}:${path.relative(root, f)}`, path: f, stat });
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return files;
|
|
187
|
+
},
|
|
188
|
+
|
|
189
|
+
async ingestFile(ref, ctx, emit) {
|
|
190
|
+
const s = ref.state;
|
|
191
|
+
let records = 0;
|
|
192
|
+
let malformed = 0;
|
|
193
|
+
|
|
194
|
+
/** The turn currently being accumulated. */
|
|
195
|
+
let turn = s.openTurn
|
|
196
|
+
? { id: s.openTurn.id, firstTs: s.openTurn.firstTs, lastTs: s.openTurn.lastTs, usage: TurnUsage.from(s.openTurn.usage), continued: true }
|
|
197
|
+
: null;
|
|
198
|
+
|
|
199
|
+
const newTurn = (id, ts) => ({ id: id || null, firstTs: ts, lastTs: ts, usage: new TurnUsage(), continued: false });
|
|
200
|
+
|
|
201
|
+
const flushTurn = () => {
|
|
202
|
+
if (!turn || !turn.usage.events) { turn = null; return; }
|
|
203
|
+
const u = turn.usage.finish();
|
|
204
|
+
const routeProvider = (s.routeProvider || '').toLowerCase();
|
|
205
|
+
const isVendor = VENDORS.has(routeProvider);
|
|
206
|
+
const project = s.cwd ? path.basename(s.cwd) : null;
|
|
207
|
+
const inTotal = u.input_tokens;
|
|
208
|
+
const cached = u.cached_input_tokens;
|
|
209
|
+
// OpenAI's input_tokens INCLUDES cached_input_tokens; the schema wants
|
|
210
|
+
// them mutually exclusive.
|
|
211
|
+
const fresh = inTotal === null ? null : cached === null ? inTotal : Math.max(0, inTotal - cached);
|
|
212
|
+
if (!fresh && !cached && !u.output_tokens && !u.cache_write_input_tokens) { turn = null; return; }
|
|
213
|
+
emit({
|
|
214
|
+
timestamp: turn.lastTs,
|
|
215
|
+
model: s.model || null,
|
|
216
|
+
providerHint: isVendor ? routeProvider : null,
|
|
217
|
+
gateway: routeProvider && !isVendor ? routeProvider : null,
|
|
218
|
+
input_tokens: fresh,
|
|
219
|
+
cache_read_tokens: cached,
|
|
220
|
+
cache_write_tokens: u.cache_write_input_tokens,
|
|
221
|
+
output_tokens: u.output_tokens,
|
|
222
|
+
reasoning_tokens: u.reasoning_output_tokens,
|
|
223
|
+
cache_refresh_tokens: null,
|
|
224
|
+
session_id: s.sessionId || ref.key,
|
|
225
|
+
conversation_id: s.threadId || null,
|
|
226
|
+
request_id: turn.id,
|
|
227
|
+
project,
|
|
228
|
+
repository: project,
|
|
229
|
+
category: s.threadSource === 'subagent' ? 'subagent' : 'main',
|
|
230
|
+
client: 'codex',
|
|
231
|
+
application: 'Codex',
|
|
232
|
+
interfaceSignals: [s.source, s.originator, s.threadSource],
|
|
233
|
+
duration_ms: s.lastDuration ?? null,
|
|
234
|
+
metadata: {
|
|
235
|
+
cwd: s.cwd || null,
|
|
236
|
+
cli_version: s.cliVersion || null,
|
|
237
|
+
originator: s.originator || null,
|
|
238
|
+
source: s.source || null,
|
|
239
|
+
agent_role: s.agentRole || null,
|
|
240
|
+
reasoning_effort: s.effort || null,
|
|
241
|
+
service_tier: s.tier || null,
|
|
242
|
+
context_window: s.contextWindow ?? null,
|
|
243
|
+
time_to_first_token_ms: s.lastTtft ?? null,
|
|
244
|
+
plan_type: s.planType ?? null,
|
|
245
|
+
token_count_events: turn.usage.events,
|
|
246
|
+
usage_segments: turn.usage.segments,
|
|
247
|
+
turn_continued: turn.continued || undefined,
|
|
248
|
+
},
|
|
249
|
+
});
|
|
250
|
+
records++;
|
|
251
|
+
s.lastDuration = null;
|
|
252
|
+
s.lastTtft = null;
|
|
253
|
+
turn = null;
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const res = readLines(
|
|
257
|
+
ref.path,
|
|
258
|
+
(line) => {
|
|
259
|
+
let d;
|
|
260
|
+
try {
|
|
261
|
+
d = JSON.parse(line);
|
|
262
|
+
} catch {
|
|
263
|
+
malformed++;
|
|
264
|
+
return;
|
|
265
|
+
}
|
|
266
|
+
const p = d.payload || {};
|
|
267
|
+
|
|
268
|
+
if (d.type === 'session_meta') {
|
|
269
|
+
s.sessionId = p.session_id || p.id || null;
|
|
270
|
+
s.threadId = p.id || null;
|
|
271
|
+
s.cwd = p.cwd || s.cwd || null;
|
|
272
|
+
s.originator = p.originator || s.originator || null;
|
|
273
|
+
s.source = flattenSource(p.source) || s.source || null;
|
|
274
|
+
s.agentRole = agentRole(p.source) || s.agentRole || null;
|
|
275
|
+
s.threadSource = p.thread_source || s.threadSource || null;
|
|
276
|
+
s.cliVersion = p.cli_version || s.cliVersion || null;
|
|
277
|
+
s.routeProvider = p.model_provider || s.routeProvider || null;
|
|
278
|
+
if (p.model) s.model = p.model;
|
|
279
|
+
return;
|
|
280
|
+
}
|
|
281
|
+
if (d.type === 'turn_context') {
|
|
282
|
+
if (p.cwd) s.cwd = p.cwd;
|
|
283
|
+
if (p.model) s.model = p.model;
|
|
284
|
+
if (p.turn_id && (!turn || turn.id !== p.turn_id)) {
|
|
285
|
+
flushTurn();
|
|
286
|
+
turn = newTurn(p.turn_id, d.timestamp);
|
|
287
|
+
}
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
if (d.type === 'event_msg' && p.type === 'thread_settings_applied') {
|
|
291
|
+
const t = p.thread_settings || {};
|
|
292
|
+
if (t.model) s.model = t.model;
|
|
293
|
+
if (t.model_provider_id) s.routeProvider = t.model_provider_id;
|
|
294
|
+
if (t.cwd) s.cwd = t.cwd;
|
|
295
|
+
if (t.reasoning_effort) s.effort = t.reasoning_effort;
|
|
296
|
+
if (t.service_tier) s.tier = t.service_tier;
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (d.type === 'event_msg' && p.type === 'task_started') {
|
|
300
|
+
if (!turn || turn.id !== p.turn_id) {
|
|
301
|
+
flushTurn();
|
|
302
|
+
turn = newTurn(p.turn_id, d.timestamp);
|
|
303
|
+
}
|
|
304
|
+
if (p.model_context_window) s.contextWindow = p.model_context_window;
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
if (d.type === 'event_msg' && p.type === 'task_complete') {
|
|
308
|
+
s.lastDuration = p.duration_ms ?? null;
|
|
309
|
+
s.lastTtft = p.time_to_first_token_ms ?? null;
|
|
310
|
+
if (turn && (turn.id === null || !p.turn_id || turn.id === p.turn_id)) flushTurn();
|
|
311
|
+
return;
|
|
312
|
+
}
|
|
313
|
+
if (!(d.type === 'event_msg' && p.type === 'token_count')) return;
|
|
314
|
+
|
|
315
|
+
const info = p.info || {};
|
|
316
|
+
const last = info.last_token_usage || info.total_token_usage;
|
|
317
|
+
if (!last) return;
|
|
318
|
+
if (info.model_context_window) s.contextWindow = info.model_context_window;
|
|
319
|
+
if (p.rate_limits?.plan_type) s.planType = p.rate_limits.plan_type;
|
|
320
|
+
|
|
321
|
+
// Sessions from older builds have no turn markers; treat each event as
|
|
322
|
+
// its own turn rather than dropping the usage.
|
|
323
|
+
if (!turn) turn = newTurn(null, d.timestamp);
|
|
324
|
+
turn.usage.add(last);
|
|
325
|
+
turn.lastTs = d.timestamp;
|
|
326
|
+
if (turn.id === null) flushTurn();
|
|
327
|
+
},
|
|
328
|
+
{ start: ref.start, must: MARKS },
|
|
329
|
+
);
|
|
330
|
+
|
|
331
|
+
// A turn still open at EOF keeps accumulating on the next refresh, so no
|
|
332
|
+
// partial record is written for it and none is written twice.
|
|
333
|
+
s.openTurn = turn && turn.usage.events
|
|
334
|
+
? { id: turn.id, firstTs: turn.firstTs, lastTs: turn.lastTs, usage: turn.usage.toJSON() }
|
|
335
|
+
: null;
|
|
336
|
+
if (turn && !turn.id) flushTurn();
|
|
337
|
+
|
|
338
|
+
return { offset: res.offset, records, malformed, openTurn: s.openTurn ? 1 : 0 };
|
|
339
|
+
},
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
function flattenSource(src) {
|
|
343
|
+
if (!src) return null;
|
|
344
|
+
if (typeof src === 'string') return src;
|
|
345
|
+
if (typeof src === 'object') {
|
|
346
|
+
const k = Object.keys(src)[0];
|
|
347
|
+
if (!k) return null;
|
|
348
|
+
const v = src[k];
|
|
349
|
+
if (typeof v === 'string') return `${k}:${v}`;
|
|
350
|
+
if (v && typeof v === 'object') {
|
|
351
|
+
const inner = Object.values(v)[0];
|
|
352
|
+
return typeof inner === 'string' ? `${k}:${inner}` : k;
|
|
353
|
+
}
|
|
354
|
+
return k;
|
|
355
|
+
}
|
|
356
|
+
return null;
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
/** "luna_worker" from a subagent thread_spawn descriptor, when present. */
|
|
360
|
+
function agentRole(src) {
|
|
361
|
+
try {
|
|
362
|
+
return src?.subagent?.thread_spawn?.agent_role ?? src?.subagent?.other ?? null;
|
|
363
|
+
} catch {
|
|
364
|
+
return null;
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
function num(v) {
|
|
369
|
+
return v === undefined || v === null ? 0 : Number(v);
|
|
370
|
+
}
|
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode — opencode session/message database.
|
|
3
|
+
*
|
|
4
|
+
* Source: <data-home>/opencode/opencode.db (SQLite; $XDG_DATA_HOME-aware,
|
|
5
|
+
* so ~/.local/share/opencode/opencode.db by default). Read-only, via
|
|
6
|
+
* node:sqlite, read from a snapshot like every SQLite adapter here.
|
|
7
|
+
*
|
|
8
|
+
* ## What this source is
|
|
9
|
+
*
|
|
10
|
+
* One row per message in `message`, with a JSON `data` column. Assistant rows
|
|
11
|
+
* carry the usage block the provider returned:
|
|
12
|
+
*
|
|
13
|
+
* data.tokens = { input, output, reasoning, cache: { read, write }, total }
|
|
14
|
+
* data.modelID / data.providerID / data.cost / data.time.{created,completed}
|
|
15
|
+
*
|
|
16
|
+
* The `session` table adds directory, agent and parent (subagent) links; the
|
|
17
|
+
* `project` table adds the worktree.
|
|
18
|
+
*
|
|
19
|
+
* ## Token semantics (verified against a live corpus)
|
|
20
|
+
*
|
|
21
|
+
* input fresh prompt tokens, EXCLUSIVE of cache reads. Confirmed
|
|
22
|
+
* empirically: cache_read regularly exceeds input on long
|
|
23
|
+
* sessions, which is impossible under an inclusive convention,
|
|
24
|
+
* and data.tokens.total == input+output+reasoning+read+write
|
|
25
|
+
* exactly, i.e. the fields are disjoint addends.
|
|
26
|
+
* output generated tokens. `reasoning` is USUALLY a subset of output
|
|
27
|
+
* (OpenAI/Anthropic convention) but some providers behind the
|
|
28
|
+
* gateway report it additively — 5% of a real corpus had
|
|
29
|
+
* reasoning > output. Where that happens the only reading that
|
|
30
|
+
* satisfies the schema's subset invariant without losing tokens
|
|
31
|
+
* is output += reasoning, with reasoning kept as the subset.
|
|
32
|
+
* cache.read -> cache_read_tokens
|
|
33
|
+
* cache.write -> cache_write_tokens
|
|
34
|
+
*
|
|
35
|
+
* ## The incremental trap: rows are UPDATED in place
|
|
36
|
+
*
|
|
37
|
+
* An assistant row is inserted when the request starts and its tokens are
|
|
38
|
+
* revised as the response finalises — on a real corpus every single
|
|
39
|
+
* token-bearing row had time_updated != time_created. A naive
|
|
40
|
+
* "time_created > cursor" cursor would freeze the first, partial snapshot
|
|
41
|
+
* forever. So this adapter uses the same tails mechanism as the Anthropic
|
|
42
|
+
* adapter: re-read a recent window ordered by time_updated, remember per
|
|
43
|
+
* message what has already been emitted, and emit only the DELTA when a
|
|
44
|
+
* message's totals grew. Each emission carries the message's own
|
|
45
|
+
* time.created, so usage stays attributed to the request that produced it.
|
|
46
|
+
*/
|
|
47
|
+
import fs from 'node:fs';
|
|
48
|
+
import path from 'node:path';
|
|
49
|
+
import os from 'node:os';
|
|
50
|
+
import { createProvider } from '../../core/registry.js';
|
|
51
|
+
import { MEASUREMENT, hashId } from '../../core/schema.js';
|
|
52
|
+
import { openReadOnly, sqliteAvailable } from '../../core/sqlite.js';
|
|
53
|
+
|
|
54
|
+
const ROW_CAP = 500000;
|
|
55
|
+
/** How far behind the watermark to re-read, to catch in-place updates. */
|
|
56
|
+
const OVERLAP_MS = 7 * 24 * 60 * 60 * 1000;
|
|
57
|
+
/** Emitted-tail entries older than this, relative to the watermark, are pruned. */
|
|
58
|
+
const TAIL_TTL_MS = 30 * 24 * 60 * 60 * 1000;
|
|
59
|
+
|
|
60
|
+
const FIELDS = ['input_tokens', 'output_tokens', 'cache_read_tokens', 'cache_write_tokens', 'reasoning_tokens'];
|
|
61
|
+
|
|
62
|
+
/** providerID values that ARE the model vendor; anything else routes through a gateway. */
|
|
63
|
+
const VENDORS = new Set([
|
|
64
|
+
'anthropic', 'openai', 'google', 'gemini', 'deepseek', 'xai', 'groq',
|
|
65
|
+
'mistral', 'azure', 'bedrock', 'vertex', 'ollama', 'amazon', 'moonshot',
|
|
66
|
+
'alibaba', 'qwen', 'zai', 'github-copilot',
|
|
67
|
+
]);
|
|
68
|
+
|
|
69
|
+
export function dbPath(ctx) {
|
|
70
|
+
const c = ctx?.config?.sources?.opencode;
|
|
71
|
+
const p = c?.db || (Array.isArray(c?.paths) ? c.paths[0] : null);
|
|
72
|
+
if (p) return expand(p);
|
|
73
|
+
const dataHome = process.env.XDG_DATA_HOME || path.join(ctx?.home || os.homedir(), '.local', 'share');
|
|
74
|
+
return path.join(dataHome, 'opencode', 'opencode.db');
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function expand(p) {
|
|
78
|
+
return p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export default createProvider({
|
|
82
|
+
id: 'opencode',
|
|
83
|
+
name: 'OpenCode',
|
|
84
|
+
description: 'Per-request token usage from the opencode message database.',
|
|
85
|
+
measurement: MEASUREMENT.PRIMARY,
|
|
86
|
+
requires: ['~/.local/share/opencode/opencode.db', 'Node 22.5+ (node:sqlite)'],
|
|
87
|
+
|
|
88
|
+
async detect(ctx) {
|
|
89
|
+
if (!sqliteAvailable()) return { available: false, detail: 'node:sqlite unavailable — needs Node 22.5+' };
|
|
90
|
+
const f = dbPath(ctx);
|
|
91
|
+
if (!fs.existsSync(f)) {
|
|
92
|
+
return { available: false, detail: 'No opencode.db found (looked in $XDG_DATA_HOME/opencode)' };
|
|
93
|
+
}
|
|
94
|
+
return { available: true, detail: shortPath(f, ctx), paths: [f] };
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
async fetchUsage(ctx, emit, sourceState) {
|
|
98
|
+
const f = dbPath(ctx);
|
|
99
|
+
const db = openReadOnly(f);
|
|
100
|
+
let records = 0;
|
|
101
|
+
let skipped = 0;
|
|
102
|
+
let updated = 0;
|
|
103
|
+
const notes = [];
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
const cursor = sourceState.cursor || { updated: 0 };
|
|
107
|
+
// Tails: per-message totals already emitted. Keyed by message id, they
|
|
108
|
+
// make re-reading the overlap window idempotent (delta 0 => no record).
|
|
109
|
+
const tails = sourceState.tails || (sourceState.tails = {});
|
|
110
|
+
|
|
111
|
+
const rows = db.prepare(
|
|
112
|
+
`SELECT m.id AS msg_id, m.session_id AS session_id, m.time_created AS time_created,
|
|
113
|
+
m.time_updated AS time_updated, m.data AS data,
|
|
114
|
+
s.directory AS directory, s.parent_id AS parent_id, s.agent AS agent,
|
|
115
|
+
s.version AS cli_version, p.worktree AS worktree
|
|
116
|
+
FROM message m
|
|
117
|
+
LEFT JOIN session s ON s.id = m.session_id
|
|
118
|
+
LEFT JOIN project p ON p.id = s.project_id
|
|
119
|
+
WHERE json_extract(m.data,'$.role') = 'assistant'
|
|
120
|
+
AND json_extract(m.data,'$.tokens') IS NOT NULL
|
|
121
|
+
AND m.time_updated > ?
|
|
122
|
+
ORDER BY m.time_updated ASC
|
|
123
|
+
LIMIT ?`,
|
|
124
|
+
).all(Math.max(0, (cursor.updated || 0) - OVERLAP_MS), ROW_CAP);
|
|
125
|
+
if (rows.length === ROW_CAP) notes.push('row cap hit — run refresh again to continue');
|
|
126
|
+
|
|
127
|
+
let watermark = cursor.updated || 0;
|
|
128
|
+
|
|
129
|
+
for (const r of rows) {
|
|
130
|
+
watermark = Math.max(watermark, Number(r.time_updated) || 0);
|
|
131
|
+
let d;
|
|
132
|
+
try { d = JSON.parse(r.data); } catch { continue; }
|
|
133
|
+
const t = d.tokens || {};
|
|
134
|
+
const created = Number(r.time_created);
|
|
135
|
+
if (!created) continue;
|
|
136
|
+
|
|
137
|
+
const base = {
|
|
138
|
+
input_tokens: n(t.input),
|
|
139
|
+
output_tokens: n(t.output),
|
|
140
|
+
cache_read_tokens: n(t.cache?.read),
|
|
141
|
+
cache_write_tokens: n(t.cache?.write),
|
|
142
|
+
reasoning_tokens: n(t.reasoning),
|
|
143
|
+
};
|
|
144
|
+
// Some providers report reasoning additively rather than as a subset
|
|
145
|
+
// of output. Folding keeps the subset invariant without losing tokens.
|
|
146
|
+
if (base.reasoning_tokens !== null && base.output_tokens !== null && base.reasoning_tokens > base.output_tokens) {
|
|
147
|
+
base.output_tokens += base.reasoning_tokens;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// An assistant row with no counts at all is an aborted request, not a
|
|
151
|
+
// zero-cost API call. Skip it; if tokens arrive later the row's
|
|
152
|
+
// time_updated advances and the next pass picks it up fresh.
|
|
153
|
+
if (FIELDS.every((k) => !base[k])) { skipped++; continue; }
|
|
154
|
+
|
|
155
|
+
const key = String(r.msg_id || `${r.session_id}:${created}`);
|
|
156
|
+
const prev = tails[key];
|
|
157
|
+
const delta = {};
|
|
158
|
+
let any = false;
|
|
159
|
+
for (const k of FIELDS) {
|
|
160
|
+
const was = prev ? (prev.f[k] ?? 0) : 0;
|
|
161
|
+
const now = base[k] ?? 0;
|
|
162
|
+
delta[k] = Math.max(0, now - was);
|
|
163
|
+
if (delta[k] > 0) any = true;
|
|
164
|
+
}
|
|
165
|
+
// Remember what this message accounts for, whether or not it grew —
|
|
166
|
+
// an unchanged re-read must stay a no-op forever. The emission index
|
|
167
|
+
// keeps each delta's record id distinct and stable across runs.
|
|
168
|
+
const emissionIndex = prev ? (prev.n || 0) : 0;
|
|
169
|
+
tails[key] = { u: Number(r.time_updated) || created, f: { ...base }, n: emissionIndex + 1 };
|
|
170
|
+
if (prev && !any) continue;
|
|
171
|
+
updated++;
|
|
172
|
+
|
|
173
|
+
const providerID = str(d.providerID)?.toLowerCase() || null;
|
|
174
|
+
const isVendor = providerID && VENDORS.has(providerID);
|
|
175
|
+
const project = projectOf(r.worktree || r.directory);
|
|
176
|
+
const completed = n(d.time?.completed);
|
|
177
|
+
|
|
178
|
+
emit({
|
|
179
|
+
id: hashId('opencode', key, '#', emissionIndex),
|
|
180
|
+
timestamp: new Date(created).toISOString(),
|
|
181
|
+
model: str(d.modelID),
|
|
182
|
+
gateway: providerID && !isVendor ? providerID : null,
|
|
183
|
+
...pick(delta),
|
|
184
|
+
session_id: r.session_id || null,
|
|
185
|
+
conversation_id: r.session_id || null,
|
|
186
|
+
request_id: r.msg_id || null,
|
|
187
|
+
project,
|
|
188
|
+
repository: project,
|
|
189
|
+
category: r.parent_id ? 'subagent' : (str(r.agent) || 'main'),
|
|
190
|
+
client: 'opencode',
|
|
191
|
+
application: 'OpenCode',
|
|
192
|
+
interfaceSignals: [],
|
|
193
|
+
duration_ms: completed !== null && completed >= created ? completed - created : null,
|
|
194
|
+
metadata: {
|
|
195
|
+
cwd: str(r.directory),
|
|
196
|
+
provider_id: providerID,
|
|
197
|
+
mode: str(d.mode),
|
|
198
|
+
agent: str(r.agent),
|
|
199
|
+
finish: str(d.finish),
|
|
200
|
+
cli_version: str(r.cli_version),
|
|
201
|
+
reported_total: n(t.total),
|
|
202
|
+
...(prev ? { continuation_of: key } : {}),
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
records++;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Bound the tail state: anything this far behind the watermark cannot
|
|
209
|
+
// reappear inside the overlap window, so its emitted totals are final.
|
|
210
|
+
const floor = watermark - TAIL_TTL_MS;
|
|
211
|
+
for (const k of Object.keys(tails)) if ((tails[k].u || 0) < floor) delete tails[k];
|
|
212
|
+
|
|
213
|
+
cursor.updated = watermark;
|
|
214
|
+
sourceState.cursor = cursor;
|
|
215
|
+
} finally {
|
|
216
|
+
db.close();
|
|
217
|
+
}
|
|
218
|
+
return { records, cursor: sourceState.cursor, notes };
|
|
219
|
+
},
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
function pick(delta) {
|
|
223
|
+
const out = {};
|
|
224
|
+
for (const k of FIELDS) out[k] = delta[k];
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function projectOf(dir) {
|
|
229
|
+
if (!dir) return null;
|
|
230
|
+
const parts = String(dir).split('/').filter(Boolean);
|
|
231
|
+
return parts.length ? parts[parts.length - 1] : null;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function shortPath(p, ctx) {
|
|
235
|
+
const home = ctx?.home || os.homedir();
|
|
236
|
+
return p.startsWith(home) ? '~' + p.slice(home.length) : p;
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function n(v) {
|
|
240
|
+
return v === undefined || v === null ? null : Number(v);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function str(v) {
|
|
244
|
+
return v === undefined || v === null || v === '' ? null : String(v);
|
|
245
|
+
}
|
package/src/sdk.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider SDK — the minimum surface a community adapter needs.
|
|
3
|
+
*
|
|
4
|
+
* import { createProvider, registerProvider, normalizeUsage, validateUsage }
|
|
5
|
+
* from 'tokenflow/sdk';
|
|
6
|
+
*
|
|
7
|
+
* See docs/creating-provider.md for the full contract.
|
|
8
|
+
*/
|
|
9
|
+
export { createProvider, registerProvider, getProvider, listProviders } from './core/registry.js';
|
|
10
|
+
export { validateUsage, validateProvider } from './core/validate.js';
|
|
11
|
+
export {
|
|
12
|
+
createRecord, computeTotal, dateParts, hashId,
|
|
13
|
+
MEASUREMENT, INTERFACE, INTERFACE_ORDER, interfaceClass,
|
|
14
|
+
BILLABLE_TOKEN_FIELDS, BREAKDOWN_TOKEN_FIELDS, TOKEN_FIELDS,
|
|
15
|
+
} from './core/schema.js';
|
|
16
|
+
export { classifyModel, BUILTIN_MODEL_RULES } from './core/model-map.js';
|
|
17
|
+
export { classifyInterface } from './core/interface-map.js';
|
|
18
|
+
export { readLines, readJsonLines } from './core/jsonl.js';
|
|
19
|
+
export { walk, enrich } from './core/ingest.js';
|
|
20
|
+
export { openReadOnly, tables, columns, sqliteAvailable } from './core/sqlite.js';
|
|
21
|
+
export { registerAnalytics, listAnalytics } from './analytics/index.js';
|
|
22
|
+
import { enrich } from './core/ingest.js';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Normalize a partial record without running a full ingest — handy in adapter
|
|
26
|
+
* unit tests and in `--dry-run` importers.
|
|
27
|
+
* @param {object} partial
|
|
28
|
+
* @param {{provider:{id:string,name:string,measurement?:string}, tz?:string|null, priceBook?:object,
|
|
29
|
+
* rules?:object[], config?:object, user?:string|null, machine?:string|null,
|
|
30
|
+
* seq?:number, fileRef?:object|null}} opt
|
|
31
|
+
*/
|
|
32
|
+
export function normalizeUsage(partial, opt) {
|
|
33
|
+
return enrich(partial, {
|
|
34
|
+
ctx: {
|
|
35
|
+
tz: opt.tz ?? null,
|
|
36
|
+
priceBook: opt.priceBook ?? { lookup: () => null },
|
|
37
|
+
rules: opt.rules,
|
|
38
|
+
config: opt.config ?? {},
|
|
39
|
+
user: opt.user ?? null,
|
|
40
|
+
machine: opt.machine ?? null,
|
|
41
|
+
},
|
|
42
|
+
provider: opt.provider,
|
|
43
|
+
seq: opt.seq ?? 0,
|
|
44
|
+
fileRef: opt.fileRef ?? null,
|
|
45
|
+
});
|
|
46
|
+
}
|