@llm4ts/flow 2.29.0 → 2.30.1

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/src/Timing.ts CHANGED
@@ -2,20 +2,164 @@ import * as Clock from "effect/Clock"
2
2
  import * as Context from "effect/Context"
3
3
  import * as Effect from "effect/Effect"
4
4
  import * as Exit from "effect/Exit"
5
+ import * as Metric from "effect/Metric"
6
+ import * as Option from "effect/Option"
5
7
  import * as Ref from "effect/Ref"
6
8
  import * as Stream from "effect/Stream"
9
+ import type * as Tracer from "effect/Tracer"
7
10
  import type { JudgmentShape } from "@llm4ts/core/judgment/Judgment"
8
11
  import type { LlmServiceShape } from "@llm4ts/core/LlmService"
9
- import type { LlmChunk } from "@llm4ts/core/Models"
12
+ import { Message, type LlmChunk, type TokenUsage } from "@llm4ts/core/Models"
13
+ import { redactText } from "@llm4ts/core/observability/Redaction"
14
+ import { toolCategory } from "./Activity.ts"
15
+ import { estimateUsage, estimatedModelLabel, type EstimatedUsageOptions } from "./EstimatedUsage.ts"
10
16
  import { Timed, type FlowEventsShape } from "./FlowEvents.ts"
17
+ import {
18
+ attr,
19
+ currentKindSpan,
20
+ kindAttribute,
21
+ usageAttributes,
22
+ withKindSpan,
23
+ type OtelContent
24
+ } from "./Spans.ts"
11
25
 
12
26
  /**
13
27
  * Where a run's time goes. `timedSeat` decorates a seat so every
14
28
  * call publishes a `Timed{kind:"model"}` when it ends — its wall time, when
15
29
  * its first output came, and the API and tool time the backend reported, if
16
30
  * it did. The events carry the seat's role, never the prompt or the reply.
31
+ *
32
+ * The same decorator makes every call an LLM span (ADR 0026) with the GenAI
33
+ * usage attributes, each harness tool call a TOOL child of it, and the four
34
+ * counters below; content joins the spans only when `content` says so.
17
35
  */
18
36
 
