@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 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 usage records are kept
7
- as observation metadata so historical imports do not create Langfuse billing or
8
- cost rows. Tool calls remain child spans under the same session.
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 usage metadata
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
@@ -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 = "v8-cached-input-token-split";
8
+ const importIdentityVersion = "v9-cost-details";
9
9
  const importIdentityVersions = {
10
- claude: "v11-tool-results",
11
- codex: "v9-codex-conversation-events",
12
- grok: "v11-chat-history-only",
13
- opencode: "v10-opencode-message-parts",
14
- pi: "v11-tool-results",
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: asNumber(record.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, { sinceMs: inner.sinceMs }),
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
@@ -13,6 +13,8 @@ type ServiceOptions = {
13
13
  batchSize: number;
14
14
  pollIntervalMs: number;
15
15
  postDelayMs: number;
16
+ costRatesPath?: string;
17
+ costRatesJson?: string;
16
18
  npxPath: string;
17
19
  since?: string;
18
20
  dryRun: boolean;
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-agent-langfuse",
3
- "version": "0.1.41",
3
+ "version": "0.1.43",
4
4
  "description": "Universal coding-agent Langfuse backfiller and live OTLP helpers",
5
5
  "type": "module",
6
6
  "license": "MIT",