@ramarivera/coding-agent-langfuse 0.1.43 → 0.1.45

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
@@ -8,6 +8,11 @@ Langfuse canonical `usage_details` and `cost_details` attributes so historical
8
8
  backfills participate in Langfuse model-usage and cost dashboards. Tool calls
9
9
  remain child spans under the same session.
10
10
 
11
+ Codex `event_msg` `token_count` rows are imported as non-billable accounting
12
+ spans. They are rolling/snapshot telemetry from the Codex session log, not
13
+ individual model generations, so the importer preserves their token details in
14
+ metadata without sending Langfuse generation `usage_details` or `cost_details`.
15
+
11
16
  ```sh
12
17
  coding-agent-langfuse-backfill --agents codex,claude,grok,pi,opencode
13
18
  ```
@@ -22,6 +27,16 @@ npx @ramarivera/coding-agent-langfuse@latest \
22
27
  --batch-size 10
23
28
  ```
24
29
 
30
+ For a Langfuse endpoint that requires project API keys, pass Basic auth
31
+ credentials as `publicKey:secretKey` or set `LANGFUSE_BACKFILL_AUTH`:
32
+
33
+ ```sh
34
+ npx @ramarivera/coding-agent-langfuse@latest \
35
+ --agents claude,codex,grok,pi,opencode \
36
+ --endpoint http://127.0.0.1:3000/api/public/otel/v1/traces \
37
+ --auth pk-lf-example:sk-lf-example
38
+ ```
39
+
25
40
  Run live incremental forwarding without putting inference behind a gateway:
26
41
 
27
42
  ```sh
@@ -45,12 +60,18 @@ records a total cost, that recorded value wins. Otherwise, the importer
45
60
  calculates per-usage-type USD costs from a model catalog using rates in USD per
46
61
  1M tokens.
47
62
 
48
- The built-in catalog covers OpenAI GPT-5.5 API list pricing plus the toolbox/Pi
63
+ The built-in catalog covers OpenAI GPT-5.5, GPT-5.4, and GPT-5.3-Codex API list
64
+ pricing, Anthropic Claude Opus/Sonnet 4 API list pricing, plus the toolbox/Pi
49
65
  models already used in local configuration, including Fireworks Kimi K2.6,
50
66
  Fireworks DeepSeek V4 Pro, MiniMax-M3, Together DeepSeek/Kimi/GLM/MiniMax, and
51
67
  Zai GLM. `gpt-5.5` is charged at current standard API list price by default:
52
68
  `$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.
69
+ Pro defaults to `$30.00` input and `$180.00` output per 1M tokens. Claude Opus 4
70
+ models default to `$15.00` input, `$1.50` cache hits, `$18.75` 5-minute cache
71
+ writes, `$30.00` 1-hour cache writes, and `$75.00` output per 1M tokens.
72
+ When a billable generation source only records a total token count without
73
+ input/output/cache breakdown, the importer charges that total at the model input
74
+ rate and marks the cost source as `calculated_total_as_input`.
54
75
 
55
76
  Use an override only when you intentionally want a different accounting policy:
56
77
 
@@ -72,7 +93,9 @@ services. A file can be either a direct model map or `{ "rates": { ... } }`:
72
93
  "input": 1,
73
94
  "output": 2,
74
95
  "cacheRead": 0.1,
75
- "cacheWrite": 0
96
+ "cacheWrite": 0,
97
+ "cacheWrite5m": 0,
98
+ "cacheWrite1h": 0
76
99
  }
77
100
  }
78
101
  }
@@ -138,8 +161,26 @@ npx @ramarivera/coding-agent-langfuse@latest \
138
161
  --endpoint https://langfuse.ai.roxasroot.net/otel/v1/traces
139
162
  ```
140
163
 