37
+ export interface TimedSeatOptions {
38
+ /** Who serves the seat, read at each call (a roster hands the coder over). */
39
+ readonly executor?: Effect.Effect<string | undefined>
40
+ /** What content the spans may carry (ADR 0026); off by default. */
41
+ readonly content?: OtelContent
42
+ /** When set, a call the backend reports no usage for gets an estimate on its span, flagged. */
43
+ readonly estimate?: EstimatedUsageOptions
44
+ }
45
+
46
+ const tokensCounter = Metric.counter("llm4ts.tokens", {
47
+ description: "tokens by model, role, executor and direction",
48
+ incremental: true
49
+ })
50
+ const costCounter = Metric.counter("llm4ts.cost.usd", {
51
+ description: "cost the backend reported, USD",
52
+ incremental: true
53
+ })
54
+ const callsCounter = Metric.counter("llm4ts.model.calls", {
55
+ description: "model calls by role and executor",
56
+ incremental: true
57
+ })
58
+ const toolCallsCounter = Metric.counter("llm4ts.tool.calls", {
59
+ description: "harness tool calls by kind of work",
60
+ incremental: true
61
+ })
62
+
63
+ const inputChars = 64_000
64
+ const partChars = 4_000
65
+
66
+ const clean = (text: string, limit: number): string => redactText(text, { maxLength: limit })
67
+
68
+ /** The prompt as content attributes, or nothing: the system prompt only under `full`. */
69
+ const promptContent = (
70
+ content: OtelContent,
71
+ messages: ReadonlyArray<Message>
72
+ ): Record<string, unknown> => {
73
+ if (content === "off") {
74
+ return {}
75
+ }
76
+ const shown = messages.filter((message) => content === "full" || message.role !== "System")
77
+ return {
78
+ [attr.input]: clean(
79
+ shown.map((message) => `${message.role}: ${message.content}`).join("\n\n"),
80
+ inputChars
81
+ ),
82
+ [attr.inputMessages]: JSON.stringify(
83
+ shown.map((message) => ({
84
+ role: message.role.toLowerCase(),
85
+ content: clean(message.content, inputChars)
86
+ }))
87
+ )
88
+ }
89
+ }
90
+
91
+ const replyContent = (content: OtelContent, text: string): Record<string, unknown> =>
92
+ content === "off"
93
+ ? {}
94
+ : {
95
+ [attr.output]: clean(text, inputChars),
96
+ [attr.outputMessages]: JSON.stringify([
97
+ { role: "assistant", content: clean(text, inputChars) }
98
+ ])
99
+ }
100
+
101
+ const count = (
102
+ counter: Metric.Counter<number>,
103
+ attributes: Record<string, string>,
104
+ by: number
105
+ ): Effect.Effect<void> =>
106
+ by === 0 ? Effect.void : Metric.update(by)(Metric.withAttributes(attributes)(counter))
107
+
108
+ /** The usage and the model a call ends with: measured from the chunks, else estimated when asked. */
109
+ const settleUsage = (
110
+ measured: TokenUsage | undefined,
111
+ model: string | undefined,
112
+ promptChars: number,
113
+ completionChars: number,
114
+ estimate: EstimatedUsageOptions | undefined
115
+ ): {
116
+ readonly usage: TokenUsage | undefined
117
+ readonly model: string | undefined
118
+ readonly estimated: boolean
119
+ } =>
120
+ measured !== undefined
121
+ ? { usage: measured, model, estimated: false }
122
+ : estimate === undefined
123
+ ? { usage: undefined, model, estimated: false }
124
+ : {
125
+ usage: estimateUsage(promptChars, completionChars, estimate),
126
+ model: model ?? estimatedModelLabel(estimate.referenceModel),
127
+ estimated: true
128
+ }
129
+
130
+ const recordCall = (
131
+ role: string,
132
+ executor: string | undefined,
133
+ settled: ReturnType<typeof settleUsage>,
134
+ failed: boolean
135
+ ): Effect.Effect<void> => {
136
+ const base = {
137
+ role,
138
+ executor: executor ?? "",
139
+ model: settled.model ?? "",
140
+ estimated: String(settled.estimated)
141
+ }
142
+ return Effect.all(
143
+ [
144
+ count(callsCounter, { role, executor: executor ?? "", failed: String(failed) }, 1),
145
+ settled.usage === undefined
146
+ ? Effect.void
147
+ : Effect.all([
148
+ count(tokensCounter, { ...base, direction: "input" }, settled.usage.prompt),
149
+ count(tokensCounter, { ...base, direction: "output" }, settled.usage.completion),
150
+ settled.estimated || settled.usage.costUsd === undefined
151
+ ? Effect.void
152
+ : count(
153
+ costCounter,
154
+ { role, executor: executor ?? "", model: settled.model ?? "" },
155
+ settled.usage.costUsd
156
+ )
157
+ ])
158
+ ],
159
+ { discard: true }
160
+ )
161
+ }
162
+
19
163
  /**
20
164
  * The role a call is made for, when it is not the seat's own: one seat
21
165
  * serves the reviewer, the story judge and the BLOCKED_ON verifier when no
@@ -53,18 +197,145 @@ export const timeEffect = <A, E, R>(
53
197
  )
54
198
  )
55
199
 
200
+ /**
201
+ * `timeEffect` under an LLM span named for the role; `usageOf` reads what
202
+ * the result says about tokens and model, when it says anything.
203
+ */
204
+ const tracedEffect = <A, E, R>(
205
+ events: FlowEventsShape,
206
+ label: string,
207
+ options: TimedSeatOptions,
208
+ effect: Effect.Effect<A, E, R>,
209
+ timed: (ms: number, failed: boolean, role: string | undefined) => Timed,
210
+ usageOf: (value: A) => {
211
+ readonly usage: TokenUsage | undefined
212
+ readonly model: string | undefined
213
+ }
214
+ ): Effect.Effect<A, E, R> =>
215
+ Effect.gen(function* () {
216
+ const role = (yield* TimedRole) ?? label
217
+ const executor = options.executor === undefined ? undefined : yield* options.executor
218
+ return yield* withKindSpan(
219
+ role,
220
+ {
221
+ kind: "LLM",
222
+ attributes: {
223
+ [attr.role]: role,
224
+ [attr.operation]: "generate",
225
+ ...(executor === undefined ? {} : { [attr.executor]: executor })
226
+ }
227
+ },
228
+ timeEffect(events, effect, timed).pipe(
229
+ Effect.onExit((exit) => {
230
+ const known = Exit.isSuccess(exit)
231
+ ? usageOf(exit.value)
232
+ : { usage: undefined, model: undefined }
233
+ const settled = settleUsage(known.usage, known.model, 0, 0, undefined)
234
+ return Effect.all(
235
+ [
236
+ Effect.annotateCurrentSpan(
237
+ usageAttributes(settled.model, settled.usage, settled.estimated)
238
+ ),
239
+ recordCall(role, executor, settled, Exit.isFailure(exit))
240
+ ],
241
+ { discard: true }
242
+ )
243
+ })
244
+ )
245
+ )
246
+ })
247
+
56
248
  const timedStream = <E>(
57
249
  events: FlowEventsShape,
58
250
  label: string,
251
+ options: TimedSeatOptions,
252
+ messages: ReadonlyArray<Message>,
59
253
  stream: Stream.Stream<LlmChunk, E>
60
254
  ): Stream.Stream<LlmChunk, E> =>
