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 +65 -0
- package/README_CN.md +52 -0
- package/package.json +1 -1
- package/src/capture-policy.ts +35 -11
- package/src/commands.ts +3 -1
- package/src/config.ts +3 -0
- package/src/handlers/tool.ts +6 -3
- package/src/redaction.ts +21 -7
- package/src/types.ts +2 -0
- package/src/usage-options.ts +41 -0
- package/src/utils.ts +113 -8
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
package/src/capture-policy.ts
CHANGED
|
@@ -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
|
-
|
|
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] =
|
|
119
|
-
|
|
120
|
-
|
|
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
|
|
package/src/handlers/tool.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
409
|
-
|
|
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 {
|
|
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> {
|