@ramarivera/coding-agent-langfuse 0.1.52 → 0.1.54

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,10 +8,12 @@ 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`.
11
+ Codex `event_msg` `token_count` rows are imported as cost-accounting
12
+ generations when they include input/output/cache buckets for a known priced
13
+ model. The importer preserves the original token snapshot in metadata and sends
14
+ Langfuse canonical `usage_details`/`cost_details` derived from the bucketed
15
+ usage. Total-only Codex snapshots and unknown/free-preview models stay
16
+ non-billable to avoid inventing cost from ambiguous telemetry.
15
17
 
16
18
  ```sh
17
19
  coding-agent-langfuse-backfill --agents codex,claude,grok,pi,opencode
@@ -63,13 +65,14 @@ There are two config layers:
63
65
  - Optional project-local overlays: every `.langfuse-ca.json` found while
64
66
  walking from the session cwd up to the scanned home directory
65
67
 
66
- Global config uses prefix rules:
68
+ Global config uses path-prefix or Git-remote rules:
67
69
 
68
70
  ```json
69
71
  {
70
72
  "rules": [
71
73
  {
72
74
  "pathPrefix": "/Users/example/dev/acme",
75
+ "gitRemoteIncludes": ["github.com/acme"],
73
76
  "tags": ["acme", "client:acme"],
74
77
  "metadata": {
75
78
  "project_group": "acme",
@@ -82,6 +85,11 @@ Global config uses prefix rules:
82
85
  }
83
86
  ```
84
87
 
88
+ Use `pathPrefix` for stable checkout roots. Use `gitRemoteIncludes` for
89
+ temporary agent worktrees, where the cwd may live under a random directory such
90
+ as `.codex/worktrees/<id>/repo` but the Git remote still identifies the real
91
+ project.
92
+
85
93
  Project-local config is intentionally smaller and overrides/extends the matched
86
94
  global rule. Parent overlays are applied before child overlays, so a config at
87
95
  `~/work/client-a/.langfuse-ca.json` can tag a whole workstream while nested
@@ -127,8 +135,8 @@ records a total cost, that recorded value wins. Otherwise, the importer
127
135
  calculates per-usage-type USD costs from a model catalog using rates in USD per
128
136
  1M tokens.
129
137
 
130
- The built-in catalog covers OpenAI GPT-5.5, GPT-5.4, and GPT-5.3-Codex API list
131
- pricing, Anthropic Claude Opus/Sonnet 4 API list pricing, plus the toolbox/Pi
138
+ The built-in catalog covers OpenAI GPT-5.5, GPT-5.4, GPT-5.4 Mini, and
139
+ GPT-5.3-Codex API list pricing, Anthropic Claude Opus/Sonnet 4 API list pricing, plus the toolbox/Pi
132
140
  models already used in local configuration, including Fireworks Kimi K2.6,
133
141
  Fireworks DeepSeek V4 Pro, MiniMax-M3, Together DeepSeek/Kimi/GLM/MiniMax, and
134
142
  Zai GLM. `gpt-5.5` is charged at current standard API list price by default:
@@ -139,6 +147,10 @@ writes, `$30.00` 1-hour cache writes, and `$75.00` output per 1M tokens.
139
147
  When a billable generation source only records a total token count without
140
148
  input/output/cache breakdown, the importer charges that total at the model input
141
149
  rate and marks the cost source as `calculated_total_as_input`.
150
+ Codex token-count snapshots are the exception: they are charged only when the
151
+ snapshot has explicit input/output/cache buckets. Codex reasoning tokens are
152
+ kept in metadata, but not charged separately because Codex token-count output
153
+ totals already include reasoning tokens.
142
154
 
143
155
  Use an override only when you intentionally want a different accounting policy:
144
156
 
@@ -70,7 +70,8 @@ type OtlpOptions = {
70
70
  homeDir?: string;
71
71
  };