141
- Deduplication is state-file based and keyed by agent, session id, and source
142
- record id. Reuse the same `--state` path for repeat repairs on a host.
164
+ Deduplication is state-file based and keyed by importer state identity, agent,
165
+ session id, and source record id. Reuse the same `--state` path for normal
166
+ incremental runs.
167
+
168
+ For an intentional repair replay, add `--force` to resend the selected window
169
+ even when the state file says those events were already sent:
170
+
171
+ ```sh
172
+ npx @ramarivera/coding-agent-langfuse@latest \
173
+ --agents claude,codex,grok,pi,opencode \
174
+ --since 2026-05-01T00:00:00Z \
175
+ --until 2026-06-01T00:00:00Z \
176
+ --force \
177
+ --endpoint https://langfuse.ai.roxasroot.net/otel/v1/traces
178
+ ```
179
+
180
+ The Langfuse trace/span IDs intentionally stay pinned to the original pre-cost
181
+ identity, while the state-file key can advance with importer payload changes.
182
+ That lets cost repairs replace historical zero-cost rows instead of creating a
183
+ new duplicate identity for the same source event.
143
184
 
144
185
  ## Verification
145
186
 
@@ -150,6 +191,7 @@ CLI against a local OTLP collector.
150
191
  npm run check
151
192
  npm test
152
193
  npm run test:e2e
