pi-langfuse 1.5.16 → 1.5.18

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
@@ -109,12 +109,38 @@ export LANGFUSE_CAPTURE_TOOL_IO=false
109
109
  export LANGFUSE_CAPTURE_SYSTEM_PROMPT=false
110
110
  export LANGFUSE_CAPTURE_CWD=false
111
111
  export LANGFUSE_CAPTURE_SOURCE_METADATA=false
112
+ export LANGFUSE_CAPTURE_PATHS=false
112
113
  ```
113
114
 
114
115
  Source metadata remains off in every preset unless `LANGFUSE_CAPTURE_SOURCE_METADATA=true` is set explicitly.
116
+ The same holds for absolute paths and `LANGFUSE_CAPTURE_PATHS`.
115
117
 
116
118
  All captured payloads are redacted before upload. The extension masks common API keys, bearer tokens, passwords, cookies, private keys, Langfuse keys, GitHub/npm/AWS-style tokens, and local absolute paths.
117
119
 
120
+ ### Absolute paths
121
+
122
+ By default, local absolute paths (`/Users/...`, `/home/...`, `/tmp/...`, `C:\Users\...`) are
123
+ replaced everywhere with a stable `[PATH_HASH:<12 hex chars>]` digest, so usernames and repository
124
+ names never reach Langfuse. This applies to inputs, outputs, tool I/O, tool error messages, and the
125
+ `cwd` metadata field. Opt in to see real paths in traces:
126
+
127
+ ```bash
128
+ export LANGFUSE_CAPTURE_PATHS=true
129
+ ```
130
+
131
+ Or persist it in `config.json`:
132
+
133
+ ```json
134
+ { "capture": { "LANGFUSE_CAPTURE_PATHS": "true" } }
135
+ ```
136
+
137
+ Like `LANGFUSE_CAPTURE_SOURCE_METADATA`, this stays off in every privacy preset until it is set
138
+ explicitly, and it only affects paths — secret masking (tokens, keys, cookies, passwords) is always
139
+ on regardless. Note that `LANGFUSE_CAPTURE_CWD=false` is a different control: it drops the `cwd`
140
+ metadata field entirely rather than changing how paths are rendered.
141
+
142
+ `/langfuse-status` reports the current setting under `Capture: absolute paths`.
143
+
118
144
  ### Payload limits
119
145
 
120
146
  Before upload, payloads are shaped: strings are truncated and deeply nested or
@@ -149,6 +175,44 @@ export PI_LANGFUSE_MAX_FALLBACK_TOTAL_BYTES=33554432 # whole-payload ceiling, d
149
175
  When the accumulated fallback payload exceeds the 32MB ceiling, ingestion is
150
176
  skipped with a warning instead of attempting an unrecoverably large upload.
151
177
 
178
+ ### Reasoning tokens
179
+
180
+ Pi reports reasoning (thinking) tokens for Anthropic, OpenAI Codex, OpenRouter,
181
+ opencode-go and Qwen. Providers count them inside `output`, and by default the
182
+ extension reports `output` whole, so the reasoning share is not visible in
183
+ Langfuse. Opt in to report it as its own usage bucket:
184
+
185
+ ```bash
186
+ export PI_LANGFUSE_SPLIT_REASONING_TOKENS=true
187
+ ```
188
+
189
+ Or persist it in `config.json`:
190
+
191
+ ```json
192
+ { "capture": { "PI_LANGFUSE_SPLIT_REASONING_TOKENS": "true" } }
193
+ ```
194
+
195
+ With the split on, a generation that used 37 output tokens of which 10 were
196
+ reasoning is reported as `output: 27` plus `output_reasoning_tokens: 10`. Both
197
+ keys contain `output`, so the Output row in Langfuse still shows 37; only the
198
+ breakdown beneath it gains the reasoning share. Reasoning is clamped to the
199
+ reported `output`, so the buckets always add up to the total.
200
+
201
+ > **Before enabling — check your model prices.** Langfuse matches prices to
202
+ > usage by exact key, and model definitions you created in your project take
203
+ > precedence over Langfuse's maintained defaults. A custom model priced on
204
+ > `input` and `output` only would cost `output_reasoning_tokens` at zero, so
205
+ > reasoning-heavy generations would look cheaper than they are. Add a price
206
+ > for `output_reasoning_tokens` to every custom reasoning model in
207
+ > **Settings → Models** first, then turn the split on. Langfuse's built-in
208
+ > prices for reasoning models already include it. Providers that report their
209
+ > own cost are unaffected: Langfuse uses the reported cost as-is and does not
210
+ > recompute it from usage.
211
+
212
+ The split is off by default, so upgrading changes nothing until you enable it.
213
+ Unset `PI_LANGFUSE_SPLIT_REASONING_TOKENS` (or set it to `false`) to go back;
214
+ traces already ingested keep their buckets.
215
+
152
216
  ### Method 3: Persistent `config.json`
153
217
 
154
218
  Create or update `~/.pi/agent/pi-langfuse/config.json`:
@@ -204,6 +268,7 @@ This command makes a timeout-bounded authenticated request to Langfuse and, if i
204
268
  - The trace contains the final assistant output shown in Pi.
205
269
  - Tool runs appear as tool observations with arguments, results, and error state.
206
270
  - LLM requests appear as generation observations, including usage and cost when the provider exposes them.
271
+ Reasoning tokens are reported as their own usage bucket when `PI_LANGFUSE_SPLIT_REASONING_TOKENS` is enabled.
207
272
  - Trace-level scores include tool counts, tool success rate, and whether the run had errors.
208
273
 
209
274
  The package also includes a Langfuse CLI skill, so Langfuse data can be queried directly from Pi:
package/README_CN.md CHANGED
@@ -109,12 +109,36 @@ export LANGFUSE_CAPTURE_TOOL_IO=false
109
109
  export LANGFUSE_CAPTURE_SYSTEM_PROMPT=false
110
110
  export LANGFUSE_CAPTURE_CWD=false
111
111
  export LANGFUSE_CAPTURE_SOURCE_METADATA=false
112
+ export LANGFUSE_CAPTURE_PATHS=false
112
113
  ```