72
72
  type PathTagRule = {
73
- pathPrefix: string;
73
+ pathPrefix?: string;
74
+ gitRemoteIncludes?: string[];
74
75
  tags: string[];
75
76
  metadata: Record<string, unknown>;
76
77
  projectName?: string;
package/dist/backfill.js CHANGED
@@ -8,7 +8,7 @@ const allAgents = ["claude", "codex", "grok", "opencode", "pi"];
8
8
  const importStateIdentityVersion = "v9-cost-details";
9
9
  const importStateIdentityVersions = {
10
10
  claude: "v13-claude-message-snapshot-dedupe",
11
- codex: "v11-codex-token-accounting-nonbillable",
11
+ codex: "v12-codex-token-accounting-priced",
12
12
  grok: "v12-cost-details",
13
13
  opencode: "v11-cost-details",
14
14
  pi: "v12-cost-details",
@@ -24,7 +24,7 @@ const langfuseIdIdentityVersions = {
24
24
  const importPayloadVersion = "v10-cost-details";
25
25
  const importPayloadVersions = {
26
26
  claude: "v11-claude-message-snapshot-dedupe",
27
- codex: "v11-codex-token-accounting-nonbillable",
27
+ codex: "v12-codex-token-accounting-priced",
28
28
  };
29
29
  const defaultEndpoint = "https://langfuse.ai.roxasroot.net/otel/v1/traces";
30
30
  const deadRemoteEndpoint = "http://langfuse.ai.roxasroot.net:14318/v1/traces";
@@ -72,6 +72,12 @@ const gpt54Rates = {
72
72
  cacheRead: 0.25,
73
73
  cacheWrite: 2.5,
74
74
  };
75
+ const gpt54MiniRates = {
76
+ input: 0.75,
77
+ output: 4.5,
78
+ cacheRead: 0.075,
79
+ cacheWrite: 0.75,
80
+ };
75
81
  const gpt53CodexRates = {
76
82
  input: 1.75,
77
83
  output: 14,
@@ -101,6 +107,8 @@ const defaultCostRates = {
101
107
  "openai/gpt-5.5-pro": gpt55ProRates,
102
108
  "gpt-5.4": gpt54Rates,
103
109
  "openai/gpt-5.4": gpt54Rates,
110
+ "gpt-5.4-mini": gpt54MiniRates,
111
+ "openai/gpt-5.4-mini": gpt54MiniRates,
104
112
  "gpt-5.3-codex": gpt53CodexRates,
105
113
  "openai/gpt-5.3-codex": gpt53CodexRates,
106
114
  "claude-opus-4": claudeOpus4Rates,
@@ -196,13 +204,20 @@ function projectMetadata(cwd) {
196
204
  projectFolder: projectName,
197
205
  };
198
206
  }
199
- function matchPathTags(cwd, config, homeDir) {
200
- if (!cwd)
207
+ function matchPathTags(cwd, config, homeDir, git) {
208
+ if (!cwd && !git?.remoteUrls?.length)
201
209
  return { tags: [], metadata: {} };
202
- const normalizedCwd = normalizeRulePath(cwd);
210
+ const normalizedCwd = cwd ? normalizeRulePath(cwd) : undefined;
211
+ const normalizedRemotes = (git?.remoteUrls ?? []).map((remote) => remote.toLowerCase());
203
212
  const matched = config.rules.filter((rule) => {
204
- const prefix = normalizeRulePath(rule.pathPrefix);
205
- return normalizedCwd === prefix || normalizedCwd.startsWith(`${prefix}/`);
213
+ const pathMatch = rule.pathPrefix && normalizedCwd
214
+ ? (() => {
215
+ const prefix = normalizeRulePath(rule.pathPrefix);
216
+ return normalizedCwd === prefix || normalizedCwd.startsWith(`${prefix}/`);
217
+ })()
218
+ : false;
219
+ const gitRemoteMatch = (rule.gitRemoteIncludes ?? []).some((needle) => normalizedRemotes.some((remote) => remote.includes(needle.toLowerCase())));
220
+ return pathMatch || gitRemoteMatch;
206
221
  });
207
222
  const globalMatch = matched.reduce((acc, rule) => ({
208
223
  tags: [...new Set([...acc.tags, ...rule.tags])],
@@ -238,14 +253,15 @@ function matchPathTags(cwd, config, homeDir) {
238
253
  }
239
254
  function mergeProjectMetadata(cwd, config, homeDir) {
240
255
  const project = projectMetadata(cwd);
241
- const pathTags = matchPathTags(cwd, config, homeDir);
256
+ const git = loadGitMetadata(cwd);
257
+ const pathTags = matchPathTags(cwd, config, homeDir, git);
242
258
  return {
243
259
  ...project,
244
260
  projectName: pathTags.projectName ?? project.projectName,
245
261
  projectFolder: pathTags.projectFolder ?? project.projectFolder,
246
262
  tags: pathTags.tags,
247
263
  metadata: pathTags.metadata,
248
- git: loadGitMetadata(cwd),
264
+ git,
249
265
  };
250
266
  }
251
267
  function loadGitMetadata(cwd) {
@@ -263,10 +279,16 @@ function loadGitMetadata(cwd) {
263
279
  const branch = gitOutput(gitCwd, ["branch", "--show-current"]) ??
264
280
  gitOutput(gitCwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
265
281
  const commit = gitOutput(gitCwd, ["rev-parse", "--verify", "HEAD"]);
282
+ const remoteUrls = gitOutput(gitCwd, ["remote", "-v"])
283
+ ?.split("\n")
284
+ .map((line) => line.trim().split(/\s+/)[1])
285
+ .filter((remote) => Boolean(remote))
286
+ .filter((remote, index, remotes) => remotes.indexOf(remote) === index);
266
287
  const metadata = {
267
288
  worktreePath,
268
289
  branch: branch === "HEAD" ? undefined : branch,
269
290
  commit,
291
+ remoteUrls,
270
292
  };
271
293
  gitMetadataCache.set(gitCwd, metadata);
272
294
  return metadata;
@@ -511,8 +533,10 @@ function loadPathTagsConfig(path) {
511
533
  const rules = rawRules.map((rawRule, index) => {
512
534
  const rule = asRecord(rawRule);
513
535
  const pathPrefix = asString(rule.pathPrefix) ?? asString(rule.path_prefix);
514
- if (!pathPrefix) {
515
- throw new Error(`Invalid path tags config ${path}: rules[${index}].pathPrefix is required`);
536
+ const gitRemoteIncludes = stringArray(rule.gitRemoteIncludes ?? rule.git_remote_includes ?? rule.gitRemoteInclude ??
537
+ rule.git_remote_include);
538
+ if (!pathPrefix && gitRemoteIncludes.length === 0) {
539
+ throw new Error(`Invalid path tags config ${path}: rules[${index}] requires pathPrefix or gitRemoteIncludes`);
516
540
  }
517
541
  const tags = Array.isArray(rule.tags)
518
542
  ? rule.tags.filter((tag) => typeof tag === "string" && tag.trim().length > 0)
@@ -520,6 +544,7 @@ function loadPathTagsConfig(path) {
520
544
  const metadata = asRecord(rule.metadata);
521
545
  return {
522
546
  pathPrefix,
547
+ gitRemoteIncludes,
523
548
  tags,
524
549
  metadata,
525
550
  projectName: asString(rule.projectName) ?? asString(rule.project_name),
@@ -764,6 +789,13 @@ function getPath(value, keys) {
764
789
  function asString(value) {
765
790
  return typeof value === "string" ? value : undefined;
766
791
  }
792
+ function stringArray(value) {
793
+ if (typeof value === "string" && value.trim().length > 0)
794
+ return [value];
795
+ if (!Array.isArray(value))
796
+ return [];
797
+ return value.filter((item) => typeof item === "string" && item.trim().length > 0);
798
+ }
767
799
  function asNumber(value) {
768
800
  return typeof value === "number" && Number.isFinite(value)
769
801
  ? value
@@ -925,6 +957,28 @@ function usageTokenTotal(usage) {
925
957
  (usage.cacheWrite5m ?? 0) +
926
958
  (usage.cacheWrite1h ?? 0);
927
959
  }
960
+ function codexBillableTokenUsage(usage) {
961
+ if (!usage)
962
+ return undefined;
963
+ const hasTokenBuckets = [
964
+ usage.input,
965
+ usage.output,
966
+ usage.reasoning,
967
+ usage.cacheRead,
968
+ usage.cacheWrite,
969
+ usage.cacheWrite5m,
970
+ usage.cacheWrite1h,
971
+ ].some((value) => value !== undefined && value > 0);
972
+ if (!hasTokenBuckets)
973
+ return undefined;
974
+ return {
975
+ ...usage,
976
+ output: usage.output ?? usage.reasoning,
977
+ // Codex token_count rows report reasoning as a subset of output tokens.
978
+ // Keep reasoning in metadata, but do not charge it a second time.
979
+ reasoning: undefined,
980
+ };
981
+ }
928
982
  function textLength(value) {
929
983
  if (typeof value === "string")
930
984
  return value.length;
@@ -1190,6 +1244,7 @@ function codexEvents(homeDir, options = {}) {
1190
1244
  const info = asRecord(rowPayload.info);
1191
1245
  const usage = normalizeUsage(asRecord(info.last_token_usage)) ??
1192
1246
  normalizeUsage(asRecord(info.total_token_usage));
1247
+ const billableUsage = codexBillableTokenUsage(usage);
1193
1248
  const tokenKey = JSON.stringify({
1194
1249
  model: asString(info.model) ?? currentModel,
1195
1250
  usage,
@@ -1207,9 +1262,10 @@ function codexEvents(homeDir, options = {}) {
1207
1262
  cwd: currentCwd,
1208
1263
  startMs: timestamp,
1209
1264
  parentRecordId: "session",
1265
+ usage: billableUsage,
1210
1266
  metadata: {
1211
1267
  ...pick(info, ["model_context_window"]),
1212
- token_usage_billable: false,
1268
+ token_usage_billable: billableUsage !== undefined,
1213
1269
  token_usage_source: "codex_event_msg_token_count",
1214
1270
  token_usage_details: usageDetails(usage),
1215
1271
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ramarivera/coding-agent-langfuse",
3
- "version": "0.1.52",
3
+ "version": "0.1.54",
4
4
  "description": "Universal coding-agent Langfuse backfiller and live OTLP helpers",
5
5
  "type": "module",
6
6
  "license": "MIT",