61
255
  Stream.unwrap(
62
256
  Effect.gen(function* () {
63
257
  const start = yield* Clock.currentTimeMillis
64
- const role = yield* TimedRole
258
+ const role = (yield* TimedRole) ?? label
259
+ const executor = options.executor === undefined ? undefined : yield* options.executor
260
+ const content = options.content ?? "off"
65
261
  const first = yield* Ref.make<number | undefined>(undefined)
66
262
  const reported = yield* Ref.make<{ apiMs?: number; toolMs?: number }>({})
67
263
  const failed = yield* Ref.make(false)
264
+ const usage = yield* Ref.make<TokenUsage | undefined>(undefined)
265
+ const model = yield* Ref.make<string | undefined>(undefined)
266
+ const reply = yield* Ref.make("")
267
+ // The call's LLM span (ADR 0026), parented to the nearest kind span and
268
+ // ended when the stream does; each harness tool call is a TOOL child,
269
+ // opened on `tool_use` and closed on `tool_result`.
270
+ const parent = yield* currentKindSpan
271
+ const span = yield* Effect.makeSpan(role, {
272
+ kind: "client",
273
+ sampled: true,
274
+ ...(Option.isSome(parent) ? { parent: parent.value } : {}),
275
+ attributes: {
276
+ [kindAttribute]: "LLM",
277
+ [attr.role]: role,
278
+ [attr.operation]: "chat",
279
+ ...(executor === undefined ? {} : { [attr.executor]: executor }),
280
+ ...promptContent(content, messages)
281
+ }
282
+ })
283
+ const openTools = yield* Ref.make<
284
+ ReadonlyArray<{ readonly id: string | undefined; readonly span: Tracer.Span }>
285
+ >([])
286
+ const toolStarted = (chunk: LlmChunk) =>
287
+ Effect.gen(function* () {
288
+ const tool = (chunk.metadata.tool_name ?? chunk.metadata.toolName ?? "").trim()
289
+ if (tool.length === 0) {
290
+ return
291
+ }
292
+ const args = chunk.metadata.tool_input ?? chunk.metadata.toolInput ?? ""
293
+ const category = toolCategory(tool, args)
294
+ // `Effect.makeSpan`, not the tracer directly: the scope's annotations
295
+ // (story, epic, session) land on it like on every other span.
296
+ const child = yield* Effect.makeSpan(tool, {
297
+ parent: span,
298
+ kind: "internal",
299
+ sampled: true,
300
+ attributes: {
301
+ [kindAttribute]: "TOOL",
302
+ [attr.toolCategory]: category,
303
+ "tool.name": tool,
304
+ ...(content === "off" ? {} : { [attr.input]: clean(args, partChars) })
305
+ }
306
+ })
307
+ const id = chunk.metadata.tool_id ?? chunk.metadata.toolId
308
+ yield* Ref.update(openTools, (open) => [
309
+ ...open,
310
+ { id: id === undefined || id.length === 0 ? undefined : id, span: child }
311
+ ])
312
+ yield* count(toolCallsCounter, { category }, 1)
313
+ })
314
+ const toolEnded = (chunk: LlmChunk) =>
315
+ Effect.gen(function* () {
316
+ const id = chunk.metadata.tool_id ?? chunk.metadata.toolId
317
+ const found = yield* Ref.modify(openTools, (open) => {
318
+ const index =
319
+ id === undefined || id.length === 0 ? 0 : open.findIndex((entry) => entry.id === id)
320
+ const entry = index < 0 ? undefined : open[index]
321
+ return [
322
+ entry,
323
+ entry === undefined ? open : [...open.slice(0, index), ...open.slice(index + 1)]
324
+ ] as const
325
+ })
326
+ if (found === undefined) {
327
+ return
328
+ }
329
+ const toolFailed =
330
+ chunk.metadata.tool_failed === "true" || chunk.metadata.tool_status === "error"
331
+ if (content !== "off") {
332
+ found.span.attribute(attr.output, clean(chunk.metadata.tool_content ?? "", partChars))
333
+ }
334
+ found.span.end(
335
+ yield* Clock.currentTimeNanos,
336
+ toolFailed ? Exit.fail("tool failed") : Exit.void
337
+ )
338
+ })
68
339
  return stream.pipe(
69
340
  Stream.tap((chunk) =>
70
341
  Effect.gen(function* () {
@@ -79,6 +350,20 @@ const timedStream = <E>(
79
350
  ...(toolMs === undefined ? {} : { toolMs })
80
351
  }))
81
352
  }
353
+ if (chunk.usage !== undefined) {
354
+ yield* Ref.set(usage, chunk.usage)
355
+ }
356
+ if (chunk.metadata.model !== undefined) {
357
+ yield* Ref.set(model, chunk.metadata.model)
358
+ }
359
+ if (chunk.delta.length > 0) {
360
+ yield* Ref.update(reply, (text) => text + chunk.delta)
361
+ }
362
+ if (chunk.metadata.event === "tool_use") {
363
+ yield* toolStarted(chunk)
364
+ } else if (chunk.metadata.event === "tool_result") {
365
+ yield* toolEnded(chunk)
366
+ }
82
367
  })
83
368
  ),
84
369
  Stream.tapError(() => Ref.set(failed, true)),
@@ -86,16 +371,37 @@ const timedStream = <E>(
86
371
  Effect.gen(function* () {
87
372
  const end = yield* Clock.currentTimeMillis
88
373
  const firstMs = yield* Ref.get(first)
374
+ const didFail = yield* Ref.get(failed)
89
375
  yield* events.publish(
90
376
  Timed.make({
91
377
  kind: "model",
92
- label: role ?? label,
378
+ label: role,
93
379
  ms: end - start,
94
380
  ...(firstMs === undefined ? {} : { firstMs }),
95
381
  ...(yield* Ref.get(reported)),
96
- ...((yield* Ref.get(failed)) ? { failed: true } : {})
382
+ ...(didFail ? { failed: true } : {})
97
383
  })
98
384
  )
385
+ const text = yield* Ref.get(reply)
386
+ const settled = settleUsage(
387
+ yield* Ref.get(usage),
388
+ yield* Ref.get(model),
389
+ messages.reduce((sum, message) => sum + message.content.length, 0),
390
+ text.length,
391
+ options.estimate
392
+ )
393
+ for (const [key, value] of Object.entries({
394
+ ...usageAttributes(settled.model, settled.usage, settled.estimated),
395
+ ...replyContent(content, text)
396
+ })) {
397
+ span.attribute(key, value)
398
+ }
399
+ yield* recordCall(role, executor, settled, didFail)
400
+ const nanos = yield* Clock.currentTimeNanos
401
+ for (const entry of yield* Ref.get(openTools)) {
402
+ entry.span.end(nanos, Exit.void)
403
+ }
404
+ span.end(nanos, didFail ? Exit.fail("stream failed") : Exit.void)
99
405
  })
100
406
  )
101
407
  )