194
+ npm run test:e2e:langfuse
153
195
  ```
154
196
 
155
197
  The e2e suite verifies:
@@ -159,5 +201,8 @@ The e2e suite verifies:
159
201
  - Follow mode picking up newly written Codex events
160
202
  - One CLI run posting reconstructable traces for Claude Code, Codex, Grok,
161
203
  OpenCode, and Pi
204
+ - A Docker-backed mini Langfuse import that queries the public observations API
205
+ and verifies usage/cost fields for sanitized multi-agent sessions, including
206
+ Claude Code Opus 4.7 and Opus 4.8 cache accounting
162
207
  - Service plan generation for Linux systemd user units, macOS LaunchAgents, and
163
208
  Windows Scheduled Tasks
@@ -6,6 +6,8 @@ type Usage = {
6
6
  reasoning?: number;
7
7
  cacheRead?: number;
8
8
  cacheWrite?: number;
9
+ cacheWrite5m?: number;
10
+ cacheWrite1h?: number;
9
11
  total?: number;
10
12
  cost?: number;
11
13
  inputIncludesCache?: boolean;
@@ -31,9 +33,11 @@ type BackfillEvent = {
31
33
  type BackfillOptions = {
32
34
  agents: Set<AgentName>;
33
35
  endpoint: string;
36
+ auth?: string;
34
37
  statePath: string;
35
38
  homeDir: string;
36
39
  dryRun: boolean;
40
+ force: boolean;
37
41
  follow: boolean;
38
42
  pollIntervalMs: number;
39
43
  idleExitAfterMs?: number;
@@ -53,6 +57,8 @@ type CostRates = {
53
57
  reasoning?: number;
54
58
  cacheRead?: number;
55
59
  cacheWrite?: number;
60
+ cacheWrite5m?: number;
61
+ cacheWrite1h?: number;
56
62
  };
57
63
  type CostCatalog = Record<string, CostRates>;
58
64
  type OtlpOptions = {
package/dist/backfill.js CHANGED
@@ -5,14 +5,26 @@ 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 = "v9-cost-details";
9
- const importIdentityVersions = {
8
+ const importStateIdentityVersion = "v9-cost-details";
9
+ const importStateIdentityVersions = {
10
10
  claude: "v12-cost-details",
11
- codex: "v10-cost-details",
11
+ codex: "v11-codex-token-accounting-nonbillable",
12
12
  grok: "v12-cost-details",
13
13
  opencode: "v11-cost-details",
14
14
  pi: "v12-cost-details",
15
15
  };
16
+ const langfuseIdIdentityVersion = "v8-cached-input-token-split";
17
+ const langfuseIdIdentityVersions = {
18
+ claude: "v11-tool-results",
19
+ codex: "v9-codex-conversation-events",
20
+ grok: "v11-chat-history-only",
21
+ opencode: "v10-opencode-message-parts",
22
+ pi: "v11-tool-results",
23
+ };
24
+ const importPayloadVersion = "v10-cost-details";
25
+ const importPayloadVersions = {
26
+ codex: "v11-codex-token-accounting-nonbillable",
27
+ };
16
28
  const defaultEndpoint = "https://langfuse.ai.roxasroot.net/otel/v1/traces";
17
29
  const deadRemoteEndpoint = "http://langfuse.ai.roxasroot.net:14318/v1/traces";
18
30
  const defaultMaxRequestBytes = 12 * 1024 * 1024;
@@ -49,11 +61,63 @@ const gpt55ProRates = {
49
61
  cacheRead: 30,
50
62
  cacheWrite: 30,
51
63
  };
64
+ const gpt54Rates = {
65
+ input: 2.5,
66
+ output: 15,
67
+ cacheRead: 0.25,
68
+ cacheWrite: 2.5,
69
+ };
70
+ const gpt53CodexRates = {
71
+ input: 1.75,
72
+ output: 14,
73
+ cacheRead: 0.175,
74
+ cacheWrite: 1.75,
75
+ };
76
+ const claudeOpus4Rates = {
77
+ input: 15,
78
+ output: 75,
79
+ cacheRead: 1.5,
80
+ cacheWrite: 18.75,
81
+ cacheWrite5m: 18.75,
82
+ cacheWrite1h: 30,
83
+ };
84
+ const claudeSonnet4Rates = {
85
+ input: 3,
86
+ output: 15,
87
+ cacheRead: 0.3,
88
+ cacheWrite: 3.75,
89
+ cacheWrite5m: 3.75,
90
+ cacheWrite1h: 6,
91
+ };
52
92
  const defaultCostRates = {
53
93
  "gpt-5.5": gpt55Rates,
54
94
  "openai/gpt-5.5": gpt55Rates,
55
95
  "gpt-5.5-pro": gpt55ProRates,
56
96
  "openai/gpt-5.5-pro": gpt55ProRates,
97
+ "gpt-5.4": gpt54Rates,
98
+ "openai/gpt-5.4": gpt54Rates,
99
+ "gpt-5.3-codex": gpt53CodexRates,
100
+ "openai/gpt-5.3-codex": gpt53CodexRates,
101
+ "claude-opus-4": claudeOpus4Rates,
102
+ "anthropic/claude-opus-4": claudeOpus4Rates,
103
+ "claude-opus-4-1": claudeOpus4Rates,
104
+ "anthropic/claude-opus-4-1": claudeOpus4Rates,
105
+ "claude-opus-4-6": claudeOpus4Rates,
106
+ "anthropic/claude-opus-4-6": claudeOpus4Rates,
107
+ "claude-opus-4-7": claudeOpus4Rates,
108
+ "anthropic/claude-opus-4-7": claudeOpus4Rates,
109
+ "claude-opus-4-8": claudeOpus4Rates,
110
+ "anthropic/claude-opus-4-8": claudeOpus4Rates,
111
+ "claude-sonnet-4": claudeSonnet4Rates,
112
+ "anthropic/claude-sonnet-4": claudeSonnet4Rates,
113
+ "claude-sonnet-4-5": claudeSonnet4Rates,
114
+ "anthropic/claude-sonnet-4-5": claudeSonnet4Rates,
115
+ "claude-sonnet-4.5": claudeSonnet4Rates,
116
+ "anthropic/claude-sonnet-4.5": claudeSonnet4Rates,
117
+ "claude-sonnet-4-6": claudeSonnet4Rates,
118
+ "anthropic/claude-sonnet-4-6": claudeSonnet4Rates,
119
+ "claude-sonnet-4.6": claudeSonnet4Rates,
120
+ "anthropic/claude-sonnet-4.6": claudeSonnet4Rates,
57
121
  "accounts/fireworks/routers/kimi-k2p6-turbo": kimiFirepassRates,
58
122
  "fireworks-firepass/accounts/fireworks/routers/kimi-k2p6-turbo": kimiFirepassRates,
59
123
  "kimi-for-coding": kimiFirepassRates,
@@ -67,24 +131,48 @@ const defaultCostRates = {
67
131
  cacheRead: 0.2,
68
132
  cacheWrite: 0,
69
133
  },
134
+ "deepseek-ai/DeepSeek-V4-Pro": {
135
+ input: 2.1,
136
+ output: 4.4,
137
+ cacheRead: 0.2,
138
+ cacheWrite: 0,
139
+ },
70
140
  "together/zai-org/GLM-5.1": {
71
141
  input: 1.4,
72
142
  output: 4.4,
73
143
  cacheRead: 0.2,
74
144
  cacheWrite: 0,
75
145
  },
146
+ "zai-org/GLM-5.1": {
147
+ input: 1.4,
148
+ output: 4.4,
149
+ cacheRead: 0.2,
150
+ cacheWrite: 0,
151
+ },
76
152
  "together/moonshotai/Kimi-K2.6": {
77
153
  input: 1.2,
78
154
  output: 4.5,
79
155
  cacheRead: 0.2,
80
156
  cacheWrite: 0,
81
157
  },
158
+ "moonshotai/Kimi-K2.6": {
159
+ input: 1.2,
160
+ output: 4.5,
161
+ cacheRead: 0.2,
162
+ cacheWrite: 0,
163
+ },
82
164
  "together/MiniMaxAI/MiniMax-M2.7": {
83
165
  input: 0.3,
84
166
  output: 1.2,
85
167
  cacheRead: 0.06,
86
168
  cacheWrite: 0,
87
169
  },
170
+ "MiniMax-M2.7": {
171
+ input: 0.3,
172
+ output: 1.2,
173
+ cacheRead: 0.06,
174
+ cacheWrite: 0,
175
+ },
88
176
  "zai/glm-5.1": {
89
177
  input: 1.4,
90
178
  output: 4.4,
@@ -108,6 +196,7 @@ function usage() {
108
196
 
109
197
  Options:
110
198
  --endpoint URL OTLP HTTP traces endpoint (default: ${defaultEndpoint})
199
+ --auth USER:PASS Optional Langfuse Basic auth credentials
111
200
  --agents LIST Comma-separated agents: claude,codex,grok,opencode,pi
112
201
  --state PATH Dedupe state file (default: ${defaultStatePath})
113
202
  --home PATH Home directory to scan (default: current user home)
@@ -125,14 +214,18 @@ Options:
125
214
  --poll-interval-ms N Delay between --follow scans (default: 5000)
126
215
  --idle-exit-after-ms N Stop --follow after this much time without new sends
127
216
  --dry-run Discover and dedupe without sending or mutating state
217
+ --force Resend discovered events even when present in local state
128
218
  --help Show this help
129
219
  `;
