pi-langfuse 1.6.0 → 1.7.0

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
@@ -34,6 +34,7 @@ Langfuse observability extension for [Pi Coding Agent](https://github.com/earend
34
34
  - Langfuse public key, starting with `pk-lf-...`
35
35
  - Langfuse secret key, starting with `sk-lf-...`
36
36
  - Langfuse host, defaulting to `https://cloud.langfuse.com`
37
+ - Optional Langfuse user ID for per-user usage and cost aggregation
37
38
 
38
39
  3. Run Pi normally:
39
40
 
@@ -63,7 +64,7 @@ To inspect the active configuration without exposing secrets:
63
64
  /langfuse-status
64
65
  ```
65
66
 
66
- The status command reports the config source, host, masked public key, capture policy, active-run state, config path, and last runtime error.
67
+ The status command reports the config source, host, masked public key, whether a user ID is configured, capture policy, active-run state, config path, and last runtime error. It never prints the user ID itself.
67
68
 
68
69
  ### Method 2: Environment variables
69
70
 
@@ -73,9 +74,11 @@ Set these before starting Pi:
73
74
  export LANGFUSE_PUBLIC_KEY="pk-lf-xxxx"
74
75
  export LANGFUSE_SECRET_KEY="sk-lf-xxxx"
75
76
  export LANGFUSE_BASE_URL="https://cloud.langfuse.com" # optional; LANGFUSE_HOST is also supported
77
+ export LANGFUSE_USER_ID="user-123" # optional; max 200 characters
76
78
  ```
77
79
 
78
80
  Saved config takes precedence. Environment variables are only used when `~/.pi/agent/pi-langfuse/config.json` is missing or incomplete.
81
+ If saved credentials exist but `userId` is absent, `LANGFUSE_USER_ID` is still used. An explicitly saved `userId` takes precedence.
79
82
 
80
83
  For short-lived SDK hosts, set the bounded final score-delivery attempt during shutdown:
81
84
 
@@ -222,10 +225,13 @@ Create or update `~/.pi/agent/pi-langfuse/config.json`:
222
225
  "publicKey": "pk-lf-xxxx",
223
226
  "secretKey": "sk-lf-xxxx",
224
227
  "host": "https://cloud.langfuse.com",
228
+ "userId": "user-123",
225
229
  "privacyPreset": "conversations"
226
230
  }
227
231
  ```
228
232
 
233
+ `userId` is optional, explicit-only, and limited to 200 characters. The extension does not infer it from the local operating-system account. When configured, it is propagated to every observation in the trace so Langfuse can provide per-user filtering and cost aggregation.
234
+
229
235
  Fine-grained capture flags can also be persisted:
230
236
 
231
237
  ```json
@@ -267,10 +273,18 @@ This command makes a timeout-bounded authenticated request to Langfuse and, if i
267
273
  - Each user prompt within that session becomes a separate trace.
268
274
  - The trace contains the final assistant output shown in Pi.
269
275
  - Tool runs appear as tool observations with arguments, results, and error state.
276
+ Pi 1.0 nested calls (including codemode tools) are parented by `parentToolCallId`.
277
+ A tool's own model usage/cost appears in a child `tool-usage` generation; transcript totals that
278
+ include descendants are not ingested again. Pi's tool usage does not identify each nested model,
279
+ so these aggregate generations have no inferred model name or price.
270
280
  - LLM requests appear as generation observations, including usage and cost when the provider exposes them.
271
281
  Reasoning tokens are reported as their own usage bucket when `PI_LANGFUSE_SPLIT_REASONING_TOKENS` is enabled.
282
+ Virtual routing preserves `selectedModel`/`selectedProvider` alongside `actualModel`/`actualProvider`;
283
+ the generation model and `provider` metadata use the actual identity when available.
272
284
  - Pi 0.86 transcript-aware prompt/tool state appears as `system-state` events. Generations reference the
273
285
  effective prompt/tool state hashes, active tool count, update transport, and cache hit ratio.