113
114
 
114
115
  所有隐私预设默认都关闭源码元数据;只有显式设置 `LANGFUSE_CAPTURE_SOURCE_METADATA=true` 才会启用。
116
+ 绝对路径与 `LANGFUSE_CAPTURE_PATHS` 同理。
115
117
 
116
118
  所有被采集的负载在上传前仍会脱敏。扩展会隐藏常见 API key、Bearer token、密码、Cookie、私钥、Langfuse key、GitHub/npm/AWS 风格 token,并对本地绝对路径做 hash。
117
119
 
120
+ ### 绝对路径
121
+
122
+ 默认情况下,本地绝对路径(`/Users/...`、`/home/...`、`/tmp/...`、`C:\Users\...`)会在所有位置被替换为
123
+ 稳定的 `[PATH_HASH:<12 位十六进制>]` 摘要,因此用户名和仓库名不会进入 Langfuse。该规则作用于输入、输出、
124
+ 工具 I/O、工具错误信息以及 `cwd` 元数据字段。若希望在 trace 中看到真实路径,需要显式开启:
125
+
126
+ ```bash
127
+ export LANGFUSE_CAPTURE_PATHS=true
128
+ ```
129
+
130
+ 也可以持久化到 `config.json`:
131
+
132
+ ```json
133
+ { "capture": { "LANGFUSE_CAPTURE_PATHS": "true" } }
134
+ ```
135
+
136
+ 与 `LANGFUSE_CAPTURE_SOURCE_METADATA` 一样,它在所有隐私预设中默认关闭,且只影响路径;密钥脱敏(token、key、
137
+ Cookie、密码)始终生效。注意 `LANGFUSE_CAPTURE_CWD=false` 是另一个控制项——它会直接丢弃 `cwd` 元数据字段,
138
+ 而不是改变路径的呈现方式。
139
+
140
+ `/langfuse-status` 会在 `Capture: absolute paths` 下显示当前设置。
141
+
118
142
  ### 负载上限
119
143
 
120
144
  上传前会对负载做整形:字符串会被截断,过深或过宽的结构会被裁剪。这些上限让 trace 保持精简,同时保护
@@ -143,6 +167,33 @@ export PI_LANGFUSE_MAX_FALLBACK_TOTAL_BYTES=33554432 # 整体负载上限,默
143
167
 
144
168
  当累积的回退负载超过 32MB 上限时,会跳过摄取并给出告警,而不是尝试一次注定失败的超大上传。
145
169
 
