@effect-uai/core 0.8.0 → 0.9.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.
Files changed (132) hide show
  1. package/dist/{AiError-CAX_48RU.d.mts → AiError-CrdR1EQi.d.mts} +2 -2
  2. package/dist/{AiError-CAX_48RU.d.mts.map → AiError-CrdR1EQi.d.mts.map} +1 -1
  3. package/dist/{Audio-DPlTsNdz.d.mts → Audio-DDSKCkSe.d.mts} +2 -2
  4. package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-DDSKCkSe.d.mts.map} +1 -1
  5. package/dist/{Image-HNmMpMTh.d.mts → Image-C_Zjpwhc.d.mts} +2 -2
  6. package/dist/{Image-HNmMpMTh.d.mts.map → Image-C_Zjpwhc.d.mts.map} +1 -1
  7. package/dist/{Items-7ljaVxH_.d.mts → Items-CgKD6Npw.d.mts} +13 -13
  8. package/dist/{Items-7ljaVxH_.d.mts.map → Items-CgKD6Npw.d.mts.map} +1 -1
  9. package/dist/Loop-B5_cqumi.d.mts +173 -0
  10. package/dist/Loop-B5_cqumi.d.mts.map +1 -0
  11. package/dist/{Media-D_CpcM1Z.d.mts → Media-DOLyaygI.d.mts} +1 -1
  12. package/dist/{Media-D_CpcM1Z.d.mts.map → Media-DOLyaygI.d.mts.map} +1 -1
  13. package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
  14. package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
  15. package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-CMJFD3sn.d.mts} +2 -2
  16. package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-CMJFD3sn.d.mts.map} +1 -1
  17. package/dist/Tool-0XUIT2A2.d.mts +232 -0
  18. package/dist/Tool-0XUIT2A2.d.mts.map +1 -0
  19. package/dist/ToolEvent-DYrAUJGs.d.mts +151 -0
  20. package/dist/ToolEvent-DYrAUJGs.d.mts.map +1 -0
  21. package/dist/ToolResult-BZd-cEWW.d.mts +151 -0
  22. package/dist/ToolResult-BZd-cEWW.d.mts.map +1 -0
  23. package/dist/Toolkit-BKC8b8Xo.d.mts +155 -0
  24. package/dist/Toolkit-BKC8b8Xo.d.mts.map +1 -0
  25. package/dist/{dist-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
  26. package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
  27. package/dist/dist-DWgRbDN3.mjs +140 -0
  28. package/dist/dist-DWgRbDN3.mjs.map +1 -0
  29. package/dist/domain/AiError.d.mts +1 -1
  30. package/dist/domain/Audio.d.mts +1 -1
  31. package/dist/domain/Image.d.mts +1 -1
  32. package/dist/domain/Items.d.mts +1 -1
  33. package/dist/domain/Media.d.mts +1 -1
  34. package/dist/domain/Music.d.mts +1 -1
  35. package/dist/domain/Turn.d.mts +4 -4
  36. package/dist/domain/Turn.test.mjs +1 -1
  37. package/dist/embedding-model/Embedding.d.mts +1 -1
  38. package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
  39. package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
  40. package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
  41. package/dist/index.d.mts +14 -13
  42. package/dist/index.mjs +2 -1
  43. package/dist/language-model/LanguageModel.d.mts +10 -5
  44. package/dist/language-model/LanguageModel.d.mts.map +1 -1
  45. package/dist/language-model/LanguageModel.mjs.map +1 -1
  46. package/dist/language-model/LanguageModel.test.mjs +1 -1
  47. package/dist/loop/Loop.d.mts +2 -141
  48. package/dist/loop/Loop.mjs +16 -20
  49. package/dist/loop/Loop.mjs.map +1 -1
  50. package/dist/loop/Loop.test.mjs +18 -1
  51. package/dist/loop/Loop.test.mjs.map +1 -1
  52. package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
  53. package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
  54. package/dist/music-generator/MusicGenerator.d.mts +2 -2
  55. package/dist/music-generator/MusicGenerator.test.mjs +1 -1
  56. package/dist/observability/Metrics.d.mts +116 -28
  57. package/dist/observability/Metrics.d.mts.map +1 -1
  58. package/dist/observability/Metrics.mjs +250 -38
  59. package/dist/observability/Metrics.mjs.map +1 -1
  60. package/dist/observability/Metrics.test.d.mts +1 -0
  61. package/dist/observability/Metrics.test.mjs +187 -0
  62. package/dist/observability/Metrics.test.mjs.map +1 -0
  63. package/dist/observability/Telemetry.d.mts +37 -0
  64. package/dist/observability/Telemetry.d.mts.map +1 -0
  65. package/dist/observability/Telemetry.mjs +57 -0
  66. package/dist/observability/Telemetry.mjs.map +1 -0
  67. package/dist/observability/Telemetry.test.d.mts +1 -0
  68. package/dist/observability/Telemetry.test.mjs +92 -0
  69. package/dist/observability/Telemetry.test.mjs.map +1 -0
  70. package/dist/retry/Retry.d.mts +2 -2
  71. package/dist/retry/Retry.test.mjs +1 -1
  72. package/dist/sandbox/Sandbox.d.mts +1 -1
  73. package/dist/sandbox/SandboxError.d.mts +1 -1
  74. package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
  75. package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
  76. package/dist/streaming/JSONL.d.mts +1 -1
  77. package/dist/streaming/JSONL.test.mjs +1 -1
  78. package/dist/streaming/SSE.test.mjs +1 -1
  79. package/dist/structured-format/StructuredFormat.d.mts +1 -1
  80. package/dist/structured-format/StructuredFormat.mjs.map +1 -1
  81. package/dist/structured-format/StructuredFormat.test.mjs +1 -1
  82. package/dist/testing/MockMusicGenerator.d.mts +1 -1
  83. package/dist/testing/MockProvider.d.mts +1 -1
  84. package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
  85. package/dist/tool/Approval.d.mts +3 -3
  86. package/dist/tool/Approval.test.mjs +35 -34
  87. package/dist/tool/Approval.test.mjs.map +1 -1
  88. package/dist/tool/HistoryCheck.d.mts +2 -2
  89. package/dist/tool/Tool.d.mts +2 -2
  90. package/dist/tool/Tool.mjs +112 -22
  91. package/dist/tool/Tool.mjs.map +1 -1
  92. package/dist/tool/Tool.test.mjs +41 -3
  93. package/dist/tool/Tool.test.mjs.map +1 -1
  94. package/dist/tool/ToolEvent.d.mts +2 -151
  95. package/dist/tool/ToolResult.d.mts +2 -138
  96. package/dist/tool/ToolResult.mjs +18 -3
  97. package/dist/tool/ToolResult.mjs.map +1 -1
  98. package/dist/tool/Toolkit.d.mts +2 -73
  99. package/dist/tool/Toolkit.mjs +106 -28
  100. package/dist/tool/Toolkit.mjs.map +1 -1
  101. package/dist/tool/Toolkit.test.mjs +113 -7
  102. package/dist/tool/Toolkit.test.mjs.map +1 -1
  103. package/dist/transcriber/Transcriber.d.mts +2 -2
  104. package/dist/transcriber/Transcriber.test.mjs +1 -1
  105. package/dist/web-search/WebSearch.d.mts +1 -1
  106. package/dist/web-search/WebSearchTool.d.mts +2 -2
  107. package/dist/web-search/WebSearchTool.d.mts.map +1 -1
  108. package/dist/web-search/WebSearchTool.mjs.map +1 -1
  109. package/package.json +1 -1
  110. package/src/embedding-model/EmbeddingModel.test.ts +4 -0
  111. package/src/index.ts +1 -0
  112. package/src/language-model/LanguageModel.ts +7 -2
  113. package/src/loop/Loop.test.ts +22 -0
  114. package/src/loop/Loop.ts +27 -4
  115. package/src/observability/Metrics.test.ts +266 -0
  116. package/src/observability/Metrics.ts +505 -65
  117. package/src/observability/Telemetry.test.ts +123 -0
  118. package/src/observability/Telemetry.ts +96 -0
  119. package/src/structured-format/StructuredFormat.ts +4 -0
  120. package/src/tool/Approval.test.ts +22 -21
  121. package/src/tool/Tool.test.ts +55 -2
  122. package/src/tool/Tool.ts +221 -55
  123. package/src/tool/ToolResult.ts +22 -1
  124. package/src/tool/Toolkit.test.ts +168 -7
  125. package/src/tool/Toolkit.ts +336 -99
  126. package/src/web-search/WebSearchTool.ts +1 -1
  127. package/dist/Tool-BKiieFXk.d.mts +0 -102
  128. package/dist/Tool-BKiieFXk.d.mts.map +0 -1
  129. package/dist/loop/Loop.d.mts.map +0 -1
  130. package/dist/tool/ToolEvent.d.mts.map +0 -1
  131. package/dist/tool/ToolResult.d.mts.map +0 -1
  132. package/dist/tool/Toolkit.d.mts.map +0 -1
@@ -1,87 +1,527 @@
1
- import { Clock, Duration, Effect, Option, Stream } from "effect"
1
+ import {
2
+ Array as Arr,
3
+ Clock,
4
+ Duration,
5
+ Effect,
6
+ Match,
7
+ Option,
8
+ Predicate,
9
+ Ref,
10
+ Result,
11
+ Schedule,
12
+ Stream,
13
+ } from "effect"
14
+ import type { Usage } from "../domain/Items.js"
15
+ import type { Turn, TurnEvent } from "../domain/Turn.js"
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Self-describing measurements (the exporter-facing payload)
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * The instrument-agnostic description of a single recorded value. The
23
+ * `Telemetry` recorder reads only this: it builds (or reuses) a `Metric`
24
+ * instrument from `kind` + `name` and updates it with `value`. A new metric,
25
+ * built-in or user-defined, never requires a change to the recorder.
26
+ */
27
+ export type Measurement = {
28
+ readonly name: string
29
+ readonly kind: "counter" | "histogram" | "timer" | "gauge"
30
+ readonly value: number | Duration.Duration
31
+ readonly attributes?: Readonly<Record<string, string>> | undefined
32
+ }
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // MetricEvent: an open, brand-marked union (built-ins + user metrics)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ /**
39
+ * Brand stamped on every metric event. `isMetricEvent` checks this symbol,
40
+ * not an enumerated `_tag`, so events are distinguished from `TurnEvent`s,
41
+ * tool outputs, and arbitrary custom loop values regardless of their tag.
42
+ * This is the same TypeId-symbol pattern Effect uses for `isStream` etc.
43
+ */
44
+ export const MetricEventTypeId: unique symbol = Symbol.for(
45
+ "@effect-uai/core/observability/MetricEvent",
46
+ )
47
+
48
+ /**
49
+ * The structural contract every metric event satisfies, ours and custom.
50
+ * The `_tag` + extra typed fields serve a frontend; the brand + `measurements`
51
+ * serve the exporter.
52
+ */
53
+ export type MetricEvent = {
54
+ readonly [MetricEventTypeId]: typeof MetricEventTypeId
55
+ readonly _tag: string
56
+ /** Which turn within the piped stream this sample belongs to (0-based). */
57
+ readonly turnIndex: number
58
+ readonly measurements: ReadonlyArray<Measurement>
59
+ }
60
+
61
+ /** Request/turn start to the first content delta. */
62
+ export type TimeToFirstToken = MetricEvent & {
63
+ readonly _tag: "TimeToFirstToken"
64
+ readonly elapsed: Duration.Duration
65
+ readonly kind: "text" | "reasoning"
66
+ }
67
+
68
+ /** A live output rate, emitted on the metronome cadence. */
69
+ export type Throughput = MetricEvent & {
70
+ readonly _tag: "Throughput"
71
+ readonly ratePerSecond: number
72
+ readonly unit: "char" | "token" | "event"
73
+ readonly window: Duration.Duration
74
+ }
75
+
76
+ /** Provider-reported usage at `TurnComplete`, this turn and cumulative. */
77
+ export type TokenTotals = MetricEvent & {
78
+ readonly _tag: "TokenTotals"
79
+ readonly usage: Usage
80
+ readonly cumulative: Usage
81
+ }
82
+
83
+ /** Per-turn wall times at `TurnComplete`. */
84
+ export type TimeToCompletion = MetricEvent & {
85
+ readonly _tag: "TimeToCompletion"
86
+ readonly duration: Duration.Duration
87
+ readonly generation: Duration.Duration
88
+ }
89
+
90
+ /**
91
+ * Mint a custom metric event. Stamp it with the brand and provide a
92
+ * well-formed `measurements` array; `Telemetry.record` will export it with no
93
+ * changes to the recorder. The only constraint is that each measurement's
94
+ * `kind` is one the recorder maps.
95
+ */
96
+ export const makeEvent = <Fields extends Omit<MetricEvent, typeof MetricEventTypeId>>(
97
+ fields: Fields,
98
+ ): Fields & { readonly [MetricEventTypeId]: typeof MetricEventTypeId } => ({
99
+ [MetricEventTypeId]: MetricEventTypeId,
100
+ ...fields,
101
+ })
102
+
103
+ /** Structural (brand-based) guard for any metric event, built-in or custom. */
104
+ export const isMetricEvent = (u: unknown): u is MetricEvent =>
105
+ Predicate.hasProperty(u, MetricEventTypeId)
2
106
 
3
107
  /**
4
- * Annotate every event in a stream with the elapsed `Duration` since the
5
- * stream started consuming. The first event reports its time-from-start,
6
- * which is also the conventional "time to first ____" metric.
108
+ * Project a stream of mixed events onto just its metric samples, for a side
109
+ * channel or a frontend feed.
7
110
  */
8
- export const withElapsed = <A, E, R>(
111
+ export const metricEvents = <A, E, R>(
112
+ self: Stream.Stream<A, E, R>,
113
+ ): Stream.Stream<MetricEvent, E, R> =>
114
+ self.pipe(Stream.filterMap((ev) => (isMetricEvent(ev) ? Result.succeed(ev) : Result.failVoid)))
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Internal helpers
118
+ // ---------------------------------------------------------------------------
119
+
120
+ const MetricName = {
121
+ ttft: "effect_uai_turn_ttft",
122
+ duration: "effect_uai_turn_duration",
123
+ generation: "effect_uai_turn_generation",
124
+ inputTokens: "effect_uai_input_tokens",
125
+ outputTokens: "effect_uai_output_tokens",
126
+ totalTokens: "effect_uai_total_tokens",
127
+ reasoningTokens: "effect_uai_reasoning_tokens",
128
+ cachedInputTokens: "effect_uai_cached_input_tokens",
129
+ } as const
130
+
131
+ /** Read a structural `_tag` off any value without narrowing to a closed union. */
132
+ const tagOf = (u: unknown): string | undefined =>
133
+ Predicate.hasProperty(u, "_tag") && Predicate.isString(u._tag) ? u._tag : undefined
134
+
135
+ const contentDeltaKind = (ev: unknown): Option.Option<"text" | "reasoning"> => {
136
+ const t = tagOf(ev)
137
+ return t === "TextDelta"
138
+ ? Option.some("text")
139
+ : t === "ReasoningDelta"
140
+ ? Option.some("reasoning")
141
+ : Option.none()
142
+ }
143
+
144
+ const isTurnCompleteEvent = (ev: unknown): boolean => tagOf(ev) === "TurnComplete"
145
+
146
+ const turnOf = (ev: unknown): Turn =>
147
+ (ev as Extract<TurnEvent, { readonly _tag: "TurnComplete" }>).turn
148
+
149
+ const sumOptional = (a: number | undefined, b: number | undefined): number | undefined =>
150
+ a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0)
151
+
152
+ const addUsage = (a: Usage, b: Usage): Usage => {
153
+ const cached = sumOptional(
154
+ a.input_tokens_details?.cached_tokens,
155
+ b.input_tokens_details?.cached_tokens,
156
+ )
157
+ const reasoning = sumOptional(
158
+ a.output_tokens_details?.reasoning_tokens,
159
+ b.output_tokens_details?.reasoning_tokens,
160
+ )
161
+ return {
162
+ input_tokens: sumOptional(a.input_tokens, b.input_tokens),
163
+ output_tokens: sumOptional(a.output_tokens, b.output_tokens),
164
+ total_tokens: sumOptional(a.total_tokens, b.total_tokens),
165
+ input_tokens_details: cached === undefined ? undefined : { cached_tokens: cached },
166
+ output_tokens_details: reasoning === undefined ? undefined : { reasoning_tokens: reasoning },
167
+ }
168
+ }
169
+
170
+ /** Per-turn token counts as incremental counter measurements (present fields only). */
171
+ const usageMeasurements = (usage: Usage): ReadonlyArray<Measurement> => {
172
+ const pairs: ReadonlyArray<readonly [string, number | undefined]> = [
173
+ [MetricName.inputTokens, usage.input_tokens],
174
+ [MetricName.outputTokens, usage.output_tokens],
175
+ [MetricName.totalTokens, usage.total_tokens],
176
+ [MetricName.reasoningTokens, usage.output_tokens_details?.reasoning_tokens],
177
+ [MetricName.cachedInputTokens, usage.input_tokens_details?.cached_tokens],
178
+ ]
179
+ return Arr.filterMap(pairs, ([name, value]) =>
180
+ value === undefined
181
+ ? Result.failVoid
182
+ : Result.succeed<Measurement>({ name, kind: "counter", value }),
183
+ )
184
+ }
185
+
186
+ // ---------------------------------------------------------------------------
187
+ // timeToFirstToken
188
+ // ---------------------------------------------------------------------------
189
+
190
+ export type TimeToFirstTokenOptions = {
191
+ /**
192
+ * Where the clock starts. `"request"` (default) anchors at stream
193
+ * initialization, i.e. when the provider request fires, so the measurement
194
+ * includes connection + prefill. `"first-event"` anchors at the first
195
+ * emitted event, isolating decode latency.
196
+ */
197
+ readonly from?: "request" | "first-event"
198
+ }
199
+
200
+ type TtftState = {
201
+ readonly turnIndex: number
202
+ readonly turnStart: number
203
+ readonly started: boolean
204
+ readonly seen: boolean
205
+ }
206
+
207
+ export const timeToFirstToken =
208
+ (options?: TimeToFirstTokenOptions) =>
209
+ <A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A | MetricEvent, E, R> => {
210
+ const fromRequest = (options?.from ?? "request") === "request"
211
+ return Stream.unwrap(
212
+ Effect.map(Clock.currentTimeMillis, (init) =>
213
+ self.pipe(
214
+ Stream.mapAccumEffect(
215
+ (): TtftState => ({ turnIndex: 0, turnStart: init, started: fromRequest, seen: false }),
216
+ (state, ev) =>
217
+ Effect.map(Clock.currentTimeMillis, (now) => {
218
+ const started = state.started ? state : { ...state, turnStart: now, started: true }
219
+ const kind = contentDeltaKind(ev)
220
+ const emitTtft = !started.seen && Option.isSome(kind)
221
+ const out: ReadonlyArray<A | MetricEvent> = emitTtft
222
+ ? [
223
+ ev,
224
+ makeEvent<Omit<TimeToFirstToken, typeof MetricEventTypeId>>({
225
+ _tag: "TimeToFirstToken",
226
+ turnIndex: started.turnIndex,
227
+ elapsed: Duration.millis(now - started.turnStart),
228
+ kind: (kind as Option.Some<"text" | "reasoning">).value,
229
+ measurements: [
230
+ {
231
+ name: MetricName.ttft,
232
+ kind: "timer",
233
+ value: Duration.millis(now - started.turnStart),
234
+ },
235
+ ],
236
+ }),
237
+ ]
238
+ : [ev]
239
+ const next: TtftState = isTurnCompleteEvent(ev)
240
+ ? {
241
+ turnIndex: started.turnIndex + 1,
242
+ turnStart: now,
243
+ started: fromRequest,
244
+ seen: false,
245
+ }
246
+ : { ...started, seen: started.seen || emitTtft }
247
+ return [next, out] as const
248
+ }),
249
+ ),
250
+ ),
251
+ ),
252
+ )
253
+ }
254
+
255
+ // ---------------------------------------------------------------------------
256
+ // timeToCompletion
257
+ // ---------------------------------------------------------------------------
258
+
259
+ type CompletionState = {
260
+ readonly turnIndex: number
261
+ readonly turnStart: number
262
+ readonly firstTokenAt: Option.Option<number>
263
+ }
264
+
265
+ export const timeToCompletion = <A, E, R>(
9
266
  self: Stream.Stream<A, E, R>,
10
- ): Stream.Stream<{ readonly value: A; readonly elapsed: Duration.Duration }, E, R> =>
267
+ ): Stream.Stream<A | MetricEvent, E, R> =>
11
268
  Stream.unwrap(
12
- Effect.map(Clock.currentTimeMillis, (start) =>
269
+ Effect.map(Clock.currentTimeMillis, (init) =>
13
270
  self.pipe(
14
- Stream.mapEffect((value) =>
15
- Effect.map(Clock.currentTimeMillis, (now) => ({
16
- value,
17
- elapsed: Duration.millis(now - start),
18
- })),
271
+ Stream.mapAccumEffect(
272
+ (): CompletionState => ({ turnIndex: 0, turnStart: init, firstTokenAt: Option.none() }),
273
+ (state, ev) =>
274
+ Effect.map(Clock.currentTimeMillis, (now) => {
275
+ const firstTokenAt =
276
+ Option.isNone(state.firstTokenAt) && Option.isSome(contentDeltaKind(ev))
277
+ ? Option.some(now)
278
+ : state.firstTokenAt
279
+ if (!isTurnCompleteEvent(ev)) {
280
+ return [{ ...state, firstTokenAt }, [ev]] as const
281
+ }
282
+ const duration = Duration.millis(now - state.turnStart)
283
+ const generation = Option.match(firstTokenAt, {
284
+ onNone: () => Duration.zero,
285
+ onSome: (ft) => Duration.millis(now - ft),
286
+ })
287
+ const event = makeEvent<Omit<TimeToCompletion, typeof MetricEventTypeId>>({
288
+ _tag: "TimeToCompletion",
289
+ turnIndex: state.turnIndex,
290
+ duration,
291
+ generation,
292
+ measurements: [
293
+ { name: MetricName.duration, kind: "timer", value: duration },
294
+ { name: MetricName.generation, kind: "timer", value: generation },
295
+ ],
296
+ })
297
+ const next: CompletionState = {
298
+ turnIndex: state.turnIndex + 1,
299
+ turnStart: now,
300
+ firstTokenAt: Option.none(),
301
+ }
302
+ return [next, [ev, event]] as const
303
+ }),
19
304
  ),
20
305
  ),
21
306
  ),
22
307
  )
23
308
 
309
+ // ---------------------------------------------------------------------------
310
+ // tokenTotals
311
+ // ---------------------------------------------------------------------------
312
+
313
+ const emptyUsage: Usage = {}
314
+
315
+ type TotalsState = {
316
+ readonly turnIndex: number
317
+ readonly cumulative: Usage
318
+ }
319
+
320
+ export const tokenTotals = <A, E, R>(
321
+ self: Stream.Stream<A, E, R>,
322
+ ): Stream.Stream<A | MetricEvent, E, R> =>
323
+ self.pipe(
324
+ Stream.mapAccumEffect(
325
+ (): TotalsState => ({ turnIndex: 0, cumulative: emptyUsage }),
326
+ (state, ev) =>
327
+ Effect.sync(() => {
328
+ if (!isTurnCompleteEvent(ev)) return [state, [ev]] as const
329
+ const usage = turnOf(ev).usage
330
+ const cumulative = addUsage(state.cumulative, usage)
331
+ const event = makeEvent<Omit<TokenTotals, typeof MetricEventTypeId>>({
332
+ _tag: "TokenTotals",
333
+ turnIndex: state.turnIndex,
334
+ usage,
335
+ cumulative,
336
+ measurements: usageMeasurements(usage),
337
+ })
338
+ return [{ turnIndex: state.turnIndex + 1, cumulative }, [ev, event]] as const
339
+ }),
340
+ ),
341
+ )
342
+
343
+ // ---------------------------------------------------------------------------
344
+ // throughput
345
+ // ---------------------------------------------------------------------------
346
+
347
+ export type ThroughputOptions = {
348
+ /** Tick interval. Default `"1 second"`. */
349
+ readonly every?: Duration.Input
350
+ /** What a "unit" is. Default `"char"` (exact and chunking-independent). */
351
+ readonly unit?: "char" | "token" | "event"
352
+ /** Token counter for `unit: "token"`. Absent => approximate 1 per content delta. */
353
+ readonly tokenizer?: (event: TurnEvent) => Effect.Effect<number>
354
+ /** Rate definition. Default `"windowed"` (current speed, no cold-start bias). */
355
+ readonly mode?: "windowed" | "cumulative"
356
+ /** EWMA smoothing. Omitted => off; `"default"` => a sensible factor. */
357
+ readonly smooth?: "default" | number
358
+ }
359
+
24
360
  /**
25
- * Compute the elapsed time until the first event matching the predicate.
26
- * Returns `Option.none()` if the stream completes without one.
361
+ * The throughput accumulator. Mutated by the source per content delta and read
362
+ * by the metronome per tick.
27
363
  *
28
- * Consumes the stream. To track this *alongside* live consumption, use
29
- * `Stream.broadcast` to fan the source out and run `timeToFirst` on one
30
- * branch.
364
+ * @internal
31
365
  */
32
- export const timeToFirst =
33
- <A>(predicate: (a: A) => boolean) =>
34
- <E, R>(self: Stream.Stream<A, E, R>): Effect.Effect<Option.Option<Duration.Duration>, E, R> =>
35
- withElapsed(self).pipe(
36
- Stream.filter(({ value }) => predicate(value)),
37
- Stream.runHead,
38
- Effect.map(Option.map(({ elapsed }) => elapsed)),
39
- )
366
+ export type RateState = {
367
+ readonly units: number
368
+ readonly lastUnits: number
369
+ readonly lastMillis: number
370
+ readonly firstMillis: Option.Option<number>
371
+ readonly smoothed: Option.Option<number>
372
+ readonly turnIndex: number
373
+ }
40
374
 
41
- export type RatePoint<A> = {
42
- readonly value: A
43
- readonly total: number
44
- readonly ratePerSecond: number
45
- readonly elapsed: Duration.Duration
375
+ const initRateState: RateState = {
376
+ units: 0,
377
+ lastUnits: 0,
378
+ lastMillis: 0,
379
+ firstMillis: Option.none(),
380
+ smoothed: Option.none(),
381
+ turnIndex: 0,
46
382
  }
47
383
 
384
+ const resolveSmooth = (smooth: ThroughputOptions["smooth"]): number | undefined =>
385
+ smooth === undefined ? undefined : smooth === "default" ? 0.3 : smooth
386
+
48
387
  /**
49
- * Annotate every event with a running total and a rolling rate per second,
50
- * computed from a user-supplied weight function.
51
- *
52
- * The weight is the unit you care about - bytes, tokens, error count, etc.
53
- * For tokens-per-second on `TurnEvent`, pass:
54
- *
55
- * `(d) => d._tag === "TextDelta" ? countTokens(d.text) : 0`
388
+ * Pure per-tick rate computation. Returns `None` before the first token, else
389
+ * the rate, the window it was measured over, and the next accumulator state.
390
+ * Separated from the metronome so the rate math is unit-testable without time.
56
391
  *
57
- * Use any tokenizer you like; the library does not ship one.
392
+ * @internal
58
393
  */
59
- export const withRate =
60
- <A>(weight: (a: A) => number) =>
61
- <E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<RatePoint<A>, E, R> =>
62
- Stream.unwrap(
63
- Effect.map(Clock.currentTimeMillis, (start) =>
64
- self.pipe(
65
- Stream.mapAccumEffect(
66
- () => ({ total: 0 }),
67
- (acc, value) =>
68
- Effect.map(Clock.currentTimeMillis, (now) => {
69
- const total = acc.total + weight(value)
70
- const elapsedMs = now - start
71
- const ratePerSecond = elapsedMs > 0 ? (total / elapsedMs) * 1000 : 0
72
- return [
73
- { total },
74
- [
75
- {
76
- value,
77
- total,
78
- ratePerSecond,
79
- elapsed: Duration.millis(elapsedMs),
80
- } satisfies RatePoint<A>,
81
- ],
82
- ] as const
83
- }),
394
+ export const computeThroughputTick = (
395
+ state: RateState,
396
+ now: number,
397
+ mode: "windowed" | "cumulative",
398
+ smooth: number | undefined,
399
+ ): Option.Option<{
400
+ readonly rate: number
401
+ readonly window: Duration.Duration
402
+ readonly next: RateState
403
+ }> =>
404
+ Option.map(state.firstMillis, (first) => {
405
+ const windowMs = mode === "windowed" ? now - state.lastMillis : now - first
406
+ const deltaUnits = mode === "windowed" ? state.units - state.lastUnits : state.units
407
+ const instant = windowMs > 0 ? (deltaUnits / windowMs) * 1000 : 0
408
+ const rate =
409
+ smooth === undefined
410
+ ? instant
411
+ : Option.match(state.smoothed, {
412
+ onNone: () => instant,
413
+ onSome: (prev) => smooth * instant + (1 - smooth) * prev,
414
+ })
415
+ return {
416
+ rate,
417
+ window: Duration.millis(windowMs),
418
+ next: { ...state, lastUnits: state.units, lastMillis: now, smoothed: Option.some(rate) },
419
+ }
420
+ })
421
+
422
+ const addUnits = (state: RateState, n: number, now: number): RateState =>
423
+ Option.isNone(state.firstMillis)
424
+ ? { ...state, units: n, lastUnits: 0, lastMillis: now, firstMillis: Option.some(now) }
425
+ : { ...state, units: state.units + n }
426
+
427
+ const resetTurn = (state: RateState): RateState => ({
428
+ ...initRateState,
429
+ turnIndex: state.turnIndex + 1,
430
+ })
431
+
432
+ const unitCount = (
433
+ ev: unknown,
434
+ unit: "char" | "token" | "event",
435
+ tokenizer: ThroughputOptions["tokenizer"],
436
+ ): Effect.Effect<number> => {
437
+ if (tagOf(ev) !== "TextDelta") return Effect.succeed(0)
438
+ const text = (ev as { readonly text: string }).text
439
+ return Match.value(unit).pipe(
440
+ Match.when("char", () => Effect.succeed([...text].length)),
441
+ Match.when("event", () => Effect.succeed(1)),
442
+ Match.when("token", () =>
443
+ tokenizer === undefined ? Effect.succeed(1) : tokenizer(ev as TurnEvent),
444
+ ),
445
+ Match.exhaustive,
446
+ )
447
+ }
448
+
449
+ const throughputName = (unit: "char" | "token" | "event"): string =>
450
+ `effect_uai_output_${unit}s_per_second`
451
+
452
+ export const throughput =
453
+ (options?: ThroughputOptions) =>
454
+ <A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A | MetricEvent, E, R> => {
455
+ const unit = options?.unit ?? "char"
456
+ const mode = options?.mode ?? "windowed"
457
+ const every = options?.every ?? Duration.seconds(1)
458
+ const smooth = resolveSmooth(options?.smooth)
459
+ const tokenizer = options?.tokenizer
460
+ const name = throughputName(unit)
461
+
462
+ return Stream.unwrap(
463
+ Effect.map(Ref.make(initRateState), (ref) => {
464
+ const source = self.pipe(
465
+ Stream.tap((ev) =>
466
+ isTurnCompleteEvent(ev)
467
+ ? Ref.update(ref, resetTurn)
468
+ : tagOf(ev) === "TextDelta"
469
+ ? Effect.flatMap(Clock.currentTimeMillis, (now) =>
470
+ Effect.flatMap(unitCount(ev, unit, tokenizer), (n) =>
471
+ Ref.update(ref, (state) => addUnits(state, n, now)),
472
+ ),
473
+ )
474
+ : Effect.void,
84
475
  ),
85
- ),
86
- ),
476
+ )
477
+ const metronome: Stream.Stream<MetricEvent> = Stream.fromSchedule(
478
+ Schedule.spaced(every),
479
+ ).pipe(
480
+ Stream.mapEffect(() =>
481
+ Effect.flatMap(Clock.currentTimeMillis, (now) =>
482
+ Ref.modify(ref, (state) =>
483
+ Option.match(computeThroughputTick(state, now, mode, smooth), {
484
+ onNone: () => [Option.none<MetricEvent>(), state] as const,
485
+ onSome: ({ next, rate, window }) =>
486
+ [
487
+ Option.some<MetricEvent>(
488
+ makeEvent<Omit<Throughput, typeof MetricEventTypeId>>({
489
+ _tag: "Throughput",
490
+ turnIndex: state.turnIndex,
491
+ ratePerSecond: rate,
492
+ unit,
493
+ window,
494
+ measurements: [{ name, kind: "histogram", value: rate }],
495
+ }),
496
+ ),
497
+ next,
498
+ ] as const,
499
+ }),
500
+ ),
501
+ ),
502
+ ),
503
+ Stream.filterMap((o) => (Option.isSome(o) ? Result.succeed(o.value) : Result.failVoid)),
504
+ )
505
+ return source.pipe(Stream.merge(metronome, { haltStrategy: "left" }))
506
+ }),
507
+ )
508
+ }
509
+
510
+ // ---------------------------------------------------------------------------
511
+ // allMetrics
512
+ // ---------------------------------------------------------------------------
513
+
514
+ export type AllMetricsOptions = {
515
+ readonly timeToFirstToken?: TimeToFirstTokenOptions
516
+ readonly throughput?: ThroughputOptions
517
+ }
518
+
519
+ export const allMetrics =
520
+ (options?: AllMetricsOptions) =>
521
+ <A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A | MetricEvent, E, R> =>
522
+ self.pipe(
523
+ timeToFirstToken(options?.timeToFirstToken),
524
+ throughput(options?.throughput),
525
+ tokenTotals,
526
+ timeToCompletion,
87
527
  )