286
+ State is refreshed before every provider request, including after lazy tool loading, and unchanged
287
+ state is deduplicated within an attempt.
274
288
  - Automatic retries are grouped under `agent-attempt` spans and the trace remains open until `agent_settled`.
275
289
  - Cache-warming decisions and persisted `cache_warm` usage are reported, including standalone session-grouped
276
290
  traces when warming happens while no user-request trace is active.
package/README_CN.md CHANGED
@@ -34,6 +34,7 @@
34
34
  - Langfuse 公钥,以 `pk-lf-...` 开头
35
35
  - Langfuse 密钥,以 `sk-lf-...` 开头
36
36
  - Langfuse 主机地址,默认 `https://cloud.langfuse.com`
37
+ - 可选的 Langfuse 用户 ID,用于按用户汇总用量和成本
37
38
 
38
39
  3. 正常运行 Pi:
39
40
 
@@ -63,7 +64,7 @@ Langfuse API 密钥可在 **Langfuse Cloud** -> **Settings** -> **API Keys** 中
63
64
  /langfuse-status
64
65
  ```
65
66
 
66
- 状态命令会显示配置来源、主机地址、脱敏后的公钥、采集策略、是否有活跃运行、配置文件路径,以及最近一次运行时错误。
67
+ 状态命令会显示配置来源、主机地址、脱敏后的公钥、是否已配置用户 ID、采集策略、是否有活跃运行、配置文件路径,以及最近一次运行时错误,但不会输出用户 ID 本身。
67
68
 
68
69
  ### 方式 2:环境变量
69
70
 
@@ -73,9 +74,11 @@ Langfuse API 密钥可在 **Langfuse Cloud** -> **Settings** -> **API Keys** 中
73
74
  export LANGFUSE_PUBLIC_KEY="pk-lf-xxxx"
74
75
  export LANGFUSE_SECRET_KEY="sk-lf-xxxx"
75
76
  export LANGFUSE_BASE_URL="https://cloud.langfuse.com" # 可选;也支持 LANGFUSE_HOST
77
+ export LANGFUSE_USER_ID="user-123" # 可选;最长 200 个字符
76
78
  ```
77
79
 
78
80
  保存的配置优先级更高。只有当 `~/.pi/agent/pi-langfuse/config.json` 缺失或不完整时,扩展才会使用环境变量。
81
+ 如果已保存凭据但没有 `userId`,仍会读取 `LANGFUSE_USER_ID`;显式保存的 `userId` 优先级更高。
79
82
 
80
83
  对于短生命周期的 SDK 宿主,可设置关闭时最终分数发送尝试的上限:
81
84
 
@@ -203,10 +206,13 @@ export PI_LANGFUSE_SPLIT_REASONING_TOKENS=true
203
206
  "publicKey": "pk-lf-xxxx",
204
207
  "secretKey": "sk-lf-xxxx",
205
208
  "host": "https://cloud.langfuse.com",
209
+ "userId": "user-123",
206
210
  "privacyPreset": "conversations"
207
211
  }
208
212
  ```
209
213
 
214
+ `userId` 是可选的显式配置,最长 200 个字符。扩展不会从本机操作系统账户推断该值。配置后,它会传播到 trace 中的每个 observation,以支持 Langfuse 按用户筛选并汇总成本。
215
+
210
216
  也可以持久化细粒度采集开关:
211
217
 
212
218
  ```json
@@ -248,10 +254,16 @@ pi list
248
254
  - 该会话中的每个用户提示词都会生成一个独立 trace。
249
255
  - trace 中会包含 Pi 实际显示的最终助手回复。
250
256
  - 工具执行会以工具观察节点展示参数、结果和错误状态。
257
+ Pi 1.0 的嵌套调用(含 codemode 内部工具)按 `parentToolCallId` 建立父子关系。
258
+ 工具自身的模型用量和费用记录在子级 `tool-usage` generation 中,不会重复上报 transcript 中包含
259
+ 后代工具的合计用量。Pi 的工具 usage 未标识每个内部模型,因此这些聚合节点不推断模型名称或价格。
251
260
  - 模型请求会以生成观察节点展示;如果提供商暴露相关信息,还会包含用量和成本。