170
+ ### 推理(reasoning)token
171
+
172
+ Pi 会为 Anthropic、OpenAI Codex、OpenRouter、opencode-go 和 Qwen 上报推理(thinking)token。提供商将其计入
173
+ `output`,扩展默认也原样上报整个 `output`,因此在 Langfuse 中看不到推理部分的占比。可显式开启,将其作为独立用量桶上报:
174
+
175
+ ```bash
176
+ export PI_LANGFUSE_SPLIT_REASONING_TOKENS=true
177
+ ```
178
+
179
+ 也可以持久化到 `config.json`:
180
+
181
+ ```json
182
+ { "capture": { "PI_LANGFUSE_SPLIT_REASONING_TOKENS": "true" } }
183
+ ```
184
+
185
+ 开启后,一次消耗 37 个输出 token(其中 10 个为推理)的生成会上报为 `output: 27` 加
186
+ `output_reasoning_tokens: 10`。两个键都包含 `output`,所以 Langfuse 的 Output 行仍显示 37,只是其下的明细多出推理占比。
187
+ 推理数会被限制在上报的 `output` 范围内,因此各桶之和始终等于总量。
188
+
189
+ > **开启前请先检查模型价格。** Langfuse 按键名精确匹配用量与价格,且你在项目中自定义的模型定义优先于 Langfuse 维护的
190
+ > 默认值。若自定义模型只定义了 `input` 和 `output` 价格,`output_reasoning_tokens` 会按零计价,推理密集的生成会显得比实际便宜。
191
+ > 请先在 **Settings → Models** 中为每个自定义推理模型补上 `output_reasoning_tokens` 的价格,再开启拆分。Langfuse 内置的
192
+ > 推理模型价格已包含该键。自行上报成本的提供商不受影响:Langfuse 会直接使用上报的成本,不会再根据用量重新计算。
193
+
194
+ 该拆分默认关闭,升级后在你显式开启之前不会有任何变化。取消设置 `PI_LANGFUSE_SPLIT_REASONING_TOKENS`(或设为 `false`)即可回退;
195
+ 已摄取的 trace 保留其原有用量桶。
196
+
146
197
  ### 方式 3:持久化 `config.json`
147
198
 
148
199
  创建或更新 `~/.pi/agent/pi-langfuse/config.json`:
@@ -198,6 +249,7 @@ pi list
198
249
  - trace 中会包含 Pi 实际显示的最终助手回复。
199
250
  - 工具执行会以工具观察节点展示参数、结果和错误状态。
200
251
  - 模型请求会以生成观察节点展示;如果提供商暴露相关信息,还会包含用量和成本。
252
+ 开启 `PI_LANGFUSE_SPLIT_REASONING_TOKENS` 后,推理 token 会作为独立用量桶上报。
201
253
  - trace 级别会记录工具调用次数、工具成功率和是否出现错误。
202
254
 
203
255
  此包还包含一个内置 Langfuse 技能,可直接在 Pi 中查询 Langfuse 数据:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-langfuse",
3
- "version": "1.5.16",
3
+ "version": "1.5.18",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,4 +1,4 @@
1
- import { hashPath, redactValue } from "./redaction.js";
1
+ import { hashPath, redactValue, type RedactOptions } from "./redaction.js";
2
2
 
3
3
  export interface CapturePolicy {
4
4
  readonly captureInputs: boolean;
@@ -7,6 +7,17 @@ export interface CapturePolicy {
7
7
  readonly captureSystemPrompt: boolean;
8
8
  readonly captureCwd: boolean;
9
9
  readonly captureSourceMetadata: boolean;
10
+ /**
11
+ * Capture local absolute paths verbatim. Off in every preset, like
12
+ * `captureSourceMetadata`: paths are replaced with `[PATH_HASH:...]` unless
13
+ * `LANGFUSE_CAPTURE_PATHS` opts in explicitly.
14
+ */
15
+ readonly capturePaths: boolean;
16
+ }
17
+
18
+ /** Redaction options implied by a policy, so path rendering follows the same switch everywhere. */
19
+ export function redactOptionsFor(policy: CapturePolicy): Partial<RedactOptions> {
20
+ return { redactPaths: !policy.capturePaths };
10
21
  }
11
22
 
12
23
  export type PrivacyPreset = "metadata-only" | "prompts-only" | "conversations" | "full-debug";
@@ -38,6 +49,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
38
49
  captureSystemPrompt: false,
39
50
  captureCwd: false,
40
51
  captureSourceMetadata: false,
52
+ capturePaths: false,
41
53
  },
42
54
  "prompts-only": {
43
55
  captureInputs: true,
@@ -46,6 +58,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
46
58
  captureSystemPrompt: false,
47
59
  captureCwd: false,
48
60
  captureSourceMetadata: false,
61
+ capturePaths: false,
49
62
  },
50
63
  conversations: {
51
64
  captureInputs: true,
@@ -54,6 +67,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
54
67
  captureSystemPrompt: false,
55
68
  captureCwd: false,
56
69
  captureSourceMetadata: false,
70
+ capturePaths: false,
57
71
  },
58
72
  "full-debug": {
59
73
  captureInputs: true,
@@ -62,6 +76,7 @@ const PRESETS: Record<PrivacyPreset, CapturePolicy> = {
62
76
  captureSystemPrompt: true,
63
77
  captureCwd: true,
64
78
  captureSourceMetadata: false,
79
+ capturePaths: false,
65
80
  },
66
81
  };
