@ramarivera/coding-agent-langfuse 0.1.42 → 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 +15 -3
- package/dist/backfill.js +241 -12
- 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;
|
|
@@ -44,6 +45,19 @@ type BackfillOptions = {
|
|
|
44
45
|
maxRequestBytes: number;
|
|
45
46
|
maxFieldBytes: number;
|
|
46
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;
|
|
47
61
|
};
|
|
48
62
|
type RunSummary = {
|
|
49
63
|
discovered: Record<string, number>;
|
|
@@ -77,9 +91,7 @@ declare function opencodeEvents(homeDir: string, options?: {
|
|
|
77
91
|
untilMs?: number;
|
|
78
92
|
}): BackfillEvent[];
|
|
79
93
|
declare function fingerprint(event: BackfillEvent): string;
|
|
80
|
-
declare function toOtlp(events: BackfillEvent[], options?:
|
|
81
|
-
maxFieldBytes?: number;
|
|
82
|
-
}): Record<string, unknown>;
|
|
94
|
+
declare function toOtlp(events: BackfillEvent[], options?: OtlpOptions): Record<string, unknown>;
|
|
83
95
|
declare function discoverEvents(options: BackfillOptions): BackfillEvent[];
|
|
84
96
|
declare function run(options: BackfillOptions): Promise<RunSummary>;
|
|
85
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 {};
|
|
@@ -46,6 +119,8 @@ Options:
|
|
|
46
119
|
--max-request-bytes N Split OTLP POSTs below this JSON byte size (default: ${defaultMaxRequestBytes})
|
|
47
120
|
--max-field-bytes N Truncate individual input/output fields above this byte size (default: ${defaultMaxFieldBytes})
|
|
48
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
|
|
49
124
|
--follow Keep scanning and sending newly written events
|
|
50
125
|
--poll-interval-ms N Delay between --follow scans (default: 5000)
|
|
51
126
|
--idle-exit-after-ms N Stop --follow after this much time without new sends
|
|
@@ -71,6 +146,7 @@ function parseArgs(argv) {
|
|
|
71
146
|
let postDelayMs = Number.parseInt(process.env.LANGFUSE_BACKFILL_POST_DELAY_MS ?? "", 10);
|
|
72
147
|
if (!Number.isFinite(postDelayMs))
|
|
73
148
|
postDelayMs = 0;
|
|
149
|
+
let costRates = loadCostCatalogFromEnv();
|
|
74
150
|
let follow = false;
|
|
75
151
|
let pollIntervalMs = 5_000;
|
|
76
152
|
let idleExitAfterMs;
|
|
@@ -125,6 +201,12 @@ function parseArgs(argv) {
|
|
|
125
201
|
else if (arg === "--post-delay-ms") {
|
|
126
202
|
postDelayMs = Number.parseInt(next(), 10);
|
|
127
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
|
+
}
|
|
128
210
|
else if (arg === "--follow") {
|
|
129
211
|
follow = true;
|
|
130
212
|
}
|
|
@@ -190,7 +272,82 @@ function parseArgs(argv) {
|
|
|
190
272
|
maxRequestBytes,
|
|
191
273
|
maxFieldBytes,
|
|
192
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),
|
|
193
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;
|
|
194
351
|
}
|
|
195
352
|
function normalizeEndpoint(endpoint) {
|
|
196
353
|
if (endpoint !== deadRemoteEndpoint)
|
|
@@ -312,10 +469,11 @@ function normalizeUsage(value) {
|
|
|
312
469
|
const cache = asRecord(record.cache);
|
|
313
470
|
const inputDetails = asRecord(record.input_tokens_details);
|
|
314
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);
|
|
315
475
|
const usage = {
|
|
316
|
-
input:
|
|
317
|
-
asNumber(record.input_tokens) ??
|
|
318
|
-
asNumber(record.prompt_tokens),
|
|
476
|
+
input: directInput ?? aggregateInput,
|
|
319
477
|
output: asNumber(record.output) ??
|
|
320
478
|
asNumber(record.output_tokens) ??
|
|
321
479
|
asNumber(record.completion_tokens),
|
|
@@ -340,11 +498,16 @@ function normalizeUsage(value) {
|
|
|
340
498
|
asNumber(record.total_cost) ??
|
|
341
499
|
asNumber(record.cost),
|
|
342
500
|
};
|
|
501
|
+
if (usage.input !== undefined) {
|
|
502
|
+
usage.inputIncludesCache = directInput === undefined;
|
|
503
|
+
}
|
|
343
504
|
if (usage.total === undefined) {
|
|
505
|
+
const cacheRead = usage.inputIncludesCache === false ? (usage.cacheRead ?? 0) : 0;
|
|
344
506
|
const total = (usage.input ?? 0) +
|
|
345
507
|
(usage.output ?? 0) +
|
|
346
508
|
(usage.reasoning ?? 0) +
|
|
347
|
-
(usage.cacheWrite ?? 0)
|
|
509
|
+
(usage.cacheWrite ?? 0) +
|
|
510
|
+
cacheRead;
|
|
348
511
|
if (total > 0)
|
|
349
512
|
usage.total = total;
|
|
350
513
|
}
|
|
@@ -365,8 +528,8 @@ function usageDetails(usage) {
|
|
|
365
528
|
if (!usage)
|
|
366
529
|
return undefined;
|
|
367
530
|
const details = {};
|
|
368
|
-
const cachedInput = usage.cacheRead ?? 0;
|
|
369
|
-
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);
|
|
370
533
|
const regularInput = usage.input === undefined
|
|
371
534
|
? undefined
|
|
372
535
|
: Math.max(usage.input - cachedInput - cacheWrite, 0);
|
|
@@ -384,6 +547,62 @@ function usageDetails(usage) {
|
|
|
384
547
|
details.total = usage.total;
|
|
385
548
|
return Object.keys(details).length > 0 ? details : undefined;
|
|
386
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
|
+
}
|
|
387
606
|
function isGenerationEvent(event) {
|
|
388
607
|
return event.usage !== undefined && event.role !== "user" &&
|
|
389
608
|
event.role !== "developer" && event.role !== "system";
|
|
@@ -1189,6 +1408,7 @@ function limitEventPayload(event, maxFieldBytes) {
|
|
|
1189
1408
|
}
|
|
1190
1409
|
function toOtlp(events, options = {}) {
|
|
1191
1410
|
const maxFieldBytes = options.maxFieldBytes ?? defaultMaxFieldBytes;
|
|
1411
|
+
const costRates = options.costRates ?? loadCostCatalogFromEnv();
|
|
1192
1412
|
const spansByTrace = new Map();
|
|
1193
1413
|
for (const rawEvent of events) {
|
|
1194
1414
|
const event = limitEventPayload(rawEvent, maxFieldBytes);
|
|
@@ -1259,6 +1479,7 @@ function toOtlp(events, options = {}) {
|
|
|
1259
1479
|
const modelName = normalizeModelName(event.model);
|
|
1260
1480
|
const generation = isGenerationEvent(event);
|
|
1261
1481
|
const usage = usageDetails(event.usage);
|
|
1482
|
+
const cost = generation ? calculateCost(event, usage, costRates) : undefined;
|
|
1262
1483
|
const eventProject = projectMetadata(event.cwd);
|
|
1263
1484
|
const attributes = [
|
|
1264
1485
|
attr("service.name", `agent.${event.agent}`),
|
|
@@ -1286,7 +1507,13 @@ function toOtlp(events, options = {}) {
|
|
|
1286
1507
|
attr("langfuse.observation.metadata.project_folder", eventProject.projectFolder),
|
|
1287
1508
|
attr("langfuse.observation.metadata.model", modelName ?? event.model),
|
|
1288
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),
|
|
1289
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),
|
|
1290
1517
|
attr("langfuse.observation.metadata.recorded_cost", event.usage?.cost),
|
|
1291
1518
|
attr("langfuse.observation.input", event.input),
|
|
1292
1519
|
attr("langfuse.observation.output", event.output),
|
|
@@ -1465,6 +1692,7 @@ async function run(options) {
|
|
|
1465
1692
|
batchSize: options.batchSize,
|
|
1466
1693
|
maxRequestBytes: options.maxRequestBytes,
|
|
1467
1694
|
maxFieldBytes: options.maxFieldBytes,
|
|
1695
|
+
costRates: options.costRates,
|
|
1468
1696
|
});
|
|
1469
1697
|
}
|
|
1470
1698
|
catch (error) {
|
|
@@ -1490,6 +1718,7 @@ async function run(options) {
|
|
|
1490
1718
|
try {
|
|
1491
1719
|
await postOtlp(options.endpoint, batch, {
|
|
1492
1720
|
maxFieldBytes: options.maxFieldBytes,
|
|
1721
|
+
costRates: options.costRates,
|
|
1493
1722
|
});
|
|
1494
1723
|
for (const event of batch) {
|
|
1495
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) {
|