252
261
  开启 `PI_LANGFUSE_SPLIT_REASONING_TOKENS` 后,推理 token 会作为独立用量桶上报。
262
+ 虚拟模型路由分别记录 `selectedModel`/`selectedProvider` 和 `actualModel`/`actualProvider`;
263
+ 获得实际模型身份后,generation 的 model 和 `provider` 元数据使用实际值。
253
264
  - Pi 0.86 的 transcript-aware system/tool 状态会作为 `system-state` 事件上报;generation 会引用有效的
254
265
  prompt/tool 状态指纹、活动工具数、更新传输方式和缓存命中率。
266
+ 每次 provider 请求前都会刷新状态,覆盖工具延迟加载;同一 attempt 内的未变化状态不会重复生成事件。
255
267
  - 自动重试会归入 `agent-attempt` span,trace 会持续到 `agent_settled` 才结束。
256
268
  - cache warming 决策及持久化的 `cache_warm` 用量会被记录;若 warming 发生时没有活动的用户请求 trace,
257
269
  会创建按相同 session 分组的独立 trace。
package/index.ts CHANGED
@@ -37,6 +37,7 @@ import {
37
37
  finishGenerationFromMessage,
38
38
  createFallbackGenerationFromTurn,
39
39
  recordTTFT,
40
+ recordGenerationIdentity,
40
41
  } from "./src/handlers/generation.js";
41
42
  import {
42
43
  startToolObservation,
@@ -145,13 +146,18 @@ export default async function (pi: ExtensionAPI) {
145
146
  }));
146
147
 
147
148
  pi.on("before_provider_request", async (event, ctx) => withSession(ctx, async () => {
148
- await startGeneration(asRecord(event));
149
+ await recordSystemState(ctx, pi.getActiveTools(), false);
150
+ await startGeneration(asRecord(event), ctx.model);
149
151
  }));
150
152
 
151
153
  pi.on("after_provider_response", async (event, ctx) => withSession(ctx, async () => {
152
154
  updateGenerationMetadata(asRecord(event));
153
155
  }));
154
156
 
