@effect-uai/core 0.8.0 → 0.10.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/dist/{AiError-CAX_48RU.d.mts → AiError-Bf1pq4BB.d.mts} +9 -4
- package/dist/{AiError-CAX_48RU.d.mts.map → AiError-Bf1pq4BB.d.mts.map} +1 -1
- package/dist/{Audio-DPlTsNdz.d.mts → Audio-CR7CSljJ.d.mts} +1 -1
- package/dist/{Audio-DPlTsNdz.d.mts.map → Audio-CR7CSljJ.d.mts.map} +1 -1
- package/dist/{Image-HNmMpMTh.d.mts → Image-BZmKfIdq.d.mts} +1 -1
- package/dist/{Image-HNmMpMTh.d.mts.map → Image-BZmKfIdq.d.mts.map} +1 -1
- package/dist/{Items-7ljaVxH_.d.mts → Items-BnIduEV2.d.mts} +7 -7
- package/dist/{Items-7ljaVxH_.d.mts.map → Items-BnIduEV2.d.mts.map} +1 -1
- package/dist/Loop-D8nWpowc.d.mts +173 -0
- package/dist/Loop-D8nWpowc.d.mts.map +1 -0
- package/dist/{SandboxError-BvKypkJm.d.mts → SandboxError-CIrVDOMV.d.mts} +2 -2
- package/dist/{SandboxError-BvKypkJm.d.mts.map → SandboxError-CIrVDOMV.d.mts.map} +1 -1
- package/dist/{StructuredFormat-BbN4dosH.d.mts → StructuredFormat-DHaUJBdd.d.mts} +2 -2
- package/dist/{StructuredFormat-BbN4dosH.d.mts.map → StructuredFormat-DHaUJBdd.d.mts.map} +1 -1
- package/dist/Tool-2lC_XMth.d.mts +284 -0
- package/dist/Tool-2lC_XMth.d.mts.map +1 -0
- package/dist/ToolEvent-cWZ2BnbS.d.mts +151 -0
- package/dist/ToolEvent-cWZ2BnbS.d.mts.map +1 -0
- package/dist/ToolResult-CL4hK_9z.d.mts +157 -0
- package/dist/ToolResult-CL4hK_9z.d.mts.map +1 -0
- package/dist/Toolkit-Dr9l_hNy.d.mts +196 -0
- package/dist/Toolkit-Dr9l_hNy.d.mts.map +1 -0
- package/dist/browser/Browser.d.mts +190 -0
- package/dist/browser/Browser.d.mts.map +1 -0
- package/dist/browser/Browser.mjs +34 -0
- package/dist/browser/Browser.mjs.map +1 -0
- package/dist/browser/BrowserError.d.mts +141 -0
- package/dist/browser/BrowserError.d.mts.map +1 -0
- package/dist/browser/BrowserError.mjs +89 -0
- package/dist/browser/BrowserError.mjs.map +1 -0
- package/dist/browser/BrowserTool.d.mts +58 -0
- package/dist/browser/BrowserTool.d.mts.map +1 -0
- package/dist/browser/BrowserTool.mjs +86 -0
- package/dist/browser/BrowserTool.mjs.map +1 -0
- package/dist/{dist-DV5ISja1.mjs → dist-B04dlFHg.mjs} +3 -3
- package/dist/{dist-DV5ISja1.mjs.map → dist-B04dlFHg.mjs.map} +1 -1
- package/dist/dist-DWgRbDN3.mjs +140 -0
- package/dist/dist-DWgRbDN3.mjs.map +1 -0
- package/dist/domain/AiError.d.mts +2 -2
- package/dist/domain/AiError.mjs +22 -3
- package/dist/domain/AiError.mjs.map +1 -1
- package/dist/domain/Audio.d.mts +1 -1
- package/dist/domain/Image.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Music.d.mts +1 -1
- package/dist/domain/Turn.d.mts +4 -4
- package/dist/domain/Turn.test.mjs +1 -1
- package/dist/embedding-model/Embedding.d.mts +1 -1
- package/dist/embedding-model/EmbeddingModel.d.mts +1 -1
- package/dist/embedding-model/EmbeddingModel.test.mjs +1 -1
- package/dist/embedding-model/EmbeddingModel.test.mjs.map +1 -1
- package/dist/index.d.mts +19 -13
- package/dist/index.mjs +15 -9
- package/dist/language-model/LanguageModel.d.mts +10 -5
- package/dist/language-model/LanguageModel.d.mts.map +1 -1
- package/dist/language-model/LanguageModel.mjs.map +1 -1
- package/dist/language-model/LanguageModel.test.mjs +1 -1
- package/dist/loop/Loop.d.mts +2 -141
- package/dist/loop/Loop.mjs +16 -20
- package/dist/loop/Loop.mjs.map +1 -1
- package/dist/loop/Loop.test.mjs +18 -1
- package/dist/loop/Loop.test.mjs.map +1 -1
- package/dist/{magic-string.es-BgIV5Mu3.mjs → magic-string.es-Dk-MMA0F.mjs} +1 -1
- package/dist/{magic-string.es-BgIV5Mu3.mjs.map → magic-string.es-Dk-MMA0F.mjs.map} +1 -1
- package/dist/music-generator/MusicGenerator.d.mts +2 -2
- package/dist/music-generator/MusicGenerator.test.mjs +1 -1
- package/dist/observability/Metrics.d.mts +116 -28
- package/dist/observability/Metrics.d.mts.map +1 -1
- package/dist/observability/Metrics.mjs +250 -38
- package/dist/observability/Metrics.mjs.map +1 -1
- package/dist/observability/Metrics.test.d.mts +1 -0
- package/dist/observability/Metrics.test.mjs +187 -0
- package/dist/observability/Metrics.test.mjs.map +1 -0
- package/dist/observability/Telemetry.d.mts +37 -0
- package/dist/observability/Telemetry.d.mts.map +1 -0
- package/dist/observability/Telemetry.mjs +57 -0
- package/dist/observability/Telemetry.mjs.map +1 -0
- package/dist/observability/Telemetry.test.d.mts +1 -0
- package/dist/observability/Telemetry.test.mjs +92 -0
- package/dist/observability/Telemetry.test.mjs.map +1 -0
- package/dist/retry/Retry.d.mts +2 -2
- package/dist/retry/Retry.test.mjs +1 -1
- package/dist/sandbox/Sandbox.d.mts +1 -1
- package/dist/sandbox/SandboxError.d.mts +1 -1
- package/dist/speech-synthesizer/SpeechSynthesizer.d.mts +2 -2
- package/dist/speech-synthesizer/SpeechSynthesizer.test.mjs +1 -1
- package/dist/streaming/JSONL.d.mts +1 -1
- package/dist/streaming/JSONL.test.mjs +1 -1
- package/dist/streaming/SSE.test.mjs +1 -1
- package/dist/structured-format/StructuredFormat.d.mts +1 -1
- package/dist/structured-format/StructuredFormat.mjs.map +1 -1
- package/dist/structured-format/StructuredFormat.test.mjs +1 -1
- package/dist/testing/MockMusicGenerator.d.mts +1 -1
- package/dist/testing/MockProvider.d.mts +1 -1
- package/dist/testing/MockSpeechSynthesizer.d.mts +1 -1
- package/dist/tool/Approval.d.mts +19 -7
- package/dist/tool/Approval.d.mts.map +1 -1
- package/dist/tool/Approval.mjs +18 -11
- package/dist/tool/Approval.mjs.map +1 -1
- package/dist/tool/Approval.test.mjs +80 -40
- package/dist/tool/Approval.test.mjs.map +1 -1
- package/dist/tool/HistoryCheck.d.mts +2 -2
- package/dist/tool/Tool.d.mts +2 -2
- package/dist/tool/Tool.mjs +190 -36
- package/dist/tool/Tool.mjs.map +1 -1
- package/dist/tool/Tool.test.mjs +41 -3
- package/dist/tool/Tool.test.mjs.map +1 -1
- package/dist/tool/ToolEvent.d.mts +2 -151
- package/dist/tool/ToolEvent.mjs +5 -0
- package/dist/tool/ToolEvent.mjs.map +1 -1
- package/dist/tool/ToolResult.d.mts +2 -138
- package/dist/tool/ToolResult.mjs +32 -8
- package/dist/tool/ToolResult.mjs.map +1 -1
- package/dist/tool/Toolkit.d.mts +2 -73
- package/dist/tool/Toolkit.mjs +153 -35
- package/dist/tool/Toolkit.mjs.map +1 -1
- package/dist/tool/Toolkit.test.mjs +271 -11
- package/dist/tool/Toolkit.test.mjs.map +1 -1
- package/dist/transcriber/Transcriber.d.mts +2 -2
- package/dist/transcriber/Transcriber.test.mjs +1 -1
- package/dist/web-read/WebRead.d.mts +44 -0
- package/dist/web-read/WebRead.d.mts.map +1 -0
- package/dist/web-read/WebRead.mjs +18 -0
- package/dist/web-read/WebRead.mjs.map +1 -0
- package/dist/web-read/WebReadTool.d.mts +57 -0
- package/dist/web-read/WebReadTool.d.mts.map +1 -0
- package/dist/web-read/WebReadTool.mjs +59 -0
- package/dist/web-read/WebReadTool.mjs.map +1 -0
- package/dist/web-search/WebSearch.d.mts +1 -1
- package/dist/web-search/WebSearchTool.d.mts +12 -6
- package/dist/web-search/WebSearchTool.d.mts.map +1 -1
- package/dist/web-search/WebSearchTool.mjs +10 -5
- package/dist/web-search/WebSearchTool.mjs.map +1 -1
- package/package.json +21 -1
- package/src/browser/Browser.ts +268 -0
- package/src/browser/BrowserError.ts +145 -0
- package/src/browser/BrowserTool.ts +161 -0
- package/src/domain/AiError.ts +27 -1
- package/src/embedding-model/EmbeddingModel.test.ts +4 -0
- package/src/index.ts +6 -0
- package/src/language-model/LanguageModel.ts +7 -2
- package/src/loop/Loop.test.ts +22 -0
- package/src/loop/Loop.ts +27 -4
- package/src/observability/Metrics.test.ts +266 -0
- package/src/observability/Metrics.ts +505 -65
- package/src/observability/Telemetry.test.ts +123 -0
- package/src/observability/Telemetry.ts +96 -0
- package/src/structured-format/StructuredFormat.ts +4 -0
- package/src/tool/Approval.test.ts +77 -26
- package/src/tool/Approval.ts +72 -27
- package/src/tool/Tool.test.ts +55 -2
- package/src/tool/Tool.ts +335 -70
- package/src/tool/ToolEvent.ts +5 -0
- package/src/tool/ToolResult.ts +36 -9
- package/src/tool/Toolkit.test.ts +339 -9
- package/src/tool/Toolkit.ts +470 -120
- package/src/web-read/WebRead.ts +70 -0
- package/src/web-read/WebReadTool.ts +96 -0
- package/src/web-search/WebSearchTool.ts +11 -5
- package/dist/Tool-BKiieFXk.d.mts +0 -102
- package/dist/Tool-BKiieFXk.d.mts.map +0 -1
- package/dist/loop/Loop.d.mts.map +0 -1
- package/dist/tool/ToolEvent.d.mts.map +0 -1
- package/dist/tool/ToolResult.d.mts.map +0 -1
- package/dist/tool/Toolkit.d.mts.map +0 -1
|
@@ -1,87 +1,527 @@
|
|
|
1
|
-
import {
|
|
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
|
-
*
|
|
5
|
-
*
|
|
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
|
|
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<
|
|
267
|
+
): Stream.Stream<A | MetricEvent, E, R> =>
|
|
11
268
|
Stream.unwrap(
|
|
12
|
-
Effect.map(Clock.currentTimeMillis, (
|
|
269
|
+
Effect.map(Clock.currentTimeMillis, (init) =>
|
|
13
270
|
self.pipe(
|
|
14
|
-
Stream.
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
*
|
|
26
|
-
*
|
|
361
|
+
* The throughput accumulator. Mutated by the source per content delta and read
|
|
362
|
+
* by the metronome per tick.
|
|
27
363
|
*
|
|
28
|
-
*
|
|
29
|
-
* `Stream.broadcast` to fan the source out and run `timeToFirst` on one
|
|
30
|
-
* branch.
|
|
364
|
+
* @internal
|
|
31
365
|
*/
|
|
32
|
-
export
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
*
|
|
50
|
-
*
|
|
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
|
-
*
|
|
392
|
+
* @internal
|
|
58
393
|
*/
|
|
59
|
-
export const
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
)
|