130
220
  }
131
221
  function parseArgs(argv) {
132
222
  let endpoint = normalizeEndpoint(process.env.LANGFUSE_BACKFILL_ENDPOINT ?? defaultEndpoint);
223
+ let auth = process.env.LANGFUSE_BACKFILL_AUTH ??
224
+ process.env.CODING_AGENT_LANGFUSE_AUTH;
133
225
  let statePath = process.env.LANGFUSE_BACKFILL_STATE ?? defaultStatePath;
134
226
  let homeDir = process.env.HOME ?? homedir();
135
227
  let dryRun = false;
228
+ let force = false;
136
229
  let limit;
137
230
  let sinceMs;
138
231
  let untilMs;
@@ -167,9 +260,15 @@ function parseArgs(argv) {
167
260
  if (arg === "--dry-run") {
168
261
  dryRun = true;
169
262
  }
263
+ else if (arg === "--force") {
264
+ force = true;
265
+ }
170
266
  else if (arg === "--endpoint") {
171
267
  endpoint = normalizeEndpoint(next());
172
268
  }
269
+ else if (arg === "--auth") {
270
+ auth = next();
271
+ }
173
272
  else if (arg === "--state") {
174
273
  statePath = next();
175
274
  }
@@ -255,12 +354,17 @@ function parseArgs(argv) {
255
354
  if (limit !== undefined && (!Number.isFinite(limit) || limit < 1)) {
256
355
  throw new Error("--limit must be a positive integer");
257
356
  }
357
+ if (auth !== undefined && auth.trim().length === 0) {
358
+ throw new Error("--auth must not be empty");
359
+ }
258
360
  return {
259
361
  agents,
260
362
  endpoint,
363
+ auth,
261
364
  statePath,
262
365
  homeDir,
263
366
  dryRun,
367
+ force,
264
368
  follow,
265
369
  pollIntervalMs,
266
370
  idleExitAfterMs,
@@ -329,6 +433,14 @@ function normalizeCostRates(value, modelKey, source) {
329
433
  asNumber(record.cache_write) ??
330
434
  asNumber(record.inputCacheCreation) ??
331
435
  asNumber(record.input_cache_creation),
436
+ cacheWrite5m: asNumber(record.cacheWrite5m) ??
437
+ asNumber(record.cache_write_5m) ??
438
+ asNumber(record.inputCacheCreation5m) ??
439
+ asNumber(record.input_cache_creation_5m),
440
+ cacheWrite1h: asNumber(record.cacheWrite1h) ??
441
+ asNumber(record.cache_write_1h) ??
442
+ asNumber(record.inputCacheCreation1h) ??
443
+ asNumber(record.input_cache_creation_1h),
332
444
  };
333
445
  const values = Object.entries(rates).filter(([, rate]) => rate !== undefined);
334
446
  if (values.length === 0)
@@ -467,11 +579,27 @@ function normalizeUsage(value) {
467
579
  const record = asRecord(value);
468
580
  const nestedCost = asRecord(record.cost);
469
581
  const cache = asRecord(record.cache);
582
+ const cacheCreation = asRecord(record.cache_creation);
470
583
  const inputDetails = asRecord(record.input_tokens_details);
471
584
  const outputDetails = asRecord(record.output_tokens_details);
472
585
  const directInput = asNumber(record.input);
473
586
  const aggregateInput = asNumber(record.input_tokens) ??
474
587
  asNumber(record.prompt_tokens);
588
+ const cacheWrite5m = asNumber(record.cacheWrite5m) ??
589
+ asNumber(record.cache_write_5m) ??
590
+ asNumber(record.input_cache_creation_5m) ??
591
+ asNumber(cacheCreation.ephemeral_5m_input_tokens);
592
+ const cacheWrite1h = asNumber(record.cacheWrite1h) ??
593
+ asNumber(record.cache_write_1h) ??
594
+ asNumber(record.input_cache_creation_1h) ??
595
+ asNumber(cacheCreation.ephemeral_1h_input_tokens);
596
+ const untypedCacheWrite = asNumber(record.cacheWrite) ??
597
+ asNumber(record.cache_creation_input_tokens) ??
598
+ asNumber(cache.write);
599
+ const hasTypedCacheWrite = cacheWrite5m !== undefined || cacheWrite1h !== undefined;
600
+ const hasAnthropicCacheShape = hasTypedCacheWrite ||
601
+ asNumber(record.cache_creation_input_tokens) !== undefined ||
602
+ asNumber(record.cache_read_input_tokens) !== undefined;
475
603
  const usage = {
476
604
  input: directInput ?? aggregateInput,
477
605
  output: asNumber(record.output) ??
@@ -487,9 +615,9 @@ function normalizeUsage(value) {
487
615
  asNumber(record.cached_tokens) ??
488
616
  asNumber(inputDetails.cached_tokens) ??
489
617
  asNumber(cache.read),
490
- cacheWrite: asNumber(record.cacheWrite) ??
491
- asNumber(record.cache_creation_input_tokens) ??
492
- asNumber(cache.write),
618
+ cacheWrite: hasTypedCacheWrite ? undefined : untypedCacheWrite,
619
+ cacheWrite5m,
620
+ cacheWrite1h,
493
621
  total: asNumber(record.totalTokens) ?? asNumber(record.total_tokens) ??
494
622
  asNumber(record.total),
495
623
  cost: asNumber(nestedCost.total) ??
@@ -499,14 +627,17 @@ function normalizeUsage(value) {
499
627
  asNumber(record.cost),
500
628
  };
501
629
  if (usage.input !== undefined) {
502
- usage.inputIncludesCache = directInput === undefined;
630
+ usage.inputIncludesCache = directInput === undefined && !hasAnthropicCacheShape;
503
631
  }
504
632
  if (usage.total === undefined) {
505
633
  const cacheRead = usage.inputIncludesCache === false ? (usage.cacheRead ?? 0) : 0;
634
+ const cacheWrite = (usage.cacheWrite ?? 0) +
635
+ (usage.cacheWrite5m ?? 0) +
636
+ (usage.cacheWrite1h ?? 0);
506
637
  const total = (usage.input ?? 0) +
507
638
  (usage.output ?? 0) +
508
639
  (usage.reasoning ?? 0) +
509
- (usage.cacheWrite ?? 0) +
640
+ cacheWrite +
510
641
  cacheRead;
511
642
  if (total > 0)
512
643
  usage.total = total;
@@ -529,10 +660,13 @@ function usageDetails(usage) {
529
660
  return undefined;
530
661
  const details = {};
531
662
  const cachedInput = usage.inputIncludesCache === false ? 0 : (usage.cacheRead ?? 0);
532
- const cacheWrite = usage.inputIncludesCache === false ? 0 : (usage.cacheWrite ?? 0);
663
+ const cacheWriteTotal = (usage.cacheWrite ?? 0) +
664
+ (usage.cacheWrite5m ?? 0) +
665
+ (usage.cacheWrite1h ?? 0);
666
+ const cacheWriteInInput = usage.inputIncludesCache === false ? 0 : cacheWriteTotal;
533
667
  const regularInput = usage.input === undefined
534
668
  ? undefined
535
- : Math.max(usage.input - cachedInput - cacheWrite, 0);
669
+ : Math.max(usage.input - cachedInput - cacheWriteInInput, 0);
536
670
  if (regularInput !== undefined)
537
671
  details.input = regularInput;
538
672
  if (usage.output !== undefined)
@@ -543,6 +677,15 @@ function usageDetails(usage) {
543
677
  details.input_cached_tokens = usage.cacheRead;
544
678
  if (usage.cacheWrite !== undefined)
545
679
  details.input_cache_creation = usage.cacheWrite;
680
+ if (usage.cacheWrite5m !== undefined) {
681
+ details.input_cache_creation_5m = usage.cacheWrite5m;
682
+ }
683
+ if (usage.cacheWrite1h !== undefined) {
684
+ details.input_cache_creation_1h = usage.cacheWrite1h;
685
+ }
686
+ if (usage.cacheWrite === undefined && cacheWriteTotal > 0) {
687
+ details.input_cache_creation = cacheWriteTotal;
688
+ }
546
689
  if (usage.total !== undefined)
547
690
  details.total = usage.total;
548
691
  return Object.keys(details).length > 0 ? details : undefined;
@@ -565,13 +708,30 @@ function calculateCost(event, usage, costRates) {
565
708
  setCostPart(details, "output", usage.output, rates.output);
566
709
  setCostPart(details, "output_reasoning", usage.output_reasoning, rates.reasoning ?? rates.output);
567
710
  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);
711
+ const hasTypedCacheWrite = usage.input_cache_creation_5m !== undefined ||
712
+ usage.input_cache_creation_1h !== undefined;
713
+ setCostPart(details, "input_cache_creation_5m", usage.input_cache_creation_5m, rates.cacheWrite5m ?? rates.cacheWrite ?? rates.input);
714
+ setCostPart(details, "input_cache_creation_1h", usage.input_cache_creation_1h, rates.cacheWrite1h ?? rates.cacheWrite ?? rates.input);
715
+ if (!hasTypedCacheWrite) {
716
+ setCostPart(details, "input_cache_creation", usage.input_cache_creation, rates.cacheWrite ?? rates.input);
717
+ }
718
+ const calculatedTotal = Object.values(details).reduce((sum, value) => sum + value, 0);
719
+ let source = "calculated";
720
+ if (calculatedTotal === 0 &&
721
+ usage.total !== undefined &&
722
+ usage.total > 0 &&
723
+ rates.input !== undefined) {
724
+ for (const key of Object.keys(details))
725
+ delete details[key];
726
+ setCostPart(details, "input", usage.total, rates.input);
727
+ source = "calculated_total_as_input";
728
+ }
569
729
  if (Object.keys(details).length === 0)
570
730
  return undefined;
571
731
  details.total = roundCost(Object.values(details).reduce((sum, value) => sum + value, 0));
572
732
  return {
573
733
  details,
574
- source: "calculated",
734
+ source,
575
735
  modelKey,
576
736
  rates,
577
737
  };
@@ -805,8 +965,12 @@ function codexEvents(homeDir, options = {}) {
805
965
  cwd: currentCwd,
806
966
  startMs: timestamp,
807
967
  parentRecordId: "session",
808
- usage,
809
- metadata: pick(info, ["model_context_window"]),
968
+ metadata: {
969
+ ...pick(info, ["model_context_window"]),
970
+ token_usage_billable: false,
971
+ token_usage_source: "codex_event_msg_token_count",
972
+ token_usage_details: usageDetails(usage),
973
+ },
810
974
  });
811
975
  }
812
976
  }
@@ -1343,19 +1507,28 @@ function stableId(input) {
1343
1507
  return createHash("sha256").update(input).digest("hex").slice(0, 32);
1344
1508
  }
1345
1509
  function importIdentity(event) {
1346
- return importIdentityVersions[event.agent] ?? importIdentityVersion;
1510
+ return importStateIdentityVersions[event.agent] ?? importStateIdentityVersion;
1511
+ }
1512
+ function payloadVersion(event) {
1513
+ return importPayloadVersions[event.agent] ?? importPayloadVersion;
1514
+ }
1515
+ function langfuseIdIdentity(event) {
1516
+ return langfuseIdIdentityVersions[event.agent] ?? langfuseIdIdentityVersion;
1347
1517
  }
1348
1518
  function fingerprint(event) {
1349
1519
  return `${importIdentity(event)}:${event.agent}:${event.sessionId}:${event.recordId}`;
1350
1520
  }
1521
+ function langfuseFingerprint(event) {
1522
+ return `${langfuseIdIdentity(event)}:${event.agent}:${event.sessionId}:${event.recordId}`;
1523
+ }
1351
1524
  function traceFingerprint(event) {
1352
- return `${importIdentity(event)}:${event.agent}:${event.sessionId}`;
1525
+ return `${langfuseIdIdentity(event)}:${event.agent}:${event.sessionId}`;
1353
1526
  }
1354
1527
  function traceId(event) {
1355
1528
  return stableId(traceFingerprint(event));
1356
1529
  }
1357
1530
  function spanId(event) {
1358
- return stableId(fingerprint(event)).slice(0, 16);
1531
+ return stableId(langfuseFingerprint(event)).slice(0, 16);
1359
1532
  }
1360
1533
  function rootSpanId(event) {
1361
1534
  return stableId(`${traceFingerprint(event)}:root`).slice(0, 16);
@@ -1446,6 +1619,9 @@ function toOtlp(events, options = {}) {
1446
1619
  attr("langfuse.trace.metadata.project_path", firstProject.projectPath),
1447
1620
  attr("langfuse.trace.metadata.project_name", firstProject.projectName),
1448
1621
  attr("langfuse.trace.metadata.project_folder", firstProject.projectFolder),
1622
+ attr("langfuse.trace.metadata.import_payload_version", payloadVersion(first)),
1623
+ attr("langfuse.trace.metadata.import_state_identity", importIdentity(first)),
1624
+ attr("langfuse.trace.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
1449
1625
  attr("langfuse.observation.metadata.agent", first.agent),
1450
1626
  attr("langfuse.observation.metadata.host", currentHost),
1451
1627
  attr("langfuse.observation.metadata.machine", currentHost),
@@ -1456,6 +1632,9 @@ function toOtlp(events, options = {}) {
1456
1632
  attr("langfuse.observation.metadata.project_path", firstProject.projectPath),
1457
1633
  attr("langfuse.observation.metadata.project_name", firstProject.projectName),
1458
1634
  attr("langfuse.observation.metadata.project_folder", firstProject.projectFolder),
1635
+ attr("langfuse.observation.metadata.import_payload_version", payloadVersion(first)),
1636
+ attr("langfuse.observation.metadata.import_state_identity", importIdentity(first)),
1637
+ attr("langfuse.observation.metadata.langfuse_id_identity", langfuseIdIdentity(first)),
1459
1638
  attr("source.path", first.sourcePath),
1460
1639
  attr("cwd", first.cwd),
1461
1640
  attr("project.path", firstProject.projectPath),
@@ -1507,6 +1686,9 @@ function toOtlp(events, options = {}) {
1507
1686
  attr("langfuse.observation.metadata.project_folder", eventProject.projectFolder),
1508
1687
  attr("langfuse.observation.metadata.model", modelName ?? event.model),
1509
1688
  attr("langfuse.observation.metadata.provider", event.provider),
1689
+ attr("langfuse.observation.metadata.import_payload_version", payloadVersion(event)),
1690
+ attr("langfuse.observation.metadata.import_state_identity", importIdentity(event)),
1691
+ attr("langfuse.observation.metadata.langfuse_id_identity", langfuseIdIdentity(event)),
1510
1692
  attr("langfuse.observation.usage_details", generation ? usage : undefined),
1511
1693
  attr("langfuse.observation.cost_details", cost?.details),
1512
1694
  attr("langfuse.observation.metadata.usage_details", usage),
@@ -1515,6 +1697,9 @@ function toOtlp(events, options = {}) {
1515
1697
  attr("langfuse.observation.metadata.cost_model_key", cost?.modelKey),
1516
1698
  attr("langfuse.observation.metadata.cost_rates", cost?.rates),
1517
1699
  attr("langfuse.observation.metadata.recorded_cost", event.usage?.cost),
1700
+ attr("langfuse.observation.metadata.token_usage_billable", event.metadata?.token_usage_billable),
1701
+ attr("langfuse.observation.metadata.token_usage_source", event.metadata?.token_usage_source),
1702
+ attr("langfuse.observation.metadata.token_usage_details", event.metadata?.token_usage_details),
1518
1703
  attr("langfuse.observation.input", event.input),
1519
1704
  attr("langfuse.observation.output", event.output),
1520
1705
  attr("source.path", event.sourcePath),
@@ -1614,11 +1799,16 @@ function splitSendBatches(events, options) {
1614
1799
  }
1615
1800
  async function postOtlp(endpoint, events, options) {
1616
1801
  const body = JSON.stringify(toOtlp(events, options));
1802
+ const headers = {
1803
+ "content-type": "application/json",
1804
+ };
1805
+ if (options.auth)
1806
+ headers.Authorization = authHeader(options.auth);
1617
1807
  let response;
1618
1808
  try {
1619
1809
  response = await fetch(endpoint, {
1620
1810
  method: "POST",
1621
- headers: { "content-type": "application/json" },
1811
+ headers,
1622
1812
  body,
1623
1813
  });
1624
1814
  }
@@ -1629,6 +1819,11 @@ async function postOtlp(endpoint, events, options) {
1629
1819
  throw new Error(`OTLP POST failed: ${response.status} ${await response.text()}`);
1630
1820
  }
1631
1821
  }
1822
+ function authHeader(auth) {
1823
+ if (/^(Basic|Bearer)\s+/i.test(auth))
1824
+ return auth;
1825
+ return `Basic ${Buffer.from(auth, "utf8").toString("base64")}`;
1826
+ }
1632
1827
  function describeError(error) {
1633
1828
  if (!(error instanceof Error))
1634
1829
  return String(error);
@@ -1677,7 +1872,9 @@ async function run(options) {
1677
1872
  for (const event of events) {
1678
1873
  discovered[event.agent] = (discovered[event.agent] ?? 0) + 1;
1679
1874
  }
1680
- const unsent = events.filter((event) => state.sent[fingerprint(event)] === undefined);
1875
+ const unsent = options.force
1876
+ ? events
1877
+ : events.filter((event) => state.sent[fingerprint(event)] === undefined);
1681
1878
  const selected = options.limit === undefined
1682
1879
  ? unsent
1683
1880
  : unsent.slice(0, options.limit);
@@ -1719,6 +1916,7 @@ async function run(options) {
1719
1916
  await postOtlp(options.endpoint, batch, {
1720
1917
  maxFieldBytes: options.maxFieldBytes,
1721
1918
  costRates: options.costRates,
1919
+ auth: options.auth,
1722
1920
  });
1723
1921
  for (const event of batch) {
1724
1922
  state.sent[fingerprint(event)] = new Date().toISOString();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-agent-langfuse",
3
- "version": "0.1.43",
3
+ "version": "0.1.45",
4
4
  "description": "Universal coding-agent Langfuse backfiller and live OTLP helpers",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -25,6 +25,7 @@
25
25
  "check": "tsc --noEmit",
26
26
  "test": "node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test test/**/*.test.ts",
27
27
  "test:e2e": "npm run build && node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test e2e/test/**/*.test.ts",
28
+ "test:e2e:langfuse": "npm run build && LANGFUSE_DOCKER_E2E=1 node --disable-warning=MODULE_TYPELESS_PACKAGE_JSON --experimental-strip-types --test e2e/test/langfuse-docker.test.ts",
28
29
  "pack:dry-run": "npm pack --dry-run",
29
30
  "prepack": "npm run build"
30
31
  },