67
82
 
@@ -72,9 +87,15 @@ const FLAG_TO_FIELD = {
72
87
  LANGFUSE_CAPTURE_SYSTEM_PROMPT: "captureSystemPrompt",
73
88
  LANGFUSE_CAPTURE_CWD: "captureCwd",
74
89
  LANGFUSE_CAPTURE_SOURCE_METADATA: "captureSourceMetadata",
90
+ LANGFUSE_CAPTURE_PATHS: "capturePaths",
75
91
  } as const;
76
92
 
77
- function parseFlag(value: string | undefined): boolean | undefined {
93
+ /**
94
+ * Parse a boolean env flag. `1/true/yes/on` and `0/false/no/off` are
95
+ * case-insensitive; anything else (including unset) is `undefined` so the
96
+ * caller keeps its default.
97
+ */
98
+ export function parseFlag(value: string | undefined): boolean | undefined {
78
99
  if (value === undefined) {
79
100
  return undefined;
80
101
  }
@@ -109,18 +130,20 @@ function redactMetadata(metadata: Record<string, unknown> | undefined, policy: C
109
130
  return undefined;
110
131
  }
111
132
 
133
+ const redactOptions = redactOptionsFor(policy);
112
134
  const output: Record<string, unknown> = {};
113
135
  for (const [key, value] of Object.entries(metadata)) {
114
136
  if (key === "cwd") {
115
137
  if (!policy.captureCwd) {
116
138
  continue;
117
139
  }
118
- output[key] = typeof value === "string" && /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value)
119
- ? hashPath(value)
120
- : redactValue(value);
140
+ output[key] =
141
+ !policy.capturePaths && typeof value === "string" && /^(?:\/|[A-Za-z]:[\\/]|\\\\)/.test(value)
142
+ ? hashPath(value)
143
+ : redactValue(value, redactOptions);
121
144
  continue;
122
145
  }
123
- output[key] = redactValue(value);
146
+ output[key] = redactValue(value, redactOptions);
124
147
  }
125
148
  return Object.keys(output).length > 0 ? output : undefined;
126
149
  }