157
+ pi.on("provider_stream_event", (event, ctx) => withSession(ctx, () => {
158
+ recordGenerationIdentity(asRecord(event));
159
+ }));
160
+
155
161
  pi.on("message_update", async (event, ctx) => withSession(ctx, async () => {
156
162
  recordTTFT(asRecord(event));
157
163
  const message = getMessageFromEvent(asRecord(event));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-langfuse",
3
- "version": "1.6.0",
3
+ "version": "1.7.0",
4
4
  "description": "Langfuse extension for Pi coding agent",
5
5
  "repository": {
6
6
  "type": "git",
@@ -62,7 +62,7 @@
62
62
  "node": ">=22"
63
63
  },
64
64
  "devDependencies": {
65
- "@earendil-works/pi-coding-agent": "^0.86.0",
65
+ "@earendil-works/pi-coding-agent": "^1.0.0",
66
66
  "tsx": "^4.19.0",
67
67
  "typescript": "^6.0.3"
68
68
  }
package/src/commands.ts CHANGED
@@ -211,6 +211,7 @@ function formatStatus(configPath: string, env: Record<string, string | undefined
211
211
  `Source: ${configSource(env, configPath)}`,
212
212
  `Host: ${safeConfig?.host ?? config.host}`,
213
213
  `Public key: ${safeConfig?.publicKey ?? "[REDACTED_SECRET]"}`,
214
+ `User ID: ${config.userId ? "configured" : "not set"}`,
214
215
  `Config file: ${configPath}`,
215
216
  `Privacy preset: ${inferPreset(policy)}`,
216
217
  "Capture:",
package/src/config.ts CHANGED
@@ -8,6 +8,16 @@ import { createCapturePolicy, type EnvLike } from "./capture-policy.js";
8
8
  import { createPayloadLimits } from "./limits.js";
9
9
  import { createUsageOptions } from "./usage-options.js";
10
10
 
11
+ const MAX_LANGFUSE_USER_ID_LENGTH = 200;
12
+
13
+ function normalizeUserId(value: unknown): string | undefined {
14
+ if (typeof value !== "string") {
15
+ return undefined;
16
+ }
17
+ const trimmed = value.trim();
18
+ return trimmed ? trimmed.slice(0, MAX_LANGFUSE_USER_ID_LENGTH) : undefined;
19
+ }
20
+
11
21
  export function loadConfigFromFile(path = CONFIG_PATH, env: EnvLike = process.env as EnvLike): Config | null {
12
22
  if (existsSync(path)) {
13
23
  try {
@@ -23,6 +33,7 @@ export function loadConfigFromFile(path = CONFIG_PATH, env: EnvLike = process.en
23
33
  publicKey: config.publicKey,
24
34
  secretKey: config.secretKey,
25
35
  host: config.host || DEFAULT_LANGFUSE_HOST,
36
+ userId: normalizeUserId(config.userId) ?? normalizeUserId(env.LANGFUSE_USER_ID),
26
37
  capturePolicy: createCapturePolicy(captureSource),
27
38
  limits: createPayloadLimits(env),
28
39
  usage: createUsageOptions(captureSource),
@@ -47,6 +58,7 @@ export function loadConfigFromEnv(env: EnvLike = process.env as EnvLike): Config
47
58
  publicKey,
48
59
  secretKey,
49
60
  host: env.LANGFUSE_BASE_URL || env.LANGFUSE_HOST || DEFAULT_LANGFUSE_HOST,
61
+ userId: normalizeUserId(env.LANGFUSE_USER_ID),
50
62
  capturePolicy: createCapturePolicy(env),
51
63
  limits: createPayloadLimits(env),
52
64
  usage: createUsageOptions(env),
@@ -108,10 +120,12 @@ async function collectConfigFromUI(ctx: any, reason: string): Promise<Config | n
108
120
  }
109
121
 
110
122
  const hostInput = (await ctx.ui.input("Langfuse host:", DEFAULT_LANGFUSE_HOST))?.trim();
123
+ const userId = normalizeUserId(await ctx.ui.input("Langfuse user ID (optional):", ""));
111
124
  return {
112
125
  publicKey,
113
126
  secretKey,
114
127
  host: hostInput || DEFAULT_LANGFUSE_HOST,
128
+ ...(userId ? { userId } : {}),
115
129
  capturePolicy: createCapturePolicy(),
116
130
  };
117
131
  }
@@ -96,6 +96,7 @@ export async function startAgentRun(event: Record<string, unknown>, ctx: any) {
96
96
  const root = rt.propagateAttributes(
97
97
  {
98
98
  sessionId: state.currentSessionId ? truncate(state.currentSessionId, 200) : undefined,
99
+ userId: state.config?.userId,
99
100
  traceName: "pi-agent",
100
101
  metadata: stringMetadata(captured.metadata),
101
102
  },
@@ -14,6 +14,7 @@ import {
14
14
  extractModelParameters,
15
15
  extractCacheMetrics,
16
16
  inferToolUpdateTransport,
17
+ extractModelIdentity,
17
18
  } from "../utils.js";
18
19
  import type { GenerationState, ObservationUpdate } from "../types.js";
19
20
  import { applyCapturePolicy } from "../capture-policy.js";
@@ -34,7 +35,7 @@ export function getOpenGeneration(): GenerationState | undefined {
34
35
  return undefined;
35
36
  }
36
37
 
37
- export async function startGeneration(event: Record<string, unknown>) {
38
+ export async function startGeneration(event: Record<string, unknown>, selectedModel?: unknown) {
38
39
  if (state.isTracingDisabled || !state.agentState?.root) {
39
40
  return;
40
41
  }
@@ -43,11 +44,16 @@ export async function startGeneration(event: Record<string, unknown>) {
43
44
  const key = getRequestKey(event, `generation-${++state.agentState.generationSeq}`);
44
45
  const payload = getProviderPayload(event);
45
46
  const modelParameters = extractModelParameters(payload);
46
- const model = String(event.model ?? event.modelId ?? state.currentModel ?? "");
47
- const provider = String(event.provider ?? state.currentProvider ?? "");
47
+ const selected = extractModelIdentity(selectedModel ?? state.currentModel, selectedModel ? undefined : state.currentProvider);
48
+ const payloadModel = payload && typeof payload === "object" ? (payload as Record<string, unknown>).model : undefined;
49
+ const actual = extractModelIdentity(event.model ?? event.modelId ?? payloadModel, event.provider);
48
50
  const toolUpdateTransport = inferToolUpdateTransport(payload, (state.agentState.systemStateSequence ?? 0) > 1);
49
51
  const metadata = shapePayload({
50
- provider,
52
+ provider: actual.provider ?? selected.provider,
53
+ selectedModel: selected.model,
54
+ selectedProvider: selected.provider,
55
+ actualModel: actual.model,
56
+ actualProvider: actual.provider,
51
57
  requestId: key,
52
58
  url: event.url,
53
59
  method: event.method,
@@ -81,7 +87,7 @@ export async function startGeneration(event: Record<string, unknown>) {
81
87
  name: "llm-generation",
82
88
  body: {
83
89
  input: captured.input,
84
- model: model || undefined,
90
+ model: actual.model ?? selected.model,
85
91
  modelParameters,
86
92
  metadata: captured.metadata,
87
93
  },
@@ -172,6 +178,21 @@ export function recordTTFT(event: Record<string, unknown>) {
172
178
  }
173
179
  }
174
180
 
181
+ /** Routing identity is available before the assistant message, including failed streams. */
182
+ export function recordGenerationIdentity(event: Record<string, unknown>) {
183
+ const generation = getOpenGeneration();
184
+ if (!generation) return;
185
+ const actual = extractModelIdentity(event.model, event.provider);
186
+ if (!actual.model && !actual.provider) return;
187
+ if (generation.metadata.actualModel === actual.model && generation.metadata.actualProvider === actual.provider) return;
188
+ generation.metadata = applyCapturePolicy({ metadata: shapePayload({
189
+ ...generation.metadata,
190
+ ...(actual.model ? { actualModel: actual.model } : {}),
191
+ ...(actual.provider ? { provider: actual.provider, actualProvider: actual.provider } : {}),
192
+ }) as Record<string, unknown> }, getCapturePolicy()).metadata ?? {};
193
+ generation.observation.update({ model: actual.model, metadata: generation.metadata });
194
+ }
195
+
175
196
  export async function finishGenerationFromMessage(event: Record<string, unknown>) {
176
197
  if (state.isTracingDisabled || !state.agentState) {
177
198
  return;
@@ -196,15 +217,17 @@ export async function finishGenerationFromMessage(event: Record<string, unknown>
196
217
  const costDetails = extractCostDetails({ ...event, message });
197
218
  const cacheMetrics = extractCacheMetrics({ ...event, message });
198
219
  const modelParameters = extractModelParameters(getProviderPayload(event)) ?? generation.modelParameters;
199
- const model = String(message.model ?? event.model ?? state.currentModel ?? "");
220
+ const actual = extractModelIdentity(message.model ?? event.model, message.provider ?? event.provider);
200
221
  const update: ObservationUpdate = {
201
222
  output,
202
- model: model || undefined,
223
+ model: actual.model ?? (generation.metadata.actualModel as string | undefined) ?? (state.currentModel || undefined),
203
224
  modelParameters,
204
225
  usageDetails,
205
226
  ...(costDetails ? { costDetails } : {}),
206
227
  metadata: {
207
228
  ...generation.metadata,
229
+ ...(actual.model ? { actualModel: actual.model } : {}),
230
+ ...(actual.provider ? { provider: actual.provider, actualProvider: actual.provider } : {}),
208
231
  finishReason: message.finishReason ?? message.stopReason ?? event.finishReason,
209
232
  ...cacheMetrics,
210
233
  },
@@ -235,13 +258,17 @@ export async function createFallbackGenerationFromTurn(event: Record<string, unk
235
258
  const costDetails = extractCostDetails({ ...event, message });
236
259
  const cacheMetrics = extractCacheMetrics({ ...event, message });
237
260
  const modelParameters = extractModelParameters(getProviderPayload(event));
238
- const model = String(message.model ?? event.model ?? state.currentModel ?? "");
261
+ const actual = extractModelIdentity(message.model ?? event.model, message.provider ?? event.provider);
239
262
  const captured = applyCapturePolicy(
240
263
  {
241
264
  input: state.agentState.promptInput,
242
265
  output: extractAssistantOutput(message),
243
266
  metadata: {
244
- provider: state.currentProvider || undefined,
267
+ provider: actual.provider ?? (state.currentProvider || undefined),
268
+ selectedModel: state.currentModel || undefined,
269
+ selectedProvider: state.currentProvider || undefined,
270
+ actualModel: actual.model,
271
+ actualProvider: actual.provider,
245
272
  sourceEvent: "turn_end",
246
273
  promptStateHash: state.agentState.promptStateHash,
247
274
  toolStateHash: state.agentState.toolStateHash,
@@ -260,7 +287,7 @@ export async function createFallbackGenerationFromTurn(event: Record<string, unk
260
287
  body: {
261
288
  input: captured.input,
262
289
  output: captured.output,
263
- model: model || undefined,
290
+ model: actual.model ?? (state.currentModel || undefined),
264
291
  modelParameters,
265
292
  usageDetails,
266
293
  ...(costDetails ? { costDetails } : {}),
@@ -73,7 +73,7 @@ export function initializeSystemStateTracking(ctx: any): void {
73
73
  }
74
74
  }
75
75
 
76
- export async function recordSystemState(ctx: any, activeTools: string[]): Promise<void> {
76
+ export async function recordSystemState(ctx: any, activeTools: string[], emitUnchanged = true): Promise<void> {
77
77
  const agent = state.agentState;
78
78
  if (state.isTracingDisabled || !agent?.root) return;
79
79
 
@@ -133,6 +133,9 @@ export async function recordSystemState(ctx: any, activeTools: string[]): Promis
133
133
  const changed = isInitial || promptChanged || toolsChanged || systemMessages.length > 0;
134
134
  if (changed) session.systemStateSequence++;
135
135
 
136
+ // agent_start emits a snapshot; unchanged request hooks keep the existing reference.
137
+ if (!changed && !emitUnchanged && agent.systemStateSequence !== undefined) return;
138
+
136
139
  const capturePolicy = getCapturePolicy();
137
140
  const rawSystemUpdate = systemMessages.length > 0
138
141
  ? systemMessages.map(({ entryId, message }) => ({ entryId, ...message }))
@@ -11,6 +11,7 @@ import {
11
11
  estimatePayloadBytes,
12
12
  getCapturePolicy,
13
13
  getLimits,
14
+ extractToolUsage,
14
15
  } from "../utils.js";
15
16
  import { applyCapturePolicy, redactOptionsFor } from "../capture-policy.js";
16
17
  import { redactString } from "../redaction.js";
@@ -27,17 +28,19 @@ export async function startToolObservation(event: Record<string, unknown>) {
27
28
 
28
29
  try {
29
30
  const toolName = getToolName(event);
31
+ const parentToolCallId = typeof event.parentToolCallId === "string" ? event.parentToolCallId : undefined;
30
32
  const toolInput = getToolInput(event);
31
33
  const shapedInput = shapePayload(toolInput, { maxString: getLimits().maxToolPayload });
32
34
  const captured = applyCapturePolicy(
33
35
  {
34
36
  toolInput: shapedInput,
35
- metadata: { toolName, toolCallId },
37
+ metadata: shapePayload({ toolName, toolCallId, parentToolCallId }) as Record<string, unknown>,
36
38
  },
37
39
  getCapturePolicy(),
38
40
  );
39
41
  const inputBytes = estimatePayloadBytes(captured.toolInput, getLimits().maxToolPayload);
40
- const parent = state.agentState.activeTurn ?? state.agentState.activeAttempt ?? state.agentState.root;
42
+ const parentTool = parentToolCallId ? state.agentState.activeTools.get(parentToolCallId) : undefined;
43
+ const parent = parentTool?.observation ?? state.agentState.activeTurn ?? state.agentState.activeAttempt ?? state.agentState.root;
41
44
  const tool = await startChildObservation({
42
45
  parent,
43
46
  runtime: getRuntime,
@@ -56,6 +59,7 @@ export async function startToolObservation(event: Record<string, unknown>) {
56
59
  ended: false,
57
60
  startedAt: Date.now(),
58
61
  inputBytes,
62
+ parentToolCallId,
59
63
  });
60
64
  } catch (e) {
61
65
  console.warn("📊 Langfuse: Failed to start tool observation", e);
@@ -76,6 +80,8 @@ export async function finishToolObservation(event: Record<string, unknown>) {
76
80
  if (!activeTool || activeTool.ended) {
77
81
  return;
78
82
  }
83
+ // Claim completion before creating the usage child (which awaits the runtime).
84
+ activeTool.ended = true;
79
85
 
80
86
  const isError = Boolean(event.isError ?? event.error ?? event.status === "error");
81
87
  const output =
@@ -92,17 +98,47 @@ export async function finishToolObservation(event: Record<string, unknown>) {
92
98
  const captured = applyCapturePolicy(
93
99
  {
94
100
  toolOutput: shapedOutput,
95
- metadata: {
101
+ metadata: shapePayload({
96
102
  toolName: activeTool.toolName,
97
103
  toolCallId,
104
+ parentToolCallId: activeTool.parentToolCallId,
98
105
  isError,
99
- },
106
+ }) as Record<string, unknown>,
100
107
  },
101
108
  policy,
102
109
  );
103
110
  const outputBytes = estimatePayloadBytes(captured.toolOutput, getLimits().maxToolPayload);
104
111
  const durationMs = Math.max(0, Date.now() - activeTool.startedAt);
105
112
 
113
+ // Pi's tool_result and tool_execution_end carry only this tool's own usage.
114
+ // Do not consume transcript toolResult usage, which includes all descendants.
115
+ // A separate generation makes this cost visible on Langfuse versions that
116
+ // only account for usage/cost on generation observations.
117
+ const { usageDetails, costDetails } = extractToolUsage(event);
118
+ if (usageDetails || costDetails) {
119
+ try {
120
+ const usage = await startChildObservation({
121
+ parent: activeTool.observation,
122
+ runtime: getRuntime,
123
+ name: "tool-usage",
124
+ body: {
125
+ usageDetails,
126
+ costDetails,
127
+ metadata: applyCapturePolicy({ metadata: shapePayload({
128
+ toolName: activeTool.toolName,
129
+ toolCallId,
130
+ parentToolCallId: activeTool.parentToolCallId,
131
+ usageSource: "tool-result",
132
+ }) as Record<string, unknown> }, policy).metadata,
133
+ },
134
+ asType: "generation",
135
+ });
136
+ usage.end();
137
+ } catch (error) {
138
+ console.warn("📊 Langfuse: Failed to record tool usage", error);
139
+ }
140
+ }
141
+
106
142
  activeTool.observation
107
143
  .update({
108
144
  output: captured.toolOutput,
@@ -118,8 +154,6 @@ export async function finishToolObservation(event: Record<string, unknown>) {
118
154
  },
119
155
  })
120
156
  .end();
121
- activeTool.ended = true;
122
-
123
157
  if (isError) {
124
158
  state.errorCount++;
125
159
  await sendScore("tool_is_error", 1, {
package/src/langfuse.ts CHANGED
@@ -29,6 +29,7 @@ interface RestFallbackTrace {
29
29
  input?: unknown;
30
30
  output?: unknown;
31
31
  sessionId?: string;
32
+ userId?: string;
32
33
  metadata?: Record<string, unknown>;
33
34
  }
34
35
 
@@ -558,12 +559,15 @@ function wrapObservation(
558
559
  store.observationById.set(id, record);
559
560
 
560
561
  if (!parentObservationId && !store.trace) {
562
+ const propagated = readPropagatedAttributes(observation);
561
563
  store.trace = {
562
564
  id: traceId,
563
565
  timestamp: record.startTime,
564
566
  name,
565
567
  input: body?.input,
566
- sessionId: typeof metadata?.sessionId === "string" ? metadata.sessionId : state.currentSessionId || undefined,
568
+ sessionId: propagated.sessionId
569
+ ?? (typeof metadata?.sessionId === "string" ? metadata.sessionId : state.currentSessionId || undefined),
570
+ userId: propagated.userId ?? state.config?.userId,
567
571
  metadata,
568
572
  };
569
573
  }
@@ -743,6 +747,7 @@ async function fallbackToRestIngestion(rt: LangfuseRuntime, signal: AbortSignal)
743
747
  input: trace.input,
744
748
  output: trace.output,
745
749
  sessionId: trace.sessionId,
750
+ userId: trace.userId,
746
751
  metadata: trace.metadata,
747
752
  },
748
753
  },
package/src/types.ts CHANGED
@@ -6,6 +6,7 @@ export interface Config {
6
6
  publicKey: string;
7
7
  secretKey: string;
8
8
  host: string;
9
+ userId?: string;
9
10
  capturePolicy?: CapturePolicy;
10
11
  limits?: PayloadLimits;
11
12
  usage?: UsageOptions;
@@ -132,6 +133,7 @@ export interface ToolState {
132
133
  ended: boolean;
133
134
  startedAt: number;
134
135
  inputBytes: number;
136
+ parentToolCallId?: string;
135
137
  }
136
138
 
137
139
  export interface AgentState {
package/src/utils.ts CHANGED
@@ -593,6 +593,29 @@ export function extractCostDetails(messageOrEvent: Record<string, unknown>): Rec
593
593
  };
594
594
  }
595
595
 
596
+ /** Accept both provider model ids and Pi Model objects without serializing the whole model. */
597
+ export function extractModelIdentity(value: unknown, provider?: unknown): { model?: string; provider?: string } {
598
+ const record = value && typeof value === "object" ? value as Record<string, unknown> : undefined;
599
+ const model = record?.id ?? record?.model ?? value;
600
+ const actualProvider = provider ?? record?.provider;
601
+ return {
602
+ ...(typeof model === "string" && model ? { model } : {}),
603
+ ...(typeof actualProvider === "string" && actualProvider ? { provider: actualProvider } : {}),
604
+ };
605
+ }
606
+
607
+ /** Tool hooks report own usage; transcript toolResult messages also include descendants' usage. */
608
+ export function extractToolUsage(event: Record<string, unknown>): {
609
+ usageDetails?: Record<string, number>;
610
+ costDetails?: Record<string, number>;
611
+ } {
612
+ const result = event.result && typeof event.result === "object"
613
+ ? event.result as Record<string, unknown>
614
+ : {};
615
+ const source = { usage: event.usage ?? result.usage, cost: event.cost ?? result.cost };
616
+ return { usageDetails: extractUsage(source), costDetails: extractCostDetails(source) };
617
+ }
618
+
596
619
  export function extractResponseMetadata(event: Record<string, unknown>): Record<string, unknown> {
597
620
  return shapePayload(
598
621
  {