@@ -105,27 +411,48 @@ const timedStream = <E>(
105
411
  export const timedSeat = (
106
412
  service: LlmServiceShape,
107
413
  events: FlowEventsShape,
108
- label: string
414
+ label: string,
415
+ options: TimedSeatOptions = {}
109
416
  ): LlmServiceShape => {
110
417
  const model = (ms: number, failed: boolean, role: string | undefined): Timed =>
111
418
  Timed.make({ kind: "model", label: role ?? label, ms, ...(failed ? { failed: true } : {}) })
419
+ const nothing = () => ({ usage: undefined, model: undefined })
420
+ const prompt = (text: string): ReadonlyArray<Message> => [
421
+ Message.make({ role: "User", content: text })
422
+ ]
112
423
  const batched = service.scoreLabelSequence
113
424
  return {
114
- executeStream: (prompt) => timedStream(events, label, service.executeStream(prompt)),
425
+ executeStream: (text) =>
426
+ timedStream(events, label, options, prompt(text), service.executeStream(text)),
115
427
  executeStreamWithHistory: (messages) =>
116
- timedStream(events, label, service.executeStreamWithHistory(messages)),
117
- executeWithTools: (prompt, tools) =>
118
- timeEffect(events, service.executeWithTools(prompt, tools), model),
119
- executeStructured: (prompt, schema, jsonSchema) =>
120
- timeEffect(events, service.executeStructured(prompt, schema, jsonSchema), model),
121
- executeStructuredWithUsage: (prompt, schema, jsonSchema) =>
122
- timeEffect(events, service.executeStructuredWithUsage(prompt, schema, jsonSchema), model),
123
- scoreLabels: (prompt, labels) => timeEffect(events, service.scoreLabels(prompt, labels), model),
428
+ timedStream(events, label, options, messages, service.executeStreamWithHistory(messages)),
429
+ executeWithTools: (text, tools) =>
430
+ tracedEffect(events, label, options, service.executeWithTools(text, tools), model, nothing),
431
+ executeStructured: (text, schema, jsonSchema) =>
432
+ tracedEffect(
433
+ events,
434
+ label,
435
+ options,
436
+ service.executeStructured(text, schema, jsonSchema),
437
+ model,
438
+ nothing
439
+ ),
440
+ executeStructuredWithUsage: (text, schema, jsonSchema) =>
441
+ tracedEffect(
442
+ events,
443
+ label,
444
+ options,
445
+ service.executeStructuredWithUsage(text, schema, jsonSchema),
446
+ model,
447
+ ([, usage, modelName]) => ({ usage, model: modelName })
448
+ ),
449
+ scoreLabels: (text, labels) =>
450
+ tracedEffect(events, label, options, service.scoreLabels(text, labels), model, nothing),
124
451
  ...(batched === undefined
125
452
  ? {}
126
453
  : {
127
- scoreLabelSequence: (prompt: string, labelSets: ReadonlyArray<ReadonlyArray<string>>) =>
128
- timeEffect(events, batched(prompt, labelSets), model)
454
+ scoreLabelSequence: (text: string, labelSets: ReadonlyArray<ReadonlyArray<string>>) =>
455
+ tracedEffect(events, label, options, batched(text, labelSets), model, nothing)
129
456
  }),
130
457
  isAvailable: service.isAvailable
131
458
  }