@@ -129,24 +152,25 @@ export function applyCapturePolicy(
129
152
  payload: RawTelemetryPayload,
130
153
  policy: CapturePolicy = createCapturePolicy(),
131
154
  ): CapturedTelemetryPayload {
155
+ const redactOptions = redactOptionsFor(policy);
132
156
  const captured: CapturedTelemetryPayload = {
133
157
  metadata: redactMetadata(payload.metadata, policy),
134
158
  };
135
159
 
136
160
  if (policy.captureInputs && "input" in payload) {
137
- captured.input = redactValue(payload.input);
161
+ captured.input = redactValue(payload.input, redactOptions);
138
162
  }
139
163
  if (policy.captureOutputs && "output" in payload) {
140
- captured.output = redactValue(payload.output);
164
+ captured.output = redactValue(payload.output, redactOptions);
141
165
  }
142
166
  if (policy.captureToolIo && "toolInput" in payload) {
143
- captured.toolInput = redactValue(payload.toolInput);
167
+ captured.toolInput = redactValue(payload.toolInput, redactOptions);
144
168
  }
145
169
  if (policy.captureToolIo && "toolOutput" in payload) {
146
- captured.toolOutput = redactValue(payload.toolOutput);
170
+ captured.toolOutput = redactValue(payload.toolOutput, redactOptions);
147
171
  }
148
172
  if (policy.captureSystemPrompt && "systemPrompt" in payload) {
149
- captured.systemPrompt = redactValue(payload.systemPrompt);
173
+ captured.systemPrompt = redactValue(payload.systemPrompt, redactOptions);
150
174
  }
151
175
 
152
176
  return captured;
package/src/commands.ts CHANGED
@@ -75,7 +75,7 @@ function isPrivacyPreset(value: string | undefined): value is PrivacyPreset {
75
75
  }
76
76
 
77
77
  function inferPreset(policy: CapturePolicy): PrivacyPreset | "custom" {
78
- const entries: Array<[PrivacyPreset, Omit<CapturePolicy, "captureSourceMetadata">]> = [
78
+ const entries: Array<[PrivacyPreset, Omit<CapturePolicy, "captureSourceMetadata" | "capturePaths">]> = [
79
79
  [
80
80
  "metadata-only",
81
81
  {
@@ -140,6 +140,7 @@ function describePolicy(policy: CapturePolicy) {
140
140
  `captureSystemPrompt: ${policy.captureSystemPrompt}`,
141
141
  `captureCwd: ${policy.captureCwd}`,
142
142
  `captureSourceMetadata: ${policy.captureSourceMetadata}`,
143
+ `capturePaths: ${policy.capturePaths}`,
143
144
  ].join("\n");
144
145
  }
145
146
 
@@ -218,6 +219,7 @@ function formatStatus(configPath: string, env: Record<string, string | undefined
218
219
  ` tool IO: ${flag(policy.captureToolIo)}`,
219
220
  ` system prompt: ${flag(policy.captureSystemPrompt)}`,
220
221
  ` cwd: ${flag(policy.captureCwd)}`,
222
+ ` absolute paths: ${flag(policy.capturePaths)}`,
221
223
  `Active run: ${hasActiveAgentObservation() ? "yes" : "no"}`,
222
224
  `Last error: ${lastErrorSummary()}`,
223
225
  ].join("\n");
package/src/config.ts CHANGED
@@ -6,6 +6,7 @@ import { state } from "./state.js";
6
6
  import { forceShutdownRuntime } from "./langfuse.js";
7
7
  import { createCapturePolicy, type EnvLike } from "./capture-policy.js";
8
8
  import { createPayloadLimits } from "./limits.js";
9
+ import { createUsageOptions } from "./usage-options.js";
9
10
 
10
11
  export function loadConfigFromFile(path = CONFIG_PATH, env: EnvLike = process.env as EnvLike): Config | null {
11
12
  if (existsSync(path)) {
@@ -24,6 +25,7 @@ export function loadConfigFromFile(path = CONFIG_PATH, env: EnvLike = process.en
24
25
  host: config.host || DEFAULT_LANGFUSE_HOST,
25
26
  capturePolicy: createCapturePolicy(captureSource),
26
27
  limits: createPayloadLimits(env),
28
+ usage: createUsageOptions(captureSource),
27
29
  };
28
30
  }
29
31
  } catch (e) {
@@ -47,6 +49,7 @@ export function loadConfigFromEnv(env: EnvLike = process.env as EnvLike): Config
47
49
  host: env.LANGFUSE_BASE_URL || env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
48
50
  capturePolicy: createCapturePolicy(env),
49
51
  limits: createPayloadLimits(env),
52
+ usage: createUsageOptions(env),
50
53
  };
51
54
  }
52
55
 
@@ -12,7 +12,7 @@ import {
12
12
  getCapturePolicy,
13
13
  getLimits,
14
14
  } from "../utils.js";
15
- import { applyCapturePolicy } from "../capture-policy.js";
15
+ import { applyCapturePolicy, redactOptionsFor } from "../capture-policy.js";
16
16
  import { redactString } from "../redaction.js";
17
17
 
18
18
  export async function startToolObservation(event: Record<string, unknown>) {
@@ -87,6 +87,7 @@ export async function finishToolObservation(event: Record<string, unknown>) {
87
87
  event;
88
88
 
89
89
  try {
90
+ const policy = getCapturePolicy();
90
91
  const shapedOutput = shapePayload(output, { maxString: getLimits().maxToolPayload });
91
92
  const captured = applyCapturePolicy(
92
93
  {
@@ -97,7 +98,7 @@ export async function finishToolObservation(event: Record<string, unknown>) {
97
98
  isError,
98
99
  },
99
100
  },
100
- getCapturePolicy(),
101
+ policy,
101
102
  );
102
103
  const outputBytes = estimatePayloadBytes(captured.toolOutput, getLimits().maxToolPayload);
103
104
  const durationMs = Math.max(0, Date.now() - activeTool.startedAt);
@@ -106,7 +107,9 @@ export async function finishToolObservation(event: Record<string, unknown>) {
106
107
  .update({
107
108
  output: captured.toolOutput,
108
109
  level: isError ? "ERROR" : "DEFAULT",
109
- statusMessage: isError ? redactString(truncate(String(event.error ?? output), 1_000)) : undefined,
110
+ statusMessage: isError
111
+ ? redactString(truncate(String(event.error ?? output), 1_000), redactOptionsFor(policy))
112
+ : undefined,
110
113
  metadata: {
111
114
  ...(captured.metadata ?? {}),
112
115
  durationMs,
package/src/redaction.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { getLimits } from "./limits.js";
3
+ import { state } from "./state.js";
3
4
 
4
5
  export const REDACTED = "[REDACTED_SECRET]";
5
6
 
@@ -8,8 +9,16 @@ export interface RedactOptions {
8
9
  maxArrayItems: number;
9
10
  maxObjectKeys: number;
10
11
  maxStringLength: number;
12
+ /** When false, absolute filesystem paths are emitted verbatim instead of `[PATH_HASH:...]`. */
13
+ redactPaths: boolean;
11
14
  }
12
15
 
16
+ /**
17
+ * Fallback for callers that do not pass the resolved capture policy. Mirrors
18
+ * `getLimits()` by reading the session config, and defaults to redacting so a
19
+ * missing policy can never widen disclosure. Callers that hold a policy should
20
+ * pass `redactOptionsFor(policy)` instead of relying on this.
21
+ */
13
22
  function defaultOptions(): RedactOptions {
14
23
  const limits = getLimits();
15
24
  return {
@@ -17,6 +26,7 @@ function defaultOptions(): RedactOptions {
17
26
  maxArrayItems: limits.maxArrayItems,
18
27
  maxObjectKeys: limits.maxObjectKeys,
19
28
  maxStringLength: limits.maxString,
29
+ redactPaths: !(state.config?.capturePolicy?.capturePaths ?? false),
20
30
  };
21
31
  }
22
32
 
@@ -41,16 +51,20 @@ function truncate(value: string, maxStringLength: number): string {
41
51
 
42
52
  export function redactString(value: string, options: Partial<RedactOptions> = {}): string {
43
53
  const merged = { ...defaultOptions(), ...options };
44
- const truncated = truncate(value, merged.maxStringLength);
45
- return truncated
54
+ const secretsRedacted = truncate(value, merged.maxStringLength)
46
55
  .replace(PRIVATE_KEY_RE, REDACTED)
47
56
  .replace(BEARER_RE, REDACTED)
48
57
  .replace(KNOWN_TOKEN_RE, REDACTED)
49
- .replace(SECRET_ASSIGNMENT_RE, (_match, key: string) => `${key}=${REDACTED}`)
50
- .replace(ABSOLUTE_PATH_RE, (path: string) => {
51
- const envSuffix = path.match(/([/\\]\.env(?:\.[A-Za-z0-9_-]+)?)$/)?.[1];
52
- return `${hashPath(envSuffix ? path.slice(0, -envSuffix.length) : path)}${envSuffix ?? ""}`;
53
- });
58
+ .replace(SECRET_ASSIGNMENT_RE, (_match, key: string) => `${key}=${REDACTED}`);
59
+
60
+ if (!merged.redactPaths) {
61
+ return secretsRedacted;
62
+ }
63
+
64
+ return secretsRedacted.replace(ABSOLUTE_PATH_RE, (path: string) => {
65
+ const envSuffix = path.match(/([/\\]\.env(?:\.[A-Za-z0-9_-]+)?)$/)?.[1];
66
+ return `${hashPath(envSuffix ? path.slice(0, -envSuffix.length) : path)}${envSuffix ?? ""}`;
67
+ });
54
68
  }
55
69
 
56
70
  function visit(value: unknown, options: RedactOptions, depth: number, seen: WeakSet<object>): unknown {
package/src/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { CapturePolicy } from "./capture-policy.js";
2
2
  import type { PayloadLimits } from "./limits.js";
3
+ import type { UsageOptions } from "./usage-options.js";
3
4
 
4
5
  export interface Config {
5
6
  publicKey: string;
@@ -7,6 +8,7 @@ export interface Config {
7
8
  host: string;
8
9
  capturePolicy?: CapturePolicy;
9
10
  limits?: PayloadLimits;
11
+ usage?: UsageOptions;
10
12
  }
11
13
 
12
14
  export interface LangfuseObservation {
@@ -0,0 +1,41 @@
1
+ import { parseFlag, type EnvLike } from "./capture-policy.js";
2
+ import { state } from "./state.js";
3
+
4
+ /**
5
+ * Switches that change how token usage is reported to Langfuse. Resolved once
6
+ * from the environment (and the persisted `capture` block, which is merged
7
+ * into it) when config loads; consumers read the resolved values through
8
+ * `getUsageOptions()`.
9
+ */
10
+ export interface UsageOptions {
11
+ /**
12
+ * Report reasoning tokens as `output_reasoning_tokens` and narrow `output`
13
+ * to the non-reasoning remainder. Off by default because Langfuse prices
14
+ * usage by exact key and a custom model definition priced on `output`
15
+ * alone would cost the reasoning share at zero once it moved to its own key.
16
+ */
17
+ readonly splitReasoningTokens: boolean;
18
+ }
19
+
20
+ export const DEFAULT_USAGE_OPTIONS: UsageOptions = {
21
+ splitReasoningTokens: false,
22
+ };
23
+
24
+ /**
25
+ * Resolve usage options from the environment. Namespaced `PI_LANGFUSE_*` like
26
+ * the payload limits: this is extension behaviour, not a Langfuse server knob.
27
+ */
28
+ export function createUsageOptions(env: EnvLike = process.env as EnvLike): UsageOptions {
29
+ return {
30
+ splitReasoningTokens:
31
+ parseFlag(env.PI_LANGFUSE_SPLIT_REASONING_TOKENS) ?? DEFAULT_USAGE_OPTIONS.splitReasoningTokens,
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Resolved usage options for the current session: the config-loaded values
37
+ * when a config is active, otherwise a fresh resolve from the environment.
38
+ */
39
+ export function getUsageOptions(): UsageOptions {
40
+ return state.config?.usage ?? createUsageOptions();
41
+ }
package/src/utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { getLimits } from "./limits.js";
2
- import { createCapturePolicy, type CapturePolicy } from "./capture-policy.js";
2
+ import { getUsageOptions, type UsageOptions } from "./usage-options.js";
3
+ import { createCapturePolicy, redactOptionsFor, type CapturePolicy } from "./capture-policy.js";
3
4
  import { redactValue } from "./redaction.js";
4
5
  import { state } from "./state.js";
5
6
 
@@ -140,6 +141,7 @@ export function shapePayload(
140
141
  return options.redact === false
141
142
  ? shaped
142
143
  : redactValue(shaped, {
144
+ ...redactOptionsFor(getCapturePolicy()),
143
145
  maxDepth: depth,
144
146
  maxStringLength: maxString,
145
147
  maxArrayItems,
@@ -369,7 +371,95 @@ export function getMessageFromEvent(event: Record<string, unknown>): Record<stri
369
371
  return undefined;
370
372
  }
371
373
 
372
- export function extractUsage(messageOrEvent: Record<string, unknown>): Record<string, number> | undefined {
374
+ /**
375
+ * Langfuse buckets usage and cost details by substring match on the key name:
376
+ * `input` rolls up into the Input row of the breakdown, `output` into Output,
377
+ * and anything matching neither falls into the catch-all Other row. Camel-cased
378
+ * `cacheWrite` lands in Other, so cache is reported outside the Input bucket it
379
+ * belongs to and never reaches Langfuse's own price table, which resolves
380
+ * usage types by exact key match (`price.usageType === key`) and therefore
381
+ * cannot price cache for providers that do not report cost themselves.
382
+ *
383
+ * These are the key names Langfuse documents for Anthropic-style caching.
384
+ * Pi reports `input` exclusive of cached tokens, so the buckets stay
385
+ * non-overlapping and nothing is double counted. Emitting both spellings would
386
+ * be worse than either: the canonical key would be counted in Input and the
387
+ * camel-cased duplicate again in Other.
388
+ */
389
+ const CACHE_READ_KEY = "cache_read_input_tokens";
390
+ const CACHE_WRITE_KEY = "cache_creation_input_tokens";
391
+
392
+ /**
393
+ * Anthropic prices one-hour cache writes at a different rate from the default
394
+ * five-minute ones, and Langfuse mirrors that with two TTL-specific usage keys.
395
+ * Pi reports the total under `cacheWrite` and the hour-TTL share of it under
396
+ * `cacheWrite1h`, so the two Langfuse buckets are `cacheWrite - cacheWrite1h`
397
+ * and `cacheWrite1h`. Both keys contain `input`, so the Input row of the
398
+ * breakdown is unchanged; only the price lookup differs.
399
+ */
400
+ const CACHE_WRITE_5M_KEY = "input_cache_creation_5m";
401
+ const CACHE_WRITE_1H_KEY = "input_cache_creation_1h";
402
+
403
+ /**
404
+ * Pi emits `cacheWrite1h: 0` on every Anthropic-family response, so a zero
405
+ * carries no TTL information. Only a non-zero hour-TTL figure is a real
406
+ * breakdown; everything else stays on the TTL-agnostic key so dashboards and
407
+ * custom model prices keyed on it keep working.
408
+ */
409
+ function splitCacheWrite(cacheWrite: number, cacheWrite1h: number): Record<string, number> {
410
+ if (!cacheWrite) {
411
+ return {};
412
+ }
413
+
414
+ const longWrite = Math.min(Math.max(cacheWrite1h, 0), cacheWrite);
415
+ if (!longWrite) {
416
+ return { [CACHE_WRITE_KEY]: cacheWrite };
417
+ }
418
+
419
+ const shortWrite = cacheWrite - longWrite;
420
+ return {
421
+ ...(shortWrite ? { [CACHE_WRITE_5M_KEY]: shortWrite } : {}),
422
+ [CACHE_WRITE_1H_KEY]: longWrite,
423
+ };
424
+ }
425
+
426
+ /**
427
+ * Reasoning tokens are a subset of the completion, not a sibling of it: every
428
+ * provider Pi reports them for counts them inside `output`. Emitting them as an
429
+ * extra bucket alone would therefore inflate Output usage, so `output` is
430
+ * narrowed to the non-reasoning remainder first — the same conversion Langfuse
431
+ * applies to Gemini thought tokens in `OtelIngestionProcessor`. Both keys
432
+ * contain `output`, so Langfuse re-aggregates them into an unchanged Output row.
433
+ *
434
+ * Reasoning is clamped to the reported `output` so an inconsistent provider
435
+ * count cannot drive `output` negative or make the buckets stop summing to
436
+ * `total`.
437
+ *
438
+ * The split is opt-in (`PI_LANGFUSE_SPLIT_REASONING_TOKENS`). Langfuse prices
439
+ * usage by exact key and user-defined model prices shadow the maintained
440
+ * defaults, so a custom model priced on `output` alone would silently cost the
441
+ * reasoning share at zero the moment it moved to its own key. Off by default,
442
+ * `output` stays whole and reasoning is not reported, exactly as before.
443
+ */
444
+ const REASONING_KEY = "output_reasoning_tokens";
445
+
446
+ function splitReasoning(output: number, reasoning: number, enabled: boolean): Record<string, number> {
447
+ if (!enabled) {
448
+ return { output };
449
+ }
450
+
451
+ const bounded = Math.min(Math.max(reasoning, 0), output);
452
+ if (!bounded) {
453
+ return { output };
454
+ }
455
+
456
+ return { output: output - bounded, [REASONING_KEY]: bounded };
457
+ }
458
+
459
+ export function extractUsage(
460
+ messageOrEvent: Record<string, unknown>,
461
+ options: UsageOptions = getUsageOptions(),
462
+ ): Record<string, number> | undefined {
373
463
  const usage = (messageOrEvent.usage ??
374
464
  (messageOrEvent.message && typeof messageOrEvent.message === "object"
375
465
  ? (messageOrEvent.message as Record<string, unknown>).usage
@@ -383,13 +473,17 @@ export function extractUsage(messageOrEvent: Record<string, unknown>): Record<st
383
473
  const total = Number(usage.total ?? usage.totalTokens ?? usage.total_tokens ?? input + output);
384
474
  const cacheRead = Number(usage.cacheRead ?? usage.cache_read ?? usage.cachedTokens ?? 0);
385
475
  const cacheWrite = Number(usage.cacheWrite ?? usage.cache_write ?? 0);
476
+ const cacheWrite1h = Number(usage.cacheWrite1h ?? usage.cache_write_1h ?? 0);
477
+ const reasoning = Number(
478
+ usage.reasoning ?? usage.reasoningTokens ?? usage.reasoning_tokens ?? usage.thoughtsTokenCount ?? 0,
479
+ );
386
480
 
387
481
  return {
388
482
  input,
389
- output,
483
+ ...splitReasoning(output, reasoning, options.splitReasoningTokens),
390
484
  total,
391
- ...(cacheRead ? { cacheRead } : {}),
392
- ...(cacheWrite ? { cacheWrite } : {}),
485
+ ...(cacheRead ? { [CACHE_READ_KEY]: cacheRead } : {}),
486
+ ...splitCacheWrite(cacheWrite, cacheWrite1h),
393
487
  };
394
488
  }
395
489
 
@@ -405,12 +499,23 @@ export function extractCostDetails(messageOrEvent: Record<string, unknown>): Rec
405
499
 
406
500
  const input = Number(cost.input ?? cost.inputCost ?? 0);
407
501
  const output = Number(cost.output ?? cost.outputCost ?? 0);
408
- const total = Number(cost.total ?? cost.totalCost ?? input + output);
409
- if (input === 0 && output === 0 && total === 0) {
502
+ const cacheRead = Number(cost.cacheRead ?? cost.cache_read ?? 0);
503
+ // Pi has no per-TTL cost figure: `cost.cacheWrite` already prices the
504
+ // five-minute and one-hour shares at their own rates and sums them, so the
505
+ // cost side stays on the aggregate key even when usage is split by TTL.
506
+ const cacheWrite = Number(cost.cacheWrite ?? cost.cache_write ?? 0);
507
+ const total = Number(cost.total ?? cost.totalCost ?? input + output + cacheRead + cacheWrite);
508
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0 && total === 0) {
410
509
  return undefined;
411
510
  }
412
511
 
413
- return { input, output, total };
512
+ return {
513
+ input,
514
+ output,
515
+ total,
516
+ ...(cacheRead ? { [CACHE_READ_KEY]: cacheRead } : {}),
517
+ ...(cacheWrite ? { [CACHE_WRITE_KEY]: cacheWrite } : {}),
518
+ };
414
519
  }
415
520
 
416
521
  export function extractResponseMetadata(event: Record<string, unknown>): Record<string, unknown> {