pi-langfuse 1.6.1 → 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 +8 -0
- package/README_CN.md +6 -0
- package/index.ts +7 -1
- package/package.json +2 -2
- package/src/handlers/generation.ts +37 -10
- package/src/handlers/system-state.ts +4 -1
- package/src/handlers/tool.ts +40 -6
- package/src/types.ts +1 -0
- package/src/utils.ts +23 -0
package/README.md
CHANGED
|
@@ -273,10 +273,18 @@ This command makes a timeout-bounded authenticated request to Langfuse and, if i
|
|
|
273
273
|
- Each user prompt within that session becomes a separate trace.
|
|
274
274
|
- The trace contains the final assistant output shown in Pi.
|
|
275
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.
|
|
276
280
|
- LLM requests appear as generation observations, including usage and cost when the provider exposes them.
|
|
277
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.
|
|
278
284
|
- Pi 0.86 transcript-aware prompt/tool state appears as `system-state` events. Generations reference the
|
|
279
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.
|
|
280
288
|
- Automatic retries are grouped under `agent-attempt` spans and the trace remains open until `agent_settled`.
|
|
281
289
|
- Cache-warming decisions and persisted `cache_warm` usage are reported, including standalone session-grouped
|
|
282
290
|
traces when warming happens while no user-request trace is active.
|
package/README_CN.md
CHANGED
|
@@ -254,10 +254,16 @@ pi list
|
|
|
254
254
|
- 该会话中的每个用户提示词都会生成一个独立 trace。
|
|
255
255
|
- trace 中会包含 Pi 实际显示的最终助手回复。
|
|
256
256
|
- 工具执行会以工具观察节点展示参数、结果和错误状态。
|
|
257
|
+
Pi 1.0 的嵌套调用(含 codemode 内部工具)按 `parentToolCallId` 建立父子关系。
|
|
258
|
+
工具自身的模型用量和费用记录在子级 `tool-usage` generation 中,不会重复上报 transcript 中包含
|
|
259
|
+
后代工具的合计用量。Pi 的工具 usage 未标识每个内部模型,因此这些聚合节点不推断模型名称或价格。
|
|
257
260
|
- 模型请求会以生成观察节点展示;如果提供商暴露相关信息,还会包含用量和成本。
|
|
258
261
|
开启 `PI_LANGFUSE_SPLIT_REASONING_TOKENS` 后,推理 token 会作为独立用量桶上报。
|
|
262
|
+
虚拟模型路由分别记录 `selectedModel`/`selectedProvider` 和 `actualModel`/`actualProvider`;
|
|
263
|
+
获得实际模型身份后,generation 的 model 和 `provider` 元数据使用实际值。
|
|
259
264
|
- Pi 0.86 的 transcript-aware system/tool 状态会作为 `system-state` 事件上报;generation 会引用有效的
|
|
260
265
|
prompt/tool 状态指纹、活动工具数、更新传输方式和缓存命中率。
|
|
266
|
+
每次 provider 请求前都会刷新状态,覆盖工具延迟加载;同一 attempt 内的未变化状态不会重复生成事件。
|
|
261
267
|
- 自动重试会归入 `agent-attempt` span,trace 会持续到 `agent_settled` 才结束。
|
|
262
268
|
- cache warming 决策及持久化的 `cache_warm` 用量会被记录;若 warming 发生时没有活动的用户请求 trace,
|
|
263
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
|
|
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.
|
|
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.
|
|
65
|
+
"@earendil-works/pi-coding-agent": "^1.0.0",
|
|
66
66
|
"tsx": "^4.19.0",
|
|
67
67
|
"typescript": "^6.0.3"
|
|
68
68
|
}
|
|
@@ -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
|
|
47
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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 }))
|
package/src/handlers/tool.ts
CHANGED
|
@@ -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
|
|
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/types.ts
CHANGED
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
|
{
|