@ramarivera/coding-agent-langfuse 0.1.41 → 0.1.43
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/README.md +47 -4
- package/dist/backfill.d.ts +17 -3
- package/dist/backfill.js +261 -13
- package/dist/service.d.ts +2 -0
- package/dist/service.js +18 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,9 +3,10 @@
|
|
|
3
3
|
Universal coding-agent Langfuse backfiller and OTLP exporter helpers.
|
|
4
4
|
|
|
5
5
|
It imports local histories from Codex, Claude Code, Grok, OpenCode, and Pi into
|
|
6
|
-
Langfuse as session traces with child observations. LLM
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
Langfuse as session traces with child observations. LLM generations include
|
|
7
|
+
Langfuse canonical `usage_details` and `cost_details` attributes so historical
|
|
8
|
+
backfills participate in Langfuse model-usage and cost dashboards. Tool calls
|
|
9
|
+
remain child spans under the same session.
|
|
9
10
|
|
|
10
11
|
```sh
|
|
11
12
|
coding-agent-langfuse-backfill --agents codex,claude,grok,pi,opencode
|
|
@@ -35,6 +36,48 @@ npx @ramarivera/coding-agent-langfuse@latest \
|
|
|
35
36
|
The importer is fail-fast: the first failed OTLP POST stops the run, prints the
|
|
36
37
|
real network cause, and preserves local state so reruns resume cleanly.
|
|
37
38
|
|
|
39
|
+
## Cost calculation
|
|
40
|
+
|
|
41
|
+
Backfill cost calculation follows Langfuse's OpenTelemetry mapping: generation
|
|
42
|
+
spans receive `langfuse.observation.usage_details` and
|
|
43
|
+
`langfuse.observation.cost_details` JSON attributes. If a source history already
|
|
44
|
+
records a total cost, that recorded value wins. Otherwise, the importer
|
|
45
|
+
calculates per-usage-type USD costs from a model catalog using rates in USD per
|
|
46
|
+
1M tokens.
|
|
47
|
+
|
|
48
|
+
The built-in catalog covers OpenAI GPT-5.5 API list pricing plus the toolbox/Pi
|
|
49
|
+
models already used in local configuration, including Fireworks Kimi K2.6,
|
|
50
|
+
Fireworks DeepSeek V4 Pro, MiniMax-M3, Together DeepSeek/Kimi/GLM/MiniMax, and
|
|
51
|
+
Zai GLM. `gpt-5.5` is charged at current standard API list price by default:
|
|
52
|
+
`$5.00` input, `$0.50` cached input, and `$30.00` output per 1M tokens. GPT-5.5
|
|
53
|
+
Pro defaults to `$30.00` input and `$180.00` output per 1M tokens.
|
|
54
|
+
|
|
55
|
+
Use an override only when you intentionally want a different accounting policy:
|
|
56
|
+
|
|
57
|
+
```sh
|
|
58
|
+
npx @ramarivera/coding-agent-langfuse@latest \
|
|
59
|
+
--agents codex \
|
|
60
|
+
--cost-rates-json '{"gpt-5.5":{"input":1,"output":2,"cacheRead":0.1,"cacheWrite":0}}'
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
You can also keep the policy in a JSON file and pass `--cost-rates PATH`, or set
|
|
64
|
+
`CODING_AGENT_LANGFUSE_COST_RATES_PATH` /
|
|
65
|
+
`CODING_AGENT_LANGFUSE_COST_RATES_JSON` for both manual backfills and generated
|
|
66
|
+
services. A file can be either a direct model map or `{ "rates": { ... } }`:
|
|
67
|
+
|
|
68
|
+
```json
|
|
69
|
+
{
|
|
70
|
+
"rates": {
|
|
71
|
+
"gpt-5.5": {
|
|
72
|
+
"input": 1,
|
|
73
|
+
"output": 2,
|
|
74
|
+
"cacheRead": 0.1,
|
|
75
|
+
"cacheWrite": 0
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
38
81
|
## Follow as a host service
|
|
39
82
|
|
|
40
83
|
Install a live follower directly from npm. The generated service keeps inference
|
|
@@ -112,7 +155,7 @@ npm run test:e2e
|
|
|
112
155
|
The e2e suite verifies:
|
|
113
156
|
|
|
114
157
|
- Codex full session traces with messages, reasoning, tool calls, tool results,
|
|
115
|
-
and
|
|
158
|
+
usage details, and cost details
|
|
116
159
|
- Follow mode picking up newly written Codex events
|
|
117
160
|
- One CLI run posting reconstructable traces for Claude Code, Codex, Grok,
|
|
118
161
|
OpenCode, and Pi
|
package/dist/backfill.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ type Usage = {
|
|
|
8
8
|
cacheWrite?: number;
|
|
9
9
|
total?: number;
|
|
10
10
|
cost?: number;
|
|
11
|
+
inputIncludesCache?: boolean;
|
|
11
12
|
};
|
|
12
13
|
type BackfillEvent = {
|
|
13
14
|
agent: AgentName;
|
|
@@ -39,10 +40,24 @@ type BackfillOptions = {
|
|
|
39
40
|
limit?: number;
|
|
40
41
|
sinceMs?: number;
|
|
41
42
|
untilMs?: number;
|
|
43
|
+
sessionIds: Set<string>;
|
|
42
44
|
batchSize: number;
|
|
43
45
|
maxRequestBytes: number;
|
|
44
46
|
maxFieldBytes: number;
|
|
45
47
|
postDelayMs: number;
|
|
48
|
+
costRates: CostCatalog;
|
|
49
|
+
};
|
|
50
|
+
type CostRates = {
|
|
51
|
+
input?: number;
|
|
52
|
+
output?: number;
|
|
53
|
+
reasoning?: number;
|
|
54
|
+
cacheRead?: number;
|
|
55
|
+
cacheWrite?: number;
|
|
56
|
+
};
|
|
57
|
+
type CostCatalog = Record<string, CostRates>;
|
|
58
|
+
type OtlpOptions = {
|
|
59
|
+
maxFieldBytes?: number;
|
|
60
|
+
costRates?: CostCatalog;
|
|
46
61
|
};
|
|
47
62
|
type RunSummary = {
|
|
48
63
|
discovered: Record<string, number>;
|
|
@@ -64,6 +79,7 @@ declare const allAgents: AgentName[];
|
|
|
64
79
|
declare const defaultEndpoint = "https://langfuse.ai.roxasroot.net/otel/v1/traces";
|
|
65
80
|
declare function parseArgs(argv: string[]): BackfillOptions;
|
|
66
81
|
declare function codexEvents(homeDir: string, options?: {
|
|
82
|
+
sessionIds?: Set<string>;
|
|
67
83
|
sinceMs?: number;
|
|
68
84
|
}): BackfillEvent[];
|
|
69
85
|
declare function claudeEvents(homeDir: string): BackfillEvent[];
|
|
@@ -75,9 +91,7 @@ declare function opencodeEvents(homeDir: string, options?: {
|
|
|
75
91
|
untilMs?: number;
|
|
76
92
|
}): BackfillEvent[];
|
|
77
93
|
declare function fingerprint(event: BackfillEvent): string;
|
|
78
|
-
declare function toOtlp(events: BackfillEvent[], options?:
|
|
79
|
-
maxFieldBytes?: number;
|
|
80
|
-
}): Record<string, unknown>;
|
|
94
|
+
declare function toOtlp(events: BackfillEvent[], options?: OtlpOptions): Record<string, unknown>;
|
|
81
95
|
declare function discoverEvents(options: BackfillOptions): BackfillEvent[];
|
|
82
96
|
declare function run(options: BackfillOptions): Promise<RunSummary>;
|
|
83
97
|
declare function follow(options: BackfillOptions): Promise<FollowSummary>;
|
package/dist/backfill.js
CHANGED
|
@@ -5,13 +5,13 @@ import { existsSync, mkdirSync, renameSync, readdirSync, readFileSync, statSync,
|
|
|
5
5
|
import { hostname, homedir } from "node:os";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
const allAgents = ["claude", "codex", "grok", "opencode", "pi"];
|
|
8
|
-
const importIdentityVersion = "
|
|
8
|
+
const importIdentityVersion = "v9-cost-details";
|
|
9
9
|
const importIdentityVersions = {
|
|
10
|
-
claude: "
|
|
11
|
-
codex: "
|
|
12
|
-
grok: "
|
|
13
|
-
opencode: "
|
|
14
|
-
pi: "
|
|
10
|
+
claude: "v12-cost-details",
|
|
11
|
+
codex: "v10-cost-details",
|
|
12
|
+
grok: "v12-cost-details",
|
|
13
|
+
opencode: "v11-cost-details",
|
|
14
|
+
pi: "v12-cost-details",
|
|
15
15
|
};
|
|
16
16
|
const defaultEndpoint = "https://langfuse.ai.roxasroot.net/otel/v1/traces";
|
|
17
17
|
const deadRemoteEndpoint = "http://langfuse.ai.roxasroot.net:14318/v1/traces";
|
|
@@ -19,6 +19,79 @@ const defaultMaxRequestBytes = 12 * 1024 * 1024;
|
|
|
19
19
|
const defaultMaxFieldBytes = 512 * 1024;
|
|
20
20
|
const defaultStatePath = join(homedir(), ".local/state/coding-agent-langfuse/backfill-v6.json");
|
|
21
21
|
const currentHost = hostname();
|
|
22
|
+
const kimiFirepassRates = {
|
|
23
|
+
input: 2,
|
|
24
|
+
output: 8,
|
|
25
|
+
cacheRead: 0.3,
|
|
26
|
+
cacheWrite: 0,
|
|
27
|
+
};
|
|
28
|
+
const deepseekFireworksRates = {
|
|
29
|
+
input: 1.74,
|
|
30
|
+
output: 3.48,
|
|
31
|
+
cacheRead: 0.15,
|
|
32
|
+
cacheWrite: 0,
|
|
33
|
+
};
|
|
34
|
+
const miniMaxM3Rates = {
|
|
35
|
+
input: 0.6,
|
|
36
|
+
output: 2.4,
|
|
37
|
+
cacheRead: 0.12,
|
|
38
|
+
cacheWrite: 0,
|
|
39
|
+
};
|
|
40
|
+
const gpt55Rates = {
|
|
41
|
+
input: 5,
|
|
42
|
+
output: 30,
|
|
43
|
+
cacheRead: 0.5,
|
|
44
|
+
cacheWrite: 5,
|
|
45
|
+
};
|
|
46
|
+
const gpt55ProRates = {
|
|
47
|
+
input: 30,
|
|
48
|
+
output: 180,
|
|
49
|
+
cacheRead: 30,
|
|
50
|
+
cacheWrite: 30,
|
|
51
|
+
};
|
|
52
|
+
const defaultCostRates = {
|
|
53
|
+
"gpt-5.5": gpt55Rates,
|
|
54
|
+
"openai/gpt-5.5": gpt55Rates,
|
|
55
|
+
"gpt-5.5-pro": gpt55ProRates,
|
|
56
|
+
"openai/gpt-5.5-pro": gpt55ProRates,
|
|
57
|
+
"accounts/fireworks/routers/kimi-k2p6-turbo": kimiFirepassRates,
|
|
58
|
+
"fireworks-firepass/accounts/fireworks/routers/kimi-k2p6-turbo": kimiFirepassRates,
|
|
59
|
+
"kimi-for-coding": kimiFirepassRates,
|
|
60
|
+
"accounts/fireworks/models/deepseek-v4-pro": deepseekFireworksRates,
|
|
61
|
+
"fireworks/accounts/fireworks/models/deepseek-v4-pro": deepseekFireworksRates,
|
|
62
|
+
"MiniMax-M3": miniMaxM3Rates,
|
|
63
|
+
"minimax/MiniMax-M3": miniMaxM3Rates,
|
|
64
|
+
"together/deepseek-ai/DeepSeek-V4-Pro": {
|
|
65
|
+
input: 2.1,
|
|
66
|
+
output: 4.4,
|
|
67
|
+
cacheRead: 0.2,
|
|
68
|
+
cacheWrite: 0,
|
|
69
|
+
},
|
|
70
|
+
"together/zai-org/GLM-5.1": {
|
|
71
|
+
input: 1.4,
|
|
72
|
+
output: 4.4,
|
|
73
|
+
cacheRead: 0.2,
|
|
74
|
+
cacheWrite: 0,
|
|
75
|
+
},
|
|
76
|
+
"together/moonshotai/Kimi-K2.6": {
|
|
77
|
+
input: 1.2,
|
|
78
|
+
output: 4.5,
|
|
79
|
+
cacheRead: 0.2,
|
|
80
|
+
cacheWrite: 0,
|
|
81
|
+
},
|
|
82
|
+
"together/MiniMaxAI/MiniMax-M2.7": {
|
|
83
|
+
input: 0.3,
|
|
84
|
+
output: 1.2,
|
|
85
|
+
cacheRead: 0.06,
|
|
86
|
+
cacheWrite: 0,
|
|
87
|
+
},
|
|
88
|
+
"zai/glm-5.1": {
|
|
89
|
+
input: 1.4,
|
|
90
|
+
output: 4.4,
|
|
91
|
+
cacheRead: 0.2,
|
|
92
|
+
cacheWrite: 0,
|
|
93
|
+
},
|
|
94
|
+
};
|
|
22
95
|
function projectMetadata(cwd) {
|
|
23
96
|
if (!cwd)
|
|
24
97
|
return {};
|
|
@@ -40,11 +113,14 @@ Options:
|
|
|
40
113
|
--home PATH Home directory to scan (default: current user home)
|
|
41
114
|
--since ISO_OR_MS Only import events at or after this timestamp
|
|
42
115
|
--until ISO_OR_MS Only import events before or at this timestamp
|
|
116
|
+
--session-id ID Only import one session id; repeat or comma-separate
|
|
43
117
|
--limit N Stop after N unsent events
|
|
44
118
|
--batch-size N OTLP spans per POST (default: 50)
|
|
45
119
|
--max-request-bytes N Split OTLP POSTs below this JSON byte size (default: ${defaultMaxRequestBytes})
|
|
46
120
|
--max-field-bytes N Truncate individual input/output fields above this byte size (default: ${defaultMaxFieldBytes})
|
|
47
121
|
--post-delay-ms N Delay after each successful OTLP POST (default: 0)
|
|
122
|
+
--cost-rates PATH JSON model cost-rate overrides in USD per 1M tokens
|
|
123
|
+
--cost-rates-json JSON Inline JSON model cost-rate overrides in USD per 1M tokens
|
|
48
124
|
--follow Keep scanning and sending newly written events
|
|
49
125
|
--poll-interval-ms N Delay between --follow scans (default: 5000)
|
|
50
126
|
--idle-exit-after-ms N Stop --follow after this much time without new sends
|
|
@@ -70,10 +146,12 @@ function parseArgs(argv) {
|
|
|
70
146
|
let postDelayMs = Number.parseInt(process.env.LANGFUSE_BACKFILL_POST_DELAY_MS ?? "", 10);
|
|
71
147
|
if (!Number.isFinite(postDelayMs))
|
|
72
148
|
postDelayMs = 0;
|
|
149
|
+
let costRates = loadCostCatalogFromEnv();
|
|
73
150
|
let follow = false;
|
|
74
151
|
let pollIntervalMs = 5_000;
|
|
75
152
|
let idleExitAfterMs;
|
|
76
153
|
const agents = new Set(allAgents);
|
|
154
|
+
const sessionIds = new Set();
|
|
77
155
|
for (let i = 0; i < argv.length; i++) {
|
|
78
156
|
const arg = argv[i];
|
|
79
157
|
const next = () => {
|
|
@@ -123,6 +201,12 @@ function parseArgs(argv) {
|
|
|
123
201
|
else if (arg === "--post-delay-ms") {
|
|
124
202
|
postDelayMs = Number.parseInt(next(), 10);
|
|
125
203
|
}
|
|
204
|
+
else if (arg === "--cost-rates") {
|
|
205
|
+
costRates = mergeCostCatalog(costRates, loadCostCatalogFile(next()));
|
|
206
|
+
}
|
|
207
|
+
else if (arg === "--cost-rates-json") {
|
|
208
|
+
costRates = mergeCostCatalog(costRates, parseCostCatalogJson(next()));
|
|
209
|
+
}
|
|
126
210
|
else if (arg === "--follow") {
|
|
127
211
|
follow = true;
|
|
128
212
|
}
|
|
@@ -138,6 +222,13 @@ function parseArgs(argv) {
|
|
|
138
222
|
else if (arg === "--until") {
|
|
139
223
|
untilMs = parseTime(next());
|
|
140
224
|
}
|
|
225
|
+
else if (arg === "--session-id") {
|
|
226
|
+
for (const item of next().split(",")) {
|
|
227
|
+
const sessionId = item.trim();
|
|
228
|
+
if (sessionId.length > 0)
|
|
229
|
+
sessionIds.add(sessionId);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
141
232
|
else {
|
|
142
233
|
throw new Error(`Unknown argument '${arg}'`);
|
|
143
234
|
}
|
|
@@ -176,11 +267,87 @@ function parseArgs(argv) {
|
|
|
176
267
|
limit,
|
|
177
268
|
sinceMs,
|
|
178
269
|
untilMs,
|
|
270
|
+
sessionIds,
|
|
179
271
|
batchSize,
|
|
180
272
|
maxRequestBytes,
|
|
181
273
|
maxFieldBytes,
|
|
182
274
|
postDelayMs,
|
|
275
|
+
costRates,
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
function loadCostCatalogFromEnv() {
|
|
279
|
+
let catalog = { ...defaultCostRates };
|
|
280
|
+
const path = process.env.CODING_AGENT_LANGFUSE_COST_RATES_PATH ??
|
|
281
|
+
process.env.LANGFUSE_BACKFILL_COST_RATES_PATH;
|
|
282
|
+
const inlineJson = process.env.CODING_AGENT_LANGFUSE_COST_RATES_JSON ??
|
|
283
|
+
process.env.LANGFUSE_BACKFILL_COST_RATES_JSON;
|
|
284
|
+
if (path)
|
|
285
|
+
catalog = mergeCostCatalog(catalog, loadCostCatalogFile(path));
|
|
286
|
+
if (inlineJson)
|
|
287
|
+
catalog = mergeCostCatalog(catalog, parseCostCatalogJson(inlineJson));
|
|
288
|
+
return catalog;
|
|
289
|
+
}
|
|
290
|
+
function loadCostCatalogFile(path) {
|
|
291
|
+
return parseCostCatalogJson(readFileSync(path, "utf8"), path);
|
|
292
|
+
}
|
|
293
|
+
function parseCostCatalogJson(json, source = "inline JSON") {
|
|
294
|
+
let parsed;
|
|
295
|
+
try {
|
|
296
|
+
parsed = JSON.parse(json);
|
|
297
|
+
}
|
|
298
|
+
catch (error) {
|
|
299
|
+
throw new Error(`Invalid cost rates ${source}: ${describeError(error)}`);
|
|
300
|
+
}
|
|
301
|
+
const root = asRecord(parsed);
|
|
302
|
+
const nestedRates = asRecord(root.rates);
|
|
303
|
+
const entries = Object.keys(nestedRates).length > 0 ? nestedRates : root;
|
|
304
|
+
const catalog = {};
|
|
305
|
+
for (const [modelKey, rawRates] of Object.entries(entries)) {
|
|
306
|
+
const rates = normalizeCostRates(rawRates, modelKey, source);
|
|
307
|
+
if (rates)
|
|
308
|
+
catalog[modelKey] = rates;
|
|
309
|
+
}
|
|
310
|
+
return catalog;
|
|
311
|
+
}
|
|
312
|
+
function normalizeCostRates(value, modelKey, source) {
|
|
313
|
+
const record = asRecord(value);
|
|
314
|
+
const rates = {
|
|
315
|
+
input: asNumber(record.input) ??
|
|
316
|
+
asNumber(record.input_cost) ??
|
|
317
|
+
asNumber(record.inputPerMillion),
|
|
318
|
+
output: asNumber(record.output) ??
|
|
319
|
+
asNumber(record.output_cost) ??
|
|
320
|
+
asNumber(record.outputPerMillion),
|
|
321
|
+
reasoning: asNumber(record.reasoning) ??
|
|
322
|
+
asNumber(record.reasoning_cost) ??
|
|
323
|
+
asNumber(record.reasoningPerMillion),
|
|
324
|
+
cacheRead: asNumber(record.cacheRead) ??
|
|
325
|
+
asNumber(record.cache_read) ??
|
|
326
|
+
asNumber(record.cachedInput) ??
|
|
327
|
+
asNumber(record.input_cached_tokens),
|
|
328
|
+
cacheWrite: asNumber(record.cacheWrite) ??
|
|
329
|
+
asNumber(record.cache_write) ??
|
|
330
|
+
asNumber(record.inputCacheCreation) ??
|
|
331
|
+
asNumber(record.input_cache_creation),
|
|
183
332
|
};
|
|
333
|
+
const values = Object.entries(rates).filter(([, rate]) => rate !== undefined);
|
|
334
|
+
if (values.length === 0)
|
|
335
|
+
return undefined;
|
|
336
|
+
const cleanRates = {};
|
|
337
|
+
for (const [name, rate] of values) {
|
|
338
|
+
if (rate === undefined || rate < 0) {
|
|
339
|
+
throw new Error(`Invalid ${name} cost rate for '${modelKey}' in ${source}; rates must be non-negative USD per 1M tokens.`);
|
|
340
|
+
}
|
|
341
|
+
cleanRates[name] = rate;
|
|
342
|
+
}
|
|
343
|
+
return cleanRates;
|
|
344
|
+
}
|
|
345
|
+
function mergeCostCatalog(base, override) {
|
|
346
|
+
const merged = { ...base };
|
|
347
|
+
for (const [modelKey, rates] of Object.entries(override)) {
|
|
348
|
+
merged[modelKey] = { ...(merged[modelKey] ?? {}), ...rates };
|
|
349
|
+
}
|
|
350
|
+
return merged;
|
|
184
351
|
}
|
|
185
352
|
function normalizeEndpoint(endpoint) {
|
|
186
353
|
if (endpoint !== deadRemoteEndpoint)
|
|
@@ -302,10 +469,11 @@ function normalizeUsage(value) {
|
|
|
302
469
|
const cache = asRecord(record.cache);
|
|
303
470
|
const inputDetails = asRecord(record.input_tokens_details);
|
|
304
471
|
const outputDetails = asRecord(record.output_tokens_details);
|
|
472
|
+
const directInput = asNumber(record.input);
|
|
473
|
+
const aggregateInput = asNumber(record.input_tokens) ??
|
|
474
|
+
asNumber(record.prompt_tokens);
|
|
305
475
|
const usage = {
|
|
306
|
-
input:
|
|
307
|
-
asNumber(record.input_tokens) ??
|
|
308
|
-
asNumber(record.prompt_tokens),
|
|
476
|
+
input: directInput ?? aggregateInput,
|
|
309
477
|
output: asNumber(record.output) ??
|
|
310
478
|
asNumber(record.output_tokens) ??
|
|
311
479
|
asNumber(record.completion_tokens),
|
|
@@ -330,11 +498,16 @@ function normalizeUsage(value) {
|
|
|
330
498
|
asNumber(record.total_cost) ??
|
|
331
499
|
asNumber(record.cost),
|
|
332
500
|
};
|
|
501
|
+
if (usage.input !== undefined) {
|
|
502
|
+
usage.inputIncludesCache = directInput === undefined;
|
|
503
|
+
}
|
|
333
504
|
if (usage.total === undefined) {
|
|
505
|
+
const cacheRead = usage.inputIncludesCache === false ? (usage.cacheRead ?? 0) : 0;
|
|
334
506
|
const total = (usage.input ?? 0) +
|
|
335
507
|
(usage.output ?? 0) +
|
|
336
508
|
(usage.reasoning ?? 0) +
|
|
337
|
-
(usage.cacheWrite ?? 0)
|
|
509
|
+
(usage.cacheWrite ?? 0) +
|
|
510
|
+
cacheRead;
|
|
338
511
|
if (total > 0)
|
|
339
512
|
usage.total = total;
|
|
340
513
|
}
|
|
@@ -355,8 +528,8 @@ function usageDetails(usage) {
|
|
|
355
528
|
if (!usage)
|
|
356
529
|
return undefined;
|
|
357
530
|
const details = {};
|
|
358
|
-
const cachedInput = usage.cacheRead ?? 0;
|
|
359
|
-
const cacheWrite = usage.cacheWrite ?? 0;
|
|
531
|
+
const cachedInput = usage.inputIncludesCache === false ? 0 : (usage.cacheRead ?? 0);
|
|
532
|
+
const cacheWrite = usage.inputIncludesCache === false ? 0 : (usage.cacheWrite ?? 0);
|
|
360
533
|
const regularInput = usage.input === undefined
|
|
361
534
|
? undefined
|
|
362
535
|
: Math.max(usage.input - cachedInput - cacheWrite, 0);
|
|
@@ -374,6 +547,62 @@ function usageDetails(usage) {
|
|
|
374
547
|
details.total = usage.total;
|
|
375
548
|
return Object.keys(details).length > 0 ? details : undefined;
|
|
376
549
|
}
|
|
550
|
+
function calculateCost(event, usage, costRates) {
|
|
551
|
+
if (!usage)
|
|
552
|
+
return undefined;
|
|
553
|
+
if (event.usage?.cost !== undefined) {
|
|
554
|
+
return {
|
|
555
|
+
details: { total: roundCost(event.usage.cost) },
|
|
556
|
+
source: "recorded",
|
|
557
|
+
};
|
|
558
|
+
}
|
|
559
|
+
const match = findCostRates(event, costRates);
|
|
560
|
+
if (!match)
|
|
561
|
+
return undefined;
|
|
562
|
+
const { rates, modelKey } = match;
|
|
563
|
+
const details = {};
|
|
564
|
+
setCostPart(details, "input", usage.input, rates.input);
|
|
565
|
+
setCostPart(details, "output", usage.output, rates.output);
|
|
566
|
+
setCostPart(details, "output_reasoning", usage.output_reasoning, rates.reasoning ?? rates.output);
|
|
567
|
+
setCostPart(details, "input_cached_tokens", usage.input_cached_tokens, rates.cacheRead ?? rates.input);
|
|
568
|
+
setCostPart(details, "input_cache_creation", usage.input_cache_creation, rates.cacheWrite ?? rates.input);
|
|
569
|
+
if (Object.keys(details).length === 0)
|
|
570
|
+
return undefined;
|
|
571
|
+
details.total = roundCost(Object.values(details).reduce((sum, value) => sum + value, 0));
|
|
572
|
+
return {
|
|
573
|
+
details,
|
|
574
|
+
source: "calculated",
|
|
575
|
+
modelKey,
|
|
576
|
+
rates,
|
|
577
|
+
};
|
|
578
|
+
}
|
|
579
|
+
function findCostRates(event, costRates) {
|
|
580
|
+
const modelName = normalizeModelName(event.model);
|
|
581
|
+
const candidates = [
|
|
582
|
+
event.provider && event.model ? `${event.provider}/${event.model}` : undefined,
|
|
583
|
+
event.provider && modelName ? `${event.provider}/${modelName}` : undefined,
|
|
584
|
+
event.model,
|
|
585
|
+
modelName,
|
|
586
|
+
];
|
|
587
|
+
const seen = new Set();
|
|
588
|
+
for (const candidate of candidates) {
|
|
589
|
+
if (!candidate || seen.has(candidate))
|
|
590
|
+
continue;
|
|
591
|
+
seen.add(candidate);
|
|
592
|
+
const rates = costRates[candidate];
|
|
593
|
+
if (rates)
|
|
594
|
+
return { modelKey: candidate, rates };
|
|
595
|
+
}
|
|
596
|
+
return undefined;
|
|
597
|
+
}
|
|
598
|
+
function setCostPart(details, key, tokens, usdPerMillionTokens) {
|
|
599
|
+
if (tokens === undefined || usdPerMillionTokens === undefined)
|
|
600
|
+
return;
|
|
601
|
+
details[key] = roundCost((tokens * usdPerMillionTokens) / 1_000_000);
|
|
602
|
+
}
|
|
603
|
+
function roundCost(value) {
|
|
604
|
+
return Number(value.toFixed(12));
|
|
605
|
+
}
|
|
377
606
|
function isGenerationEvent(event) {
|
|
378
607
|
return event.usage !== undefined && event.role !== "user" &&
|
|
379
608
|
event.role !== "developer" && event.role !== "system";
|
|
@@ -391,6 +620,11 @@ function codexSessionPathTimeMs(path) {
|
|
|
391
620
|
return Date.UTC(Number(year), Number(month) - 1, Number(day));
|
|
392
621
|
}
|
|
393
622
|
function fileMayContainCodexWindow(path, stat, options) {
|
|
623
|
+
if (options.sessionIds !== undefined &&
|
|
624
|
+
options.sessionIds.size > 0 &&
|
|
625
|
+
![...options.sessionIds].some((sessionId) => path.includes(sessionId))) {
|
|
626
|
+
return false;
|
|
627
|
+
}
|
|
394
628
|
if (options.sinceMs === undefined)
|
|
395
629
|
return true;
|
|
396
630
|
if (stat.mtimeMs >= options.sinceMs)
|
|
@@ -1174,6 +1408,7 @@ function limitEventPayload(event, maxFieldBytes) {
|
|
|
1174
1408
|
}
|
|
1175
1409
|
function toOtlp(events, options = {}) {
|
|
1176
1410
|
const maxFieldBytes = options.maxFieldBytes ?? defaultMaxFieldBytes;
|
|
1411
|
+
const costRates = options.costRates ?? loadCostCatalogFromEnv();
|
|
1177
1412
|
const spansByTrace = new Map();
|
|
1178
1413
|
for (const rawEvent of events) {
|
|
1179
1414
|
const event = limitEventPayload(rawEvent, maxFieldBytes);
|
|
@@ -1244,6 +1479,7 @@ function toOtlp(events, options = {}) {
|
|
|
1244
1479
|
const modelName = normalizeModelName(event.model);
|
|
1245
1480
|
const generation = isGenerationEvent(event);
|
|
1246
1481
|
const usage = usageDetails(event.usage);
|
|
1482
|
+
const cost = generation ? calculateCost(event, usage, costRates) : undefined;
|
|
1247
1483
|
const eventProject = projectMetadata(event.cwd);
|
|
1248
1484
|
const attributes = [
|
|
1249
1485
|
attr("service.name", `agent.${event.agent}`),
|
|
@@ -1271,7 +1507,13 @@ function toOtlp(events, options = {}) {
|
|
|
1271
1507
|
attr("langfuse.observation.metadata.project_folder", eventProject.projectFolder),
|
|
1272
1508
|
attr("langfuse.observation.metadata.model", modelName ?? event.model),
|
|
1273
1509
|
attr("langfuse.observation.metadata.provider", event.provider),
|
|
1510
|
+
attr("langfuse.observation.usage_details", generation ? usage : undefined),
|
|
1511
|
+
attr("langfuse.observation.cost_details", cost?.details),
|
|
1274
1512
|
attr("langfuse.observation.metadata.usage_details", usage),
|
|
1513
|
+
attr("langfuse.observation.metadata.cost_details", cost?.details),
|
|
1514
|
+
attr("langfuse.observation.metadata.cost_source", cost?.source),
|
|
1515
|
+
attr("langfuse.observation.metadata.cost_model_key", cost?.modelKey),
|
|
1516
|
+
attr("langfuse.observation.metadata.cost_rates", cost?.rates),
|
|
1275
1517
|
attr("langfuse.observation.metadata.recorded_cost", event.usage?.cost),
|
|
1276
1518
|
attr("langfuse.observation.input", event.input),
|
|
1277
1519
|
attr("langfuse.observation.output", event.output),
|
|
@@ -1408,7 +1650,10 @@ function describeError(error) {
|
|
|
1408
1650
|
function discoverEvents(options) {
|
|
1409
1651
|
const providers = {
|
|
1410
1652
|
claude: (inner) => claudeEvents(inner.homeDir),
|
|
1411
|
-
codex: (inner) => codexEvents(inner.homeDir, {
|
|
1653
|
+
codex: (inner) => codexEvents(inner.homeDir, {
|
|
1654
|
+
sessionIds: inner.sessionIds,
|
|
1655
|
+
sinceMs: inner.sinceMs,
|
|
1656
|
+
}),
|
|
1412
1657
|
grok: (inner) => grokEvents(inner.homeDir),
|
|
1413
1658
|
opencode: (inner) => opencodeEvents(inner.homeDir, {
|
|
1414
1659
|
rowLimit: inner.limit,
|
|
@@ -1422,6 +1667,7 @@ function discoverEvents(options) {
|
|
|
1422
1667
|
.flatMap((agent) => providers[agent](options))
|
|
1423
1668
|
.filter((event) => options.sinceMs === undefined || event.startMs >= options.sinceMs)
|
|
1424
1669
|
.filter((event) => options.untilMs === undefined || event.startMs <= options.untilMs)
|
|
1670
|
+
.filter((event) => options.sessionIds.size === 0 || options.sessionIds.has(event.sessionId))
|
|
1425
1671
|
.sort((a, b) => a.startMs - b.startMs);
|
|
1426
1672
|
}
|
|
1427
1673
|
async function run(options) {
|
|
@@ -1446,6 +1692,7 @@ async function run(options) {
|
|
|
1446
1692
|
batchSize: options.batchSize,
|
|
1447
1693
|
maxRequestBytes: options.maxRequestBytes,
|
|
1448
1694
|
maxFieldBytes: options.maxFieldBytes,
|
|
1695
|
+
costRates: options.costRates,
|
|
1449
1696
|
});
|
|
1450
1697
|
}
|
|
1451
1698
|
catch (error) {
|
|
@@ -1471,6 +1718,7 @@ async function run(options) {
|
|
|
1471
1718
|
try {
|
|
1472
1719
|
await postOtlp(options.endpoint, batch, {
|
|
1473
1720
|
maxFieldBytes: options.maxFieldBytes,
|
|
1721
|
+
costRates: options.costRates,
|
|
1474
1722
|
});
|
|
1475
1723
|
for (const event of batch) {
|
|
1476
1724
|
state.sent[fingerprint(event)] = new Date().toISOString();
|
package/dist/service.d.ts
CHANGED
package/dist/service.js
CHANGED
|
@@ -24,6 +24,8 @@ Service options:
|
|
|
24
24
|
--batch-size N OTLP spans per POST (default: 10)
|
|
25
25
|
--poll-interval-ms N Delay between --follow scans (default: 5000)
|
|
26
26
|
--post-delay-ms N Delay after each successful OTLP POST (default: 0)
|
|
27
|
+
--cost-rates PATH JSON model cost-rate overrides in USD per 1M tokens
|
|
28
|
+
--cost-rates-json JSON Inline JSON model cost-rate overrides in USD per 1M tokens
|
|
27
29
|
--since ISO_OR_MS Optional lower bound for events the follower may send
|
|
28
30
|
--working-directory DIR Directory the service starts in (default: --home)
|
|
29
31
|
--path VALUE PATH value injected into the service environment
|
|
@@ -49,6 +51,10 @@ function parseServiceArgs(argv) {
|
|
|
49
51
|
let batchSize = 10;
|
|
50
52
|
let pollIntervalMs = 5_000;
|
|
51
53
|
let postDelayMs = 0;
|
|
54
|
+
let costRatesPath = process.env.CODING_AGENT_LANGFUSE_COST_RATES_PATH ??
|
|
55
|
+
process.env.LANGFUSE_BACKFILL_COST_RATES_PATH;
|
|
56
|
+
let costRatesJson = process.env.CODING_AGENT_LANGFUSE_COST_RATES_JSON ??
|
|
57
|
+
process.env.LANGFUSE_BACKFILL_COST_RATES_JSON;
|
|
52
58
|
let since;
|
|
53
59
|
let dryRun = false;
|
|
54
60
|
let start = true;
|
|
@@ -95,6 +101,12 @@ function parseServiceArgs(argv) {
|
|
|
95
101
|
else if (arg === "--post-delay-ms") {
|
|
96
102
|
postDelayMs = parseNonNegativeInt(arg, next());
|
|
97
103
|
}
|
|
104
|
+
else if (arg === "--cost-rates") {
|
|
105
|
+
costRatesPath = next();
|
|
106
|
+
}
|
|
107
|
+
else if (arg === "--cost-rates-json") {
|
|
108
|
+
costRatesJson = next();
|
|
109
|
+
}
|
|
98
110
|
else if (arg === "--since") {
|
|
99
111
|
since = next();
|
|
100
112
|
}
|
|
@@ -134,6 +146,8 @@ function parseServiceArgs(argv) {
|
|
|
134
146
|
batchSize,
|
|
135
147
|
pollIntervalMs,
|
|
136
148
|
postDelayMs,
|
|
149
|
+
costRatesPath,
|
|
150
|
+
costRatesJson,
|
|
137
151
|
since,
|
|
138
152
|
dryRun,
|
|
139
153
|
start,
|
|
@@ -282,6 +296,10 @@ function buildFollowCommand(options) {
|
|
|
282
296
|
];
|
|
283
297
|
if (options.since)
|
|
284
298
|
command.push("--since", options.since);
|
|
299
|
+
if (options.costRatesPath)
|
|
300
|
+
command.push("--cost-rates", options.costRatesPath);
|
|
301
|
+
if (options.costRatesJson)
|
|
302
|
+
command.push("--cost-rates-json", options.costRatesJson);
|
|
285
303
|
return command;
|
|
286
304
|
}
|
|
287
305
|
function renderSystemdUnit(options, command) {
|