@effect-uai/core 0.11.0 → 0.12.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/{Items-BWNu6VGp.d.mts → Items-BKQgqJ8A.d.mts} +21 -8
- package/dist/Items-BKQgqJ8A.d.mts.map +1 -0
- package/dist/{Tool-CXFHCQrT.d.mts → Tool-u5bag8SO.d.mts} +2 -2
- package/dist/{Tool-CXFHCQrT.d.mts.map → Tool-u5bag8SO.d.mts.map} +1 -1
- package/dist/{ToolEvent-nVijCrvm.d.mts → ToolEvent-BBfOdhF_.d.mts} +2 -2
- package/dist/{ToolEvent-nVijCrvm.d.mts.map → ToolEvent-BBfOdhF_.d.mts.map} +1 -1
- package/dist/{ToolResult-CQqKkcDa.d.mts → ToolResult-CRpmkBXR.d.mts} +2 -2
- package/dist/{ToolResult-CQqKkcDa.d.mts.map → ToolResult-CRpmkBXR.d.mts.map} +1 -1
- package/dist/{Toolkit-BYPwwu-j.d.mts → Toolkit-D4mQvhjq.d.mts} +5 -5
- package/dist/{Toolkit-BYPwwu-j.d.mts.map → Toolkit-D4mQvhjq.d.mts.map} +1 -1
- package/dist/browser/BrowserTool.d.mts +1 -1
- package/dist/domain/Items.d.mts +1 -1
- package/dist/domain/Items.mjs +15 -1
- package/dist/domain/Items.mjs.map +1 -1
- package/dist/domain/Research.d.mts +1 -1
- package/dist/domain/Turn.d.mts +2 -1
- package/dist/domain/Turn.d.mts.map +1 -1
- package/dist/index.d.mts +5 -5
- package/dist/language-model/LanguageModel.d.mts +2 -2
- package/dist/observability/Metrics.d.mts +13 -5
- package/dist/observability/Metrics.d.mts.map +1 -1
- package/dist/observability/Metrics.mjs +30 -7
- package/dist/observability/Metrics.mjs.map +1 -1
- package/dist/observability/Metrics.test.mjs +42 -0
- package/dist/observability/Metrics.test.mjs.map +1 -1
- package/dist/retry/Retry.d.mts +3 -3
- package/dist/retry/Retry.d.mts.map +1 -1
- package/dist/retry/Retry.mjs +30 -1
- package/dist/retry/Retry.mjs.map +1 -1
- package/dist/testing/MockProvider.d.mts +1 -1
- package/dist/testing/MockSandbox.d.mts +1 -1
- package/dist/tool/Approval.d.mts +3 -3
- package/dist/tool/HistoryCheck.d.mts +2 -2
- package/dist/tool/Tool.d.mts +1 -1
- package/dist/tool/ToolEvent.d.mts +1 -1
- package/dist/tool/ToolResult.d.mts +1 -1
- package/dist/tool/Toolkit.d.mts +1 -1
- package/dist/web-read/WebReadTool.d.mts +1 -1
- package/dist/web-search/WebSearchTool.d.mts +1 -1
- package/package.json +1 -1
- package/src/domain/Items.ts +18 -0
- package/src/observability/Metrics.test.ts +65 -0
- package/src/observability/Metrics.ts +53 -11
- package/src/retry/Retry.ts +44 -3
- package/dist/Items-BWNu6VGp.d.mts.map +0 -1
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Metrics.mjs","names":["Arr"],"sources":["../../src/observability/Metrics.ts"],"sourcesContent":["import {\n Array as Arr,\n Clock,\n Duration,\n Effect,\n Match,\n Option,\n Predicate,\n Ref,\n Result,\n Schedule,\n Stream,\n} from \"effect\"\nimport type { Usage } from \"../domain/Items.js\"\nimport type { Turn, TurnEvent } from \"../domain/Turn.js\"\n\n// ---------------------------------------------------------------------------\n// Self-describing measurements (the exporter-facing payload)\n// ---------------------------------------------------------------------------\n\n/**\n * The instrument-agnostic description of a single recorded value. The\n * `Telemetry` recorder reads only this: it builds (or reuses) a `Metric`\n * instrument from `kind` + `name` and updates it with `value`. A new metric,\n * built-in or user-defined, never requires a change to the recorder.\n */\nexport type Measurement = {\n readonly name: string\n readonly kind: \"counter\" | \"histogram\" | \"timer\" | \"gauge\"\n readonly value: number | Duration.Duration\n readonly attributes?: Readonly<Record<string, string>> | undefined\n}\n\n// ---------------------------------------------------------------------------\n// MetricEvent: an open, brand-marked union (built-ins + user metrics)\n// ---------------------------------------------------------------------------\n\n/**\n * Brand stamped on every metric event. `isMetricEvent` checks this symbol,\n * not an enumerated `_tag`, so events are distinguished from `TurnEvent`s,\n * tool outputs, and arbitrary custom loop values regardless of their tag.\n * This is the same TypeId-symbol pattern Effect uses for `isStream` etc.\n */\nexport const MetricEventTypeId: unique symbol = Symbol.for(\n \"@effect-uai/core/observability/MetricEvent\",\n)\n\n/**\n * The structural contract every metric event satisfies, ours and custom.\n * The `_tag` + extra typed fields serve a frontend; the brand + `measurements`\n * serve the exporter.\n */\nexport type MetricEvent = {\n readonly [MetricEventTypeId]: typeof MetricEventTypeId\n readonly _tag: string\n /** Which turn within the piped stream this sample belongs to (0-based). */\n readonly turnIndex: number\n readonly measurements: ReadonlyArray<Measurement>\n}\n\n/** Request/turn start to the first content delta. */\nexport type TimeToFirstToken = MetricEvent & {\n readonly _tag: \"TimeToFirstToken\"\n readonly elapsed: Duration.Duration\n readonly kind: \"text\" | \"reasoning\"\n}\n\n/** A live output rate, emitted on the metronome cadence. */\nexport type Throughput = MetricEvent & {\n readonly _tag: \"Throughput\"\n readonly ratePerSecond: number\n readonly unit: \"char\" | \"token\" | \"event\"\n readonly window: Duration.Duration\n}\n\n/** Provider-reported usage at `TurnComplete`, this turn and cumulative. */\nexport type TokenTotals = MetricEvent & {\n readonly _tag: \"TokenTotals\"\n readonly usage: Usage\n readonly cumulative: Usage\n}\n\n/** Per-turn wall times at `TurnComplete`. */\nexport type TimeToCompletion = MetricEvent & {\n readonly _tag: \"TimeToCompletion\"\n readonly duration: Duration.Duration\n readonly generation: Duration.Duration\n}\n\n/**\n * Mint a custom metric event. Stamp it with the brand and provide a\n * well-formed `measurements` array; `Telemetry.record` will export it with no\n * changes to the recorder. The only constraint is that each measurement's\n * `kind` is one the recorder maps.\n */\nexport const makeEvent = <Fields extends Omit<MetricEvent, typeof MetricEventTypeId>>(\n fields: Fields,\n): Fields & { readonly [MetricEventTypeId]: typeof MetricEventTypeId } => ({\n [MetricEventTypeId]: MetricEventTypeId,\n ...fields,\n})\n\n/** Structural (brand-based) guard for any metric event, built-in or custom. */\nexport const isMetricEvent = (u: unknown): u is MetricEvent =>\n Predicate.hasProperty(u, MetricEventTypeId)\n\n/**\n * Project a stream of mixed events onto just its metric samples, for a side\n * channel or a frontend feed.\n */\nexport const metricEvents = <A, E, R>(\n self: Stream.Stream<A, E, R>,\n): Stream.Stream<MetricEvent, E, R> =>\n self.pipe(Stream.filterMap((ev) => (isMetricEvent(ev) ? Result.succeed(ev) : Result.failVoid)))\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nconst MetricName = {\n ttft: \"effect_uai_turn_ttft\",\n duration: \"effect_uai_turn_duration\",\n generation: \"effect_uai_turn_generation\",\n inputTokens: \"effect_uai_input_tokens\",\n outputTokens: \"effect_uai_output_tokens\",\n totalTokens: \"effect_uai_total_tokens\",\n reasoningTokens: \"effect_uai_reasoning_tokens\",\n cachedInputTokens: \"effect_uai_cached_input_tokens\",\n} as const\n\n/** Read a structural `_tag` off any value without narrowing to a closed union. */\nconst tagOf = (u: unknown): string | undefined =>\n Predicate.hasProperty(u, \"_tag\") && Predicate.isString(u._tag) ? u._tag : undefined\n\nconst contentDeltaKind = (ev: unknown): Option.Option<\"text\" | \"reasoning\"> => {\n const t = tagOf(ev)\n return t === \"TextDelta\"\n ? Option.some(\"text\")\n : t === \"ReasoningDelta\"\n ? Option.some(\"reasoning\")\n : Option.none()\n}\n\nconst isTurnCompleteEvent = (ev: unknown): boolean => tagOf(ev) === \"TurnComplete\"\n\nconst turnOf = (ev: unknown): Turn =>\n (ev as Extract<TurnEvent, { readonly _tag: \"TurnComplete\" }>).turn\n\nconst sumOptional = (a: number | undefined, b: number | undefined): number | undefined =>\n a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0)\n\nconst addUsage = (a: Usage, b: Usage): Usage => {\n const cached = sumOptional(\n a.input_tokens_details?.cached_tokens,\n b.input_tokens_details?.cached_tokens,\n )\n const reasoning = sumOptional(\n a.output_tokens_details?.reasoning_tokens,\n b.output_tokens_details?.reasoning_tokens,\n )\n return {\n input_tokens: sumOptional(a.input_tokens, b.input_tokens),\n output_tokens: sumOptional(a.output_tokens, b.output_tokens),\n total_tokens: sumOptional(a.total_tokens, b.total_tokens),\n input_tokens_details: cached === undefined ? undefined : { cached_tokens: cached },\n output_tokens_details: reasoning === undefined ? undefined : { reasoning_tokens: reasoning },\n }\n}\n\n/** Per-turn token counts as incremental counter measurements (present fields only). */\nconst usageMeasurements = (usage: Usage): ReadonlyArray<Measurement> => {\n const pairs: ReadonlyArray<readonly [string, number | undefined]> = [\n [MetricName.inputTokens, usage.input_tokens],\n [MetricName.outputTokens, usage.output_tokens],\n [MetricName.totalTokens, usage.total_tokens],\n [MetricName.reasoningTokens, usage.output_tokens_details?.reasoning_tokens],\n [MetricName.cachedInputTokens, usage.input_tokens_details?.cached_tokens],\n ]\n return Arr.filterMap(pairs, ([name, value]) =>\n value === undefined\n ? Result.failVoid\n : Result.succeed<Measurement>({ name, kind: \"counter\", value }),\n )\n}\n\n// ---------------------------------------------------------------------------\n// timeToFirstToken\n// ---------------------------------------------------------------------------\n\nexport type TimeToFirstTokenOptions = {\n /**\n * Where the clock starts. `\"request\"` (default) anchors at stream\n * initialization, i.e. when the provider request fires, so the measurement\n * includes connection + prefill. `\"first-event\"` anchors at the first\n * emitted event, isolating decode latency.\n */\n readonly from?: \"request\" | \"first-event\"\n}\n\ntype TtftState = {\n readonly turnIndex: number\n readonly turnStart: number\n readonly started: boolean\n readonly seen: boolean\n}\n\nexport const timeToFirstToken =\n (options?: TimeToFirstTokenOptions) =>\n <A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A | MetricEvent, E, R> => {\n const fromRequest = (options?.from ?? \"request\") === \"request\"\n return Stream.unwrap(\n Effect.map(Clock.currentTimeMillis, (init) =>\n self.pipe(\n Stream.mapAccumEffect(\n (): TtftState => ({ turnIndex: 0, turnStart: init, started: fromRequest, seen: false }),\n (state, ev) =>\n Effect.map(Clock.currentTimeMillis, (now) => {\n const started = state.started ? state : { ...state, turnStart: now, started: true }\n const kind = contentDeltaKind(ev)\n const emitTtft = !started.seen && Option.isSome(kind)\n const out: ReadonlyArray<A | MetricEvent> = emitTtft\n ? [\n ev,\n makeEvent<Omit<TimeToFirstToken, typeof MetricEventTypeId>>({\n _tag: \"TimeToFirstToken\",\n turnIndex: started.turnIndex,\n elapsed: Duration.millis(now - started.turnStart),\n kind: (kind as Option.Some<\"text\" | \"reasoning\">).value,\n measurements: [\n {\n name: MetricName.ttft,\n kind: \"timer\",\n value: Duration.millis(now - started.turnStart),\n },\n ],\n }),\n ]\n : [ev]\n const next: TtftState = isTurnCompleteEvent(ev)\n ? {\n turnIndex: started.turnIndex + 1,\n turnStart: now,\n started: fromRequest,\n seen: false,\n }\n : { ...started, seen: started.seen || emitTtft }\n return [next, out] as const\n }),\n ),\n ),\n ),\n )\n }\n\n// ---------------------------------------------------------------------------\n// timeToCompletion\n// ---------------------------------------------------------------------------\n\ntype CompletionState = {\n readonly turnIndex: number\n readonly turnStart: number\n readonly firstTokenAt: Option.Option<number>\n}\n\nexport const timeToCompletion = <A, E, R>(\n self: Stream.Stream<A, E, R>,\n): Stream.Stream<A | MetricEvent, E, R> =>\n Stream.unwrap(\n Effect.map(Clock.currentTimeMillis, (init) =>\n self.pipe(\n Stream.mapAccumEffect(\n (): CompletionState => ({ turnIndex: 0, turnStart: init, firstTokenAt: Option.none() }),\n (state, ev) =>\n Effect.map(Clock.currentTimeMillis, (now) => {\n const firstTokenAt =\n Option.isNone(state.firstTokenAt) && Option.isSome(contentDeltaKind(ev))\n ? Option.some(now)\n : state.firstTokenAt\n if (!isTurnCompleteEvent(ev)) {\n return [{ ...state, firstTokenAt }, [ev]] as const\n }\n const duration = Duration.millis(now - state.turnStart)\n const generation = Option.match(firstTokenAt, {\n onNone: () => Duration.zero,\n onSome: (ft) => Duration.millis(now - ft),\n })\n const event = makeEvent<Omit<TimeToCompletion, typeof MetricEventTypeId>>({\n _tag: \"TimeToCompletion\",\n turnIndex: state.turnIndex,\n duration,\n generation,\n measurements: [\n { name: MetricName.duration, kind: \"timer\", value: duration },\n { name: MetricName.generation, kind: \"timer\", value: generation },\n ],\n })\n const next: CompletionState = {\n turnIndex: state.turnIndex + 1,\n turnStart: now,\n firstTokenAt: Option.none(),\n }\n return [next, [ev, event]] as const\n }),\n ),\n ),\n ),\n )\n\n// ---------------------------------------------------------------------------\n// tokenTotals\n// ---------------------------------------------------------------------------\n\nconst emptyUsage: Usage = {}\n\ntype TotalsState = {\n readonly turnIndex: number\n readonly cumulative: Usage\n}\n\nexport const tokenTotals = <A, E, R>(\n self: Stream.Stream<A, E, R>,\n): Stream.Stream<A | MetricEvent, E, R> =>\n self.pipe(\n Stream.mapAccumEffect(\n (): TotalsState => ({ turnIndex: 0, cumulative: emptyUsage }),\n (state, ev) =>\n Effect.sync(() => {\n if (!isTurnCompleteEvent(ev)) return [state, [ev]] as const\n const usage = turnOf(ev).usage\n const cumulative = addUsage(state.cumulative, usage)\n const event = makeEvent<Omit<TokenTotals, typeof MetricEventTypeId>>({\n _tag: \"TokenTotals\",\n turnIndex: state.turnIndex,\n usage,\n cumulative,\n measurements: usageMeasurements(usage),\n })\n return [{ turnIndex: state.turnIndex + 1, cumulative }, [ev, event]] as const\n }),\n ),\n )\n\n// ---------------------------------------------------------------------------\n// throughput\n// ---------------------------------------------------------------------------\n\nexport type ThroughputOptions = {\n /** Tick interval. Default `\"1 second\"`. */\n readonly every?: Duration.Input\n /** What a \"unit\" is. Default `\"char\"` (exact and chunking-independent). */\n readonly unit?: \"char\" | \"token\" | \"event\"\n /** Token counter for `unit: \"token\"`. Absent => approximate 1 per content delta. */\n readonly tokenizer?: (event: TurnEvent) => Effect.Effect<number>\n /** Rate definition. Default `\"windowed\"` (current speed, no cold-start bias). */\n readonly mode?: \"windowed\" | \"cumulative\"\n /** EWMA smoothing. Omitted => off; `\"default\"` => a sensible factor. */\n readonly smooth?: \"default\" | number\n}\n\n/**\n * The throughput accumulator. Mutated by the source per content delta and read\n * by the metronome per tick.\n *\n * @internal\n */\nexport type RateState = {\n readonly units: number\n readonly lastUnits: number\n readonly lastMillis: number\n readonly firstMillis: Option.Option<number>\n readonly smoothed: Option.Option<number>\n readonly turnIndex: number\n}\n\nconst initRateState: RateState = {\n units: 0,\n lastUnits: 0,\n lastMillis: 0,\n firstMillis: Option.none(),\n smoothed: Option.none(),\n turnIndex: 0,\n}\n\nconst resolveSmooth = (smooth: ThroughputOptions[\"smooth\"]): number | undefined =>\n smooth === undefined ? undefined : smooth === \"default\" ? 0.3 : smooth\n\n/**\n * Pure per-tick rate computation. Returns `None` before the first token, else\n * the rate, the window it was measured over, and the next accumulator state.\n * Separated from the metronome so the rate math is unit-testable without time.\n *\n * @internal\n */\nexport const computeThroughputTick = (\n state: RateState,\n now: number,\n mode: \"windowed\" | \"cumulative\",\n smooth: number | undefined,\n): Option.Option<{\n readonly rate: number\n readonly window: Duration.Duration\n readonly next: RateState\n}> =>\n Option.map(state.firstMillis, (first) => {\n const windowMs = mode === \"windowed\" ? now - state.lastMillis : now - first\n const deltaUnits = mode === \"windowed\" ? state.units - state.lastUnits : state.units\n const instant = windowMs > 0 ? (deltaUnits / windowMs) * 1000 : 0\n const rate =\n smooth === undefined\n ? instant\n : Option.match(state.smoothed, {\n onNone: () => instant,\n onSome: (prev) => smooth * instant + (1 - smooth) * prev,\n })\n return {\n rate,\n window: Duration.millis(windowMs),\n next: { ...state, lastUnits: state.units, lastMillis: now, smoothed: Option.some(rate) },\n }\n })\n\nconst addUnits = (state: RateState, n: number, now: number): RateState =>\n Option.isNone(state.firstMillis)\n ? { ...state, units: n, lastUnits: 0, lastMillis: now, firstMillis: Option.some(now) }\n : { ...state, units: state.units + n }\n\nconst resetTurn = (state: RateState): RateState => ({\n ...initRateState,\n turnIndex: state.turnIndex + 1,\n})\n\nconst unitCount = (\n ev: unknown,\n unit: \"char\" | \"token\" | \"event\",\n tokenizer: ThroughputOptions[\"tokenizer\"],\n): Effect.Effect<number> => {\n if (tagOf(ev) !== \"TextDelta\") return Effect.succeed(0)\n const text = (ev as { readonly text: string }).text\n return Match.value(unit).pipe(\n Match.when(\"char\", () => Effect.succeed([...text].length)),\n Match.when(\"event\", () => Effect.succeed(1)),\n Match.when(\"token\", () =>\n tokenizer === undefined ? Effect.succeed(1) : tokenizer(ev as TurnEvent),\n ),\n Match.exhaustive,\n )\n}\n\nconst throughputName = (unit: \"char\" | \"token\" | \"event\"): string =>\n `effect_uai_output_${unit}s_per_second`\n\nexport const throughput =\n (options?: ThroughputOptions) =>\n <A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A | MetricEvent, E, R> => {\n const unit = options?.unit ?? \"char\"\n const mode = options?.mode ?? \"windowed\"\n const every = options?.every ?? Duration.seconds(1)\n const smooth = resolveSmooth(options?.smooth)\n const tokenizer = options?.tokenizer\n const name = throughputName(unit)\n\n return Stream.unwrap(\n Effect.map(Ref.make(initRateState), (ref) => {\n const source = self.pipe(\n Stream.tap((ev) =>\n isTurnCompleteEvent(ev)\n ? Ref.update(ref, resetTurn)\n : tagOf(ev) === \"TextDelta\"\n ? Effect.flatMap(Clock.currentTimeMillis, (now) =>\n Effect.flatMap(unitCount(ev, unit, tokenizer), (n) =>\n Ref.update(ref, (state) => addUnits(state, n, now)),\n ),\n )\n : Effect.void,\n ),\n )\n const metronome: Stream.Stream<MetricEvent> = Stream.fromSchedule(\n Schedule.spaced(every),\n ).pipe(\n Stream.mapEffect(() =>\n Effect.flatMap(Clock.currentTimeMillis, (now) =>\n Ref.modify(ref, (state) =>\n Option.match(computeThroughputTick(state, now, mode, smooth), {\n onNone: () => [Option.none<MetricEvent>(), state] as const,\n onSome: ({ next, rate, window }) =>\n [\n Option.some<MetricEvent>(\n makeEvent<Omit<Throughput, typeof MetricEventTypeId>>({\n _tag: \"Throughput\",\n turnIndex: state.turnIndex,\n ratePerSecond: rate,\n unit,\n window,\n measurements: [{ name, kind: \"histogram\", value: rate }],\n }),\n ),\n next,\n ] as const,\n }),\n ),\n ),\n ),\n Stream.filterMap((o) => (Option.isSome(o) ? Result.succeed(o.value) : Result.failVoid)),\n )\n return source.pipe(Stream.merge(metronome, { haltStrategy: \"left\" }))\n }),\n )\n }\n\n// ---------------------------------------------------------------------------\n// allMetrics\n// ---------------------------------------------------------------------------\n\nexport type AllMetricsOptions = {\n readonly timeToFirstToken?: TimeToFirstTokenOptions\n readonly throughput?: ThroughputOptions\n}\n\nexport const allMetrics =\n (options?: AllMetricsOptions) =>\n <A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A | MetricEvent, E, R> =>\n self.pipe(\n timeToFirstToken(options?.timeToFirstToken),\n throughput(options?.throughput),\n tokenTotals,\n timeToCompletion,\n )\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"Metrics.mjs","names":["Arr"],"sources":["../../src/observability/Metrics.ts"],"sourcesContent":["import {\n Array as Arr,\n Clock,\n Duration,\n Effect,\n Match,\n Option,\n Predicate,\n Ref,\n Result,\n Schedule,\n Stream,\n} from \"effect\"\nimport type { Usage } from \"../domain/Items.js\"\nimport { type Turn, TurnEvent } from \"../domain/Turn.js\"\n\n// ---------------------------------------------------------------------------\n// Self-describing measurements (the exporter-facing payload)\n// ---------------------------------------------------------------------------\n\n/**\n * The instrument-agnostic description of a single recorded value. The\n * `Telemetry` recorder reads only this: it builds (or reuses) a `Metric`\n * instrument from `kind` + `name` and updates it with `value`. A new metric,\n * built-in or user-defined, never requires a change to the recorder.\n */\nexport type Measurement = {\n readonly name: string\n readonly kind: \"counter\" | \"histogram\" | \"timer\" | \"gauge\"\n readonly value: number | Duration.Duration\n readonly attributes?: Readonly<Record<string, string>> | undefined\n}\n\n// ---------------------------------------------------------------------------\n// MetricEvent: an open, brand-marked union (built-ins + user metrics)\n// ---------------------------------------------------------------------------\n\n/**\n * Brand stamped on every metric event. `isMetricEvent` checks this symbol,\n * not an enumerated `_tag`, so events are distinguished from `TurnEvent`s,\n * tool outputs, and arbitrary custom loop values regardless of their tag.\n * This is the same TypeId-symbol pattern Effect uses for `isStream` etc.\n */\nexport const MetricEventTypeId: unique symbol = Symbol.for(\n \"@effect-uai/core/observability/MetricEvent\",\n)\n\n/**\n * The structural contract every metric event satisfies, ours and custom.\n * The `_tag` + extra typed fields serve a frontend; the brand + `measurements`\n * serve the exporter.\n */\nexport type MetricEvent = {\n readonly [MetricEventTypeId]: typeof MetricEventTypeId\n readonly _tag: string\n /** Which turn within the piped stream this sample belongs to (0-based). */\n readonly turnIndex: number\n readonly measurements: ReadonlyArray<Measurement>\n}\n\n/** Request/turn start to the first content delta. */\nexport type TimeToFirstToken = MetricEvent & {\n readonly _tag: \"TimeToFirstToken\"\n readonly elapsed: Duration.Duration\n readonly kind: \"text\" | \"reasoning\"\n}\n\n/** A live output rate, emitted on the metronome cadence. */\nexport type Throughput = MetricEvent & {\n readonly _tag: \"Throughput\"\n readonly ratePerSecond: number\n readonly unit: \"char\" | \"token\" | \"event\"\n readonly window: Duration.Duration\n}\n\n/** Provider-reported usage at `TurnComplete`, this turn and cumulative. */\nexport type TokenTotals = MetricEvent & {\n readonly _tag: \"TokenTotals\"\n readonly usage: Usage\n readonly cumulative: Usage\n}\n\n/** Per-turn wall times at `TurnComplete`. */\nexport type TimeToCompletion = MetricEvent & {\n readonly _tag: \"TimeToCompletion\"\n readonly duration: Duration.Duration\n readonly generation: Duration.Duration\n}\n\n/**\n * Mint a custom metric event. Stamp it with the brand and provide a\n * well-formed `measurements` array; `Telemetry.record` will export it with no\n * changes to the recorder. The only constraint is that each measurement's\n * `kind` is one the recorder maps.\n */\nexport const makeEvent = <Fields extends Omit<MetricEvent, typeof MetricEventTypeId>>(\n fields: Fields,\n): Fields & { readonly [MetricEventTypeId]: typeof MetricEventTypeId } => ({\n [MetricEventTypeId]: MetricEventTypeId,\n ...fields,\n})\n\n/** Structural (brand-based) guard for any metric event, built-in or custom. */\nexport const isMetricEvent = (u: unknown): u is MetricEvent =>\n Predicate.hasProperty(u, MetricEventTypeId)\n\n/**\n * Project a stream of mixed events onto just its metric samples, for a side\n * channel or a frontend feed.\n */\nexport const metricEvents = <A, E, R>(\n self: Stream.Stream<A, E, R>,\n): Stream.Stream<MetricEvent, E, R> =>\n self.pipe(Stream.filterMap((ev) => (isMetricEvent(ev) ? Result.succeed(ev) : Result.failVoid)))\n\n// ---------------------------------------------------------------------------\n// Internal helpers\n// ---------------------------------------------------------------------------\n\nconst MetricName = {\n ttft: \"effect_uai_turn_ttft\",\n duration: \"effect_uai_turn_duration\",\n generation: \"effect_uai_turn_generation\",\n inputTokens: \"effect_uai_input_tokens\",\n outputTokens: \"effect_uai_output_tokens\",\n totalTokens: \"effect_uai_total_tokens\",\n reasoningTokens: \"effect_uai_reasoning_tokens\",\n cachedInputTokens: \"effect_uai_cached_input_tokens\",\n cacheWriteTokens: \"effect_uai_cache_write_tokens\",\n} as const\n\n/** Read a structural `_tag` off any value without narrowing to a closed union. */\nconst tagOf = (u: unknown): string | undefined =>\n Predicate.hasProperty(u, \"_tag\") && Predicate.isString(u._tag) ? u._tag : undefined\n\nconst contentDeltaKind = (ev: unknown): Option.Option<\"text\" | \"reasoning\"> => {\n const t = tagOf(ev)\n return t === \"TextDelta\"\n ? Option.some(\"text\")\n : t === \"ReasoningDelta\"\n ? Option.some(\"reasoning\")\n : Option.none()\n}\n\nconst isTextDelta = TurnEvent.$is(\"TextDelta\")\nconst isReasoningDelta = TurnEvent.$is(\"ReasoningDelta\")\nconst isRefusalDelta = TurnEvent.$is(\"RefusalDelta\")\nconst isToolCallArgsDelta = TurnEvent.$is(\"ToolCallArgsDelta\")\n\n/** A delta carrying generated output, whatever the model was producing. */\nexport type OutputDelta = Extract<\n TurnEvent,\n { readonly _tag: \"TextDelta\" | \"ReasoningDelta\" | \"RefusalDelta\" | \"ToolCallArgsDelta\" }\n>\n\n/**\n * Narrow to a delta that contributes to the model's output stream. Tool-call\n * arguments count: an agent turn can be almost entirely `ToolCallArgsDelta`,\n * and measuring only prose reports a near-zero rate for it.\n *\n * The single place the output-delta tag list lives — the throughput\n * accumulator and `unitCount` both read it. Distinct from `contentDeltaKind`,\n * which answers a narrower question for first-token latency.\n */\nconst outputDelta = (ev: unknown): Option.Option<OutputDelta> =>\n isTextDelta(ev) || isReasoningDelta(ev) || isRefusalDelta(ev) || isToolCallArgsDelta(ev)\n ? Option.some(ev)\n : Option.none()\n\nconst outputTextOf = (delta: OutputDelta): string =>\n isToolCallArgsDelta(delta) ? delta.delta : delta.text\n\nconst isTurnCompleteEvent = (ev: unknown): boolean => tagOf(ev) === \"TurnComplete\"\n\nconst turnOf = (ev: unknown): Turn =>\n (ev as Extract<TurnEvent, { readonly _tag: \"TurnComplete\" }>).turn\n\nconst sumOptional = (a: number | undefined, b: number | undefined): number | undefined =>\n a === undefined && b === undefined ? undefined : (a ?? 0) + (b ?? 0)\n\nconst addUsage = (a: Usage, b: Usage): Usage => {\n const cached = sumOptional(\n a.input_tokens_details?.cached_tokens,\n b.input_tokens_details?.cached_tokens,\n )\n const cacheWrite = sumOptional(\n a.input_tokens_details?.cache_write_tokens,\n b.input_tokens_details?.cache_write_tokens,\n )\n const reasoning = sumOptional(\n a.output_tokens_details?.reasoning_tokens,\n b.output_tokens_details?.reasoning_tokens,\n )\n return {\n input_tokens: sumOptional(a.input_tokens, b.input_tokens),\n output_tokens: sumOptional(a.output_tokens, b.output_tokens),\n total_tokens: sumOptional(a.total_tokens, b.total_tokens),\n input_tokens_details:\n cached === undefined && cacheWrite === undefined\n ? undefined\n : {\n ...(cached !== undefined && { cached_tokens: cached }),\n ...(cacheWrite !== undefined && { cache_write_tokens: cacheWrite }),\n },\n output_tokens_details: reasoning === undefined ? undefined : { reasoning_tokens: reasoning },\n }\n}\n\n/** Per-turn token counts as incremental counter measurements (present fields only). */\nconst usageMeasurements = (usage: Usage): ReadonlyArray<Measurement> => {\n const pairs: ReadonlyArray<readonly [string, number | undefined]> = [\n [MetricName.inputTokens, usage.input_tokens],\n [MetricName.outputTokens, usage.output_tokens],\n [MetricName.totalTokens, usage.total_tokens],\n [MetricName.reasoningTokens, usage.output_tokens_details?.reasoning_tokens],\n [MetricName.cachedInputTokens, usage.input_tokens_details?.cached_tokens],\n [MetricName.cacheWriteTokens, usage.input_tokens_details?.cache_write_tokens],\n ]\n return Arr.filterMap(pairs, ([name, value]) =>\n value === undefined\n ? Result.failVoid\n : Result.succeed<Measurement>({ name, kind: \"counter\", value }),\n )\n}\n\n// ---------------------------------------------------------------------------\n// timeToFirstToken\n// ---------------------------------------------------------------------------\n\nexport type TimeToFirstTokenOptions = {\n /**\n * Where the clock starts. `\"request\"` (default) anchors at stream\n * initialization, i.e. when the provider request fires, so the measurement\n * includes connection + prefill. `\"first-event\"` anchors at the first\n * emitted event, isolating decode latency.\n */\n readonly from?: \"request\" | \"first-event\"\n}\n\ntype TtftState = {\n readonly turnIndex: number\n readonly turnStart: number\n readonly started: boolean\n readonly seen: boolean\n}\n\nexport const timeToFirstToken =\n (options?: TimeToFirstTokenOptions) =>\n <A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A | MetricEvent, E, R> => {\n const fromRequest = (options?.from ?? \"request\") === \"request\"\n return Stream.unwrap(\n Effect.map(Clock.currentTimeMillis, (init) =>\n self.pipe(\n Stream.mapAccumEffect(\n (): TtftState => ({ turnIndex: 0, turnStart: init, started: fromRequest, seen: false }),\n (state, ev) =>\n Effect.map(Clock.currentTimeMillis, (now) => {\n const started = state.started ? state : { ...state, turnStart: now, started: true }\n const kind = contentDeltaKind(ev)\n const emitTtft = !started.seen && Option.isSome(kind)\n const out: ReadonlyArray<A | MetricEvent> = emitTtft\n ? [\n ev,\n makeEvent<Omit<TimeToFirstToken, typeof MetricEventTypeId>>({\n _tag: \"TimeToFirstToken\",\n turnIndex: started.turnIndex,\n elapsed: Duration.millis(now - started.turnStart),\n kind: (kind as Option.Some<\"text\" | \"reasoning\">).value,\n measurements: [\n {\n name: MetricName.ttft,\n kind: \"timer\",\n value: Duration.millis(now - started.turnStart),\n },\n ],\n }),\n ]\n : [ev]\n const next: TtftState = isTurnCompleteEvent(ev)\n ? {\n turnIndex: started.turnIndex + 1,\n turnStart: now,\n started: fromRequest,\n seen: false,\n }\n : { ...started, seen: started.seen || emitTtft }\n return [next, out] as const\n }),\n ),\n ),\n ),\n )\n }\n\n// ---------------------------------------------------------------------------\n// timeToCompletion\n// ---------------------------------------------------------------------------\n\ntype CompletionState = {\n readonly turnIndex: number\n readonly turnStart: number\n readonly firstTokenAt: Option.Option<number>\n}\n\nexport const timeToCompletion = <A, E, R>(\n self: Stream.Stream<A, E, R>,\n): Stream.Stream<A | MetricEvent, E, R> =>\n Stream.unwrap(\n Effect.map(Clock.currentTimeMillis, (init) =>\n self.pipe(\n Stream.mapAccumEffect(\n (): CompletionState => ({ turnIndex: 0, turnStart: init, firstTokenAt: Option.none() }),\n (state, ev) =>\n Effect.map(Clock.currentTimeMillis, (now) => {\n const firstTokenAt =\n Option.isNone(state.firstTokenAt) && Option.isSome(contentDeltaKind(ev))\n ? Option.some(now)\n : state.firstTokenAt\n if (!isTurnCompleteEvent(ev)) {\n return [{ ...state, firstTokenAt }, [ev]] as const\n }\n const duration = Duration.millis(now - state.turnStart)\n const generation = Option.match(firstTokenAt, {\n onNone: () => Duration.zero,\n onSome: (ft) => Duration.millis(now - ft),\n })\n const event = makeEvent<Omit<TimeToCompletion, typeof MetricEventTypeId>>({\n _tag: \"TimeToCompletion\",\n turnIndex: state.turnIndex,\n duration,\n generation,\n measurements: [\n { name: MetricName.duration, kind: \"timer\", value: duration },\n { name: MetricName.generation, kind: \"timer\", value: generation },\n ],\n })\n const next: CompletionState = {\n turnIndex: state.turnIndex + 1,\n turnStart: now,\n firstTokenAt: Option.none(),\n }\n return [next, [ev, event]] as const\n }),\n ),\n ),\n ),\n )\n\n// ---------------------------------------------------------------------------\n// tokenTotals\n// ---------------------------------------------------------------------------\n\nconst emptyUsage: Usage = {}\n\ntype TotalsState = {\n readonly turnIndex: number\n readonly cumulative: Usage\n}\n\nexport const tokenTotals = <A, E, R>(\n self: Stream.Stream<A, E, R>,\n): Stream.Stream<A | MetricEvent, E, R> =>\n self.pipe(\n Stream.mapAccumEffect(\n (): TotalsState => ({ turnIndex: 0, cumulative: emptyUsage }),\n (state, ev) =>\n Effect.sync(() => {\n if (!isTurnCompleteEvent(ev)) return [state, [ev]] as const\n const usage = turnOf(ev).usage\n const cumulative = addUsage(state.cumulative, usage)\n const event = makeEvent<Omit<TokenTotals, typeof MetricEventTypeId>>({\n _tag: \"TokenTotals\",\n turnIndex: state.turnIndex,\n usage,\n cumulative,\n measurements: usageMeasurements(usage),\n })\n return [{ turnIndex: state.turnIndex + 1, cumulative }, [ev, event]] as const\n }),\n ),\n )\n\n// ---------------------------------------------------------------------------\n// throughput\n// ---------------------------------------------------------------------------\n\nexport type ThroughputOptions = {\n /** Tick interval. Default `\"1 second\"`. */\n readonly every?: Duration.Input\n /** What a \"unit\" is. Default `\"char\"` (exact and chunking-independent). */\n readonly unit?: \"char\" | \"token\" | \"event\"\n /**\n * Token counter for `unit: \"token\"`. Called only for deltas that carry\n * generated output. Absent => approximate 1 per such delta.\n */\n readonly tokenizer?: (event: OutputDelta) => Effect.Effect<number>\n /** Rate definition. Default `\"windowed\"` (current speed, no cold-start bias). */\n readonly mode?: \"windowed\" | \"cumulative\"\n /** EWMA smoothing. Omitted => off; `\"default\"` => a sensible factor. */\n readonly smooth?: \"default\" | number\n}\n\n/**\n * The throughput accumulator. Mutated by the source per content delta and read\n * by the metronome per tick.\n *\n * @internal\n */\nexport type RateState = {\n readonly units: number\n readonly lastUnits: number\n readonly lastMillis: number\n readonly firstMillis: Option.Option<number>\n readonly smoothed: Option.Option<number>\n readonly turnIndex: number\n}\n\nconst initRateState: RateState = {\n units: 0,\n lastUnits: 0,\n lastMillis: 0,\n firstMillis: Option.none(),\n smoothed: Option.none(),\n turnIndex: 0,\n}\n\nconst resolveSmooth = (smooth: ThroughputOptions[\"smooth\"]): number | undefined =>\n smooth === undefined ? undefined : smooth === \"default\" ? 0.3 : smooth\n\n/**\n * Pure per-tick rate computation. Returns `None` before the first token, else\n * the rate, the window it was measured over, and the next accumulator state.\n * Separated from the metronome so the rate math is unit-testable without time.\n *\n * @internal\n */\nexport const computeThroughputTick = (\n state: RateState,\n now: number,\n mode: \"windowed\" | \"cumulative\",\n smooth: number | undefined,\n): Option.Option<{\n readonly rate: number\n readonly window: Duration.Duration\n readonly next: RateState\n}> =>\n Option.map(state.firstMillis, (first) => {\n const windowMs = mode === \"windowed\" ? now - state.lastMillis : now - first\n const deltaUnits = mode === \"windowed\" ? state.units - state.lastUnits : state.units\n const instant = windowMs > 0 ? (deltaUnits / windowMs) * 1000 : 0\n const rate =\n smooth === undefined\n ? instant\n : Option.match(state.smoothed, {\n onNone: () => instant,\n onSome: (prev) => smooth * instant + (1 - smooth) * prev,\n })\n return {\n rate,\n window: Duration.millis(windowMs),\n next: { ...state, lastUnits: state.units, lastMillis: now, smoothed: Option.some(rate) },\n }\n })\n\nconst addUnits = (state: RateState, n: number, now: number): RateState =>\n Option.isNone(state.firstMillis)\n ? { ...state, units: n, lastUnits: 0, lastMillis: now, firstMillis: Option.some(now) }\n : { ...state, units: state.units + n }\n\nconst resetTurn = (state: RateState): RateState => ({\n ...initRateState,\n turnIndex: state.turnIndex + 1,\n})\n\nconst unitCount = (\n ev: unknown,\n unit: \"char\" | \"token\" | \"event\",\n tokenizer: ThroughputOptions[\"tokenizer\"],\n): Effect.Effect<number> => {\n const output = outputDelta(ev)\n if (Option.isNone(output)) return Effect.succeed(0)\n const delta = output.value\n return Match.value(unit).pipe(\n Match.when(\"char\", () => Effect.succeed([...outputTextOf(delta)].length)),\n Match.when(\"event\", () => Effect.succeed(1)),\n Match.when(\"token\", () => (tokenizer === undefined ? Effect.succeed(1) : tokenizer(delta))),\n Match.exhaustive,\n )\n}\n\nconst throughputName = (unit: \"char\" | \"token\" | \"event\"): string =>\n `effect_uai_output_${unit}s_per_second`\n\nexport const throughput =\n (options?: ThroughputOptions) =>\n <A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A | MetricEvent, E, R> => {\n const unit = options?.unit ?? \"char\"\n const mode = options?.mode ?? \"windowed\"\n const every = options?.every ?? Duration.seconds(1)\n const smooth = resolveSmooth(options?.smooth)\n const tokenizer = options?.tokenizer\n const name = throughputName(unit)\n\n return Stream.unwrap(\n Effect.map(Ref.make(initRateState), (ref) => {\n const source = self.pipe(\n Stream.tap((ev) =>\n isTurnCompleteEvent(ev)\n ? Ref.update(ref, resetTurn)\n : Option.isSome(outputDelta(ev))\n ? Effect.flatMap(Clock.currentTimeMillis, (now) =>\n Effect.flatMap(unitCount(ev, unit, tokenizer), (n) =>\n Ref.update(ref, (state) => addUnits(state, n, now)),\n ),\n )\n : Effect.void,\n ),\n )\n const metronome: Stream.Stream<MetricEvent> = Stream.fromSchedule(\n Schedule.spaced(every),\n ).pipe(\n Stream.mapEffect(() =>\n Effect.flatMap(Clock.currentTimeMillis, (now) =>\n Ref.modify(ref, (state) =>\n Option.match(computeThroughputTick(state, now, mode, smooth), {\n onNone: () => [Option.none<MetricEvent>(), state] as const,\n onSome: ({ next, rate, window }) =>\n [\n Option.some<MetricEvent>(\n makeEvent<Omit<Throughput, typeof MetricEventTypeId>>({\n _tag: \"Throughput\",\n turnIndex: state.turnIndex,\n ratePerSecond: rate,\n unit,\n window,\n measurements: [{ name, kind: \"histogram\", value: rate }],\n }),\n ),\n next,\n ] as const,\n }),\n ),\n ),\n ),\n Stream.filterMap((o) => (Option.isSome(o) ? Result.succeed(o.value) : Result.failVoid)),\n )\n return source.pipe(Stream.merge(metronome, { haltStrategy: \"left\" }))\n }),\n )\n }\n\n// ---------------------------------------------------------------------------\n// allMetrics\n// ---------------------------------------------------------------------------\n\nexport type AllMetricsOptions = {\n readonly timeToFirstToken?: TimeToFirstTokenOptions\n readonly throughput?: ThroughputOptions\n}\n\nexport const allMetrics =\n (options?: AllMetricsOptions) =>\n <A, E, R>(self: Stream.Stream<A, E, R>): Stream.Stream<A | MetricEvent, E, R> =>\n self.pipe(\n timeToFirstToken(options?.timeToFirstToken),\n throughput(options?.throughput),\n tokenTotals,\n timeToCompletion,\n )\n"],"mappings":"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"}
|
|
@@ -11,6 +11,10 @@ const turnComplete = (usage) => TurnEvent.TurnComplete({ turn: {
|
|
|
11
11
|
stop_reason: "stop"
|
|
12
12
|
} });
|
|
13
13
|
const text = (s) => TurnEvent.TextDelta({ text: s });
|
|
14
|
+
const toolArgs = (s) => TurnEvent.ToolCallArgsDelta({
|
|
15
|
+
call_id: "call_1",
|
|
16
|
+
delta: s
|
|
17
|
+
});
|
|
14
18
|
const samples = (events) => events.filter(isMetricEvent);
|
|
15
19
|
const head = (xs) => Option.getOrThrow(Array.head(xs));
|
|
16
20
|
const tagged = (events, tag) => samples(events).filter((e) => e._tag === tag);
|
|
@@ -55,6 +59,26 @@ describe("tokenTotals", () => {
|
|
|
55
59
|
value: 7
|
|
56
60
|
});
|
|
57
61
|
}));
|
|
62
|
+
it.effect("carries cache read and write tokens through usage and measurements", () => Effect.gen(function* () {
|
|
63
|
+
const stream = Stream.make(turnComplete({
|
|
64
|
+
input_tokens: 94,
|
|
65
|
+
input_tokens_details: {
|
|
66
|
+
cached_tokens: 31851,
|
|
67
|
+
cache_write_tokens: 604
|
|
68
|
+
}
|
|
69
|
+
}), turnComplete({
|
|
70
|
+
input_tokens: 6,
|
|
71
|
+
input_tokens_details: { cached_tokens: 32e3 }
|
|
72
|
+
}));
|
|
73
|
+
const totals = tagged(yield* Stream.runCollect(stream.pipe(tokenTotals)), "TokenTotals");
|
|
74
|
+
globalExpect(totals.map((t) => t.cumulative.input_tokens_details?.cache_write_tokens)).toEqual([604, 604]);
|
|
75
|
+
globalExpect(totals.map((t) => t.cumulative.input_tokens_details?.cached_tokens)).toEqual([31851, 63851]);
|
|
76
|
+
globalExpect(head(totals).measurements.find((m) => m.name === "effect_uai_cache_write_tokens")).toEqual({
|
|
77
|
+
name: "effect_uai_cache_write_tokens",
|
|
78
|
+
kind: "counter",
|
|
79
|
+
value: 604
|
|
80
|
+
});
|
|
81
|
+
}));
|
|
58
82
|
});
|
|
59
83
|
describe("timeToFirstToken", () => {
|
|
60
84
|
it.effect("fires on the first content delta, measured from request (stream init)", () => Effect.gen(function* () {
|
|
@@ -159,6 +183,24 @@ describe("throughput (metronome)", () => {
|
|
|
159
183
|
globalExpect(head(rates).turnIndex).toBe(0);
|
|
160
184
|
globalExpect(head(rates).ratePerSecond).toBeGreaterThan(0);
|
|
161
185
|
}));
|
|
186
|
+
it.effect("counts tool-call arguments: a turn with no prose still reports a rate", () => Effect.gen(function* () {
|
|
187
|
+
const rates = (yield* runTimed(Stream.make(toolArgs("{\"city\":"), toolArgs("\"Lisbon\"}")).pipe(Stream.concat(timed([["10 seconds", turnComplete({})]]))).pipe(throughput({
|
|
188
|
+
every: "1 second",
|
|
189
|
+
unit: "char"
|
|
190
|
+
}), metricEvents, Stream.take(1)), ["1 second"])).filter((e) => e._tag === "Throughput");
|
|
191
|
+
globalExpect(rates).toHaveLength(1);
|
|
192
|
+
globalExpect(head(rates).ratePerSecond).toBeGreaterThan(0);
|
|
193
|
+
}));
|
|
194
|
+
it.live("ignores events that carry no generated output", () => Effect.gen(function* () {
|
|
195
|
+
const stream = Stream.make(TurnEvent.ToolCallStart({
|
|
196
|
+
call_id: "call_1",
|
|
197
|
+
name: "get_weather"
|
|
198
|
+
}), TurnEvent.UsageUpdate({ usage: { output_tokens: 5 } })).pipe(Stream.concat(timed([["30 millis", turnComplete({})]]))).pipe(throughput({
|
|
199
|
+
every: "10 millis",
|
|
200
|
+
unit: "char"
|
|
201
|
+
}), metricEvents);
|
|
202
|
+
globalExpect((yield* Stream.runCollect(stream)).filter((e) => e._tag === "Throughput")).toHaveLength(0);
|
|
203
|
+
}));
|
|
162
204
|
});
|
|
163
205
|
describe("custom events and helpers", () => {
|
|
164
206
|
it("makeEvent stamps the brand so isMetricEvent and the recorder see it", () => {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Metrics.test.mjs","names":["Arr"],"sources":["../../src/observability/Metrics.test.ts"],"sourcesContent":["import { describe, it } from \"@effect/vitest\"\nimport { Array as Arr, Duration, Effect, Fiber, Option, Stream } from \"effect\"\nimport * as TestClock from \"effect/testing/TestClock\"\nimport { expect } from \"vitest\"\nimport type { Usage } from \"../domain/Items.js\"\nimport { TurnEvent } from \"../domain/Turn.js\"\nimport {\n type MetricEvent,\n type Throughput,\n type TimeToCompletion,\n type TimeToFirstToken,\n type TokenTotals,\n computeThroughputTick,\n isMetricEvent,\n makeEvent,\n metricEvents,\n throughput,\n timeToCompletion,\n timeToFirstToken,\n tokenTotals,\n} from \"./Metrics.js\"\n\nconst turnComplete = (usage: Usage): TurnEvent =>\n TurnEvent.TurnComplete({ turn: { items: [], usage, stop_reason: \"stop\" } })\n\nconst text = (s: string): TurnEvent => TurnEvent.TextDelta({ text: s })\n\nconst samples = (events: ReadonlyArray<unknown>): ReadonlyArray<MetricEvent> =>\n events.filter(isMetricEvent)\n\nconst head = <A>(xs: ReadonlyArray<A>): A => Option.getOrThrow(Arr.head(xs))\n\nconst tagged = <T extends MetricEvent[\"_tag\"]>(\n events: ReadonlyArray<unknown>,\n tag: T,\n): ReadonlyArray<Extract<MetricEvent, { readonly _tag: T }>> =>\n samples(events).filter((e): e is Extract<MetricEvent, { readonly _tag: T }> => e._tag === tag)\n\n// A stream that emits each event after the given delay since the previous one.\nconst timed = <A>(steps: ReadonlyArray<readonly [Duration.Input, A]>): Stream.Stream<A> =>\n Stream.fromIterable(steps).pipe(\n Stream.mapEffect(([delay, ev]) => Effect.as(Effect.sleep(delay), ev)),\n )\n\n// Collect a metered stream while advancing the test clock by each step, so\n// every per-event clock read lands at a known virtual time.\nconst runTimed = <A>(\n metered: Stream.Stream<A>,\n steps: ReadonlyArray<Duration.Input>,\n): Effect.Effect<ReadonlyArray<A>> =>\n Effect.gen(function* () {\n const fiber = yield* Effect.forkChild(Stream.runCollect(metered))\n yield* Effect.forEach(steps, (step) => TestClock.adjust(step), { discard: true })\n return yield* Fiber.join(fiber)\n })\n\ndescribe(\"tokenTotals\", () => {\n it.effect(\"turn level: a single TurnComplete reports that generation's usage\", () =>\n Effect.gen(function* () {\n const stream = Stream.make(\n text(\"hi\"),\n turnComplete({ input_tokens: 100, output_tokens: 20, total_tokens: 120 }),\n )\n const out = yield* Stream.runCollect(stream.pipe(tokenTotals))\n const totals = tagged(out, \"TokenTotals\") as ReadonlyArray<TokenTotals>\n expect(totals).toHaveLength(1)\n expect(head(totals).turnIndex).toBe(0)\n expect(head(totals).usage.output_tokens).toBe(20)\n expect(head(totals).cumulative.output_tokens).toBe(20)\n }),\n )\n\n it.effect(\"loop level: cumulative sums across every turn, usage stays per-turn\", () =>\n Effect.gen(function* () {\n const stream = Stream.make(\n text(\"a\"),\n turnComplete({ input_tokens: 1000, output_tokens: 120 }),\n turnComplete({ input_tokens: 1300, output_tokens: 410 }),\n )\n const out = yield* Stream.runCollect(stream.pipe(tokenTotals))\n const totals = tagged(out, \"TokenTotals\") as ReadonlyArray<TokenTotals>\n expect(totals.map((t) => t.turnIndex)).toEqual([0, 1])\n expect(totals.map((t) => t.usage.output_tokens)).toEqual([120, 410])\n expect(totals.map((t) => t.cumulative.input_tokens)).toEqual([1000, 2300])\n expect(totals.map((t) => t.cumulative.output_tokens)).toEqual([120, 530])\n }),\n )\n\n it.effect(\"emits an incremental output_tokens counter measurement per turn\", () =>\n Effect.gen(function* () {\n const stream = Stream.make(turnComplete({ output_tokens: 7 }))\n const out = yield* Stream.runCollect(stream.pipe(tokenTotals))\n const m = head(samples(out)).measurements.find((m) => m.name === \"effect_uai_output_tokens\")\n expect(m).toEqual({ name: \"effect_uai_output_tokens\", kind: \"counter\", value: 7 })\n }),\n )\n})\n\ndescribe(\"timeToFirstToken\", () => {\n it.effect(\"fires on the first content delta, measured from request (stream init)\", () =>\n Effect.gen(function* () {\n const stream = timed<TurnEvent>([\n [\"300 millis\", text(\"hello\")],\n [\"700 millis\", turnComplete({})],\n ]).pipe(timeToFirstToken())\n const out = yield* runTimed(stream, [\"300 millis\", \"700 millis\"])\n const ttft = tagged(out, \"TimeToFirstToken\") as ReadonlyArray<TimeToFirstToken>\n expect(ttft).toHaveLength(1)\n expect(Duration.toMillis(head(ttft).elapsed)).toBe(300)\n expect(head(ttft).kind).toBe(\"text\")\n }),\n )\n\n it.effect(\"reports kind reasoning when reasoning arrives first\", () =>\n Effect.gen(function* () {\n const stream = timed<TurnEvent>([\n [\"120 millis\", TurnEvent.ReasoningDelta({ text: \"...\", kind: \"trace\" })],\n [\"80 millis\", text(\"answer\")],\n [\"50 millis\", turnComplete({})],\n ]).pipe(timeToFirstToken())\n const out = yield* runTimed(stream, [\"120 millis\", \"80 millis\", \"50 millis\"])\n const ttft = tagged(out, \"TimeToFirstToken\") as ReadonlyArray<TimeToFirstToken>\n expect(ttft).toHaveLength(1)\n expect(head(ttft).kind).toBe(\"reasoning\")\n expect(Duration.toMillis(head(ttft).elapsed)).toBe(120)\n }),\n )\n\n it.effect(\"loop level: one TTFT per turn, turnIndex increments\", () =>\n Effect.gen(function* () {\n const stream = timed<TurnEvent>([\n [\"200 millis\", text(\"a\")],\n [\"100 millis\", turnComplete({})],\n [\"300 millis\", text(\"b\")],\n [\"100 millis\", turnComplete({})],\n ]).pipe(timeToFirstToken())\n const out = yield* runTimed(stream, [\"200 millis\", \"100 millis\", \"300 millis\", \"100 millis\"])\n const ttft = tagged(out, \"TimeToFirstToken\") as ReadonlyArray<TimeToFirstToken>\n expect(ttft.map((t) => t.turnIndex)).toEqual([0, 1])\n // turn 0 from init; turn 1 from the previous TurnComplete (loop-scope approximation)\n expect(ttft.map((t) => Duration.toMillis(t.elapsed))).toEqual([200, 300])\n }),\n )\n})\n\ndescribe(\"timeToCompletion\", () => {\n it.effect(\"reports duration (request->complete) and generation (first token->complete)\", () =>\n Effect.gen(function* () {\n const stream = timed<TurnEvent>([\n [\"300 millis\", text(\"hi\")],\n [\"700 millis\", turnComplete({})],\n ]).pipe(timeToCompletion)\n const out = yield* runTimed(stream, [\"300 millis\", \"700 millis\"])\n const done = tagged(out, \"TimeToCompletion\") as ReadonlyArray<TimeToCompletion>\n expect(done).toHaveLength(1)\n expect(Duration.toMillis(head(done).duration)).toBe(1000)\n expect(Duration.toMillis(head(done).generation)).toBe(700)\n }),\n )\n})\n\ndescribe(\"computeThroughputTick\", () => {\n const base = {\n units: 0,\n lastUnits: 0,\n lastMillis: 0,\n firstMillis: Option.none<number>(),\n smoothed: Option.none<number>(),\n turnIndex: 0,\n }\n\n it(\"returns None before the first token\", () => {\n expect(Option.isNone(computeThroughputTick(base, 500, \"windowed\", undefined))).toBe(true)\n })\n\n it(\"windowed: rate is units in the last interval over that interval\", () => {\n const state = {\n ...base,\n units: 30,\n lastUnits: 10,\n lastMillis: 500,\n firstMillis: Option.some(100),\n }\n const r = computeThroughputTick(state, 1000, \"windowed\", undefined)\n // (30 - 10) units over (1000 - 500) ms = 40/s\n expect(Option.getOrThrow(r).rate).toBe(40)\n })\n\n it(\"cumulative: rate is total units over time since first token\", () => {\n const state = {\n ...base,\n units: 30,\n lastUnits: 10,\n lastMillis: 500,\n firstMillis: Option.some(100),\n }\n const r = computeThroughputTick(state, 1000, \"cumulative\", undefined)\n // 30 units over (1000 - 100) ms ~= 33.33/s\n expect(Option.getOrThrow(r).rate).toBeCloseTo(33.333, 2)\n })\n\n it(\"smooth: blends the instantaneous rate with the carried average\", () => {\n const state = {\n ...base,\n units: 20,\n lastUnits: 0,\n lastMillis: 0,\n firstMillis: Option.some(0),\n smoothed: Option.some(100),\n }\n // windowed instant = 20 units / 1000 ms = 20/s; smoothed = 0.5*20 + 0.5*100 = 60\n const r = computeThroughputTick(state, 1000, \"windowed\", 0.5)\n expect(Option.getOrThrow(r).rate).toBe(60)\n })\n})\n\ndescribe(\"throughput (metronome)\", () => {\n it.effect(\"emits a live char rate on the tick, over the elapsed window\", () =>\n Effect.gen(function* () {\n // 10 chars land at t=0; the source then stays open so the metronome can\n // tick. take(1) ends the stream on the first sample, so termination does\n // not depend on the source closing.\n const source = Stream.make(text(\"aaaaa\"), text(\"aaaaa\")).pipe(\n Stream.concat(timed<TurnEvent>([[\"10 seconds\", turnComplete({})]])),\n )\n const stream = source.pipe(\n throughput({ every: \"1 second\", unit: \"char\" }),\n metricEvents,\n Stream.take(1),\n )\n const out = yield* runTimed(stream, [\"1 second\"])\n const rates = out.filter((e): e is Throughput => e._tag === \"Throughput\")\n expect(rates).toHaveLength(1)\n expect(head(rates).unit).toBe(\"char\")\n expect(head(rates).turnIndex).toBe(0)\n // exact rate math is pinned by the computeThroughputTick tests; here we\n // only assert the metronome wired a positive char rate onto the tick.\n expect(head(rates).ratePerSecond).toBeGreaterThan(0)\n }),\n )\n})\n\ndescribe(\"custom events and helpers\", () => {\n it(\"makeEvent stamps the brand so isMetricEvent and the recorder see it\", () => {\n const ev = makeEvent({\n _tag: \"ToolLatency\",\n turnIndex: 0,\n measurements: [{ name: \"myapp_tool_latency\", kind: \"timer\", value: Duration.millis(42) }],\n })\n expect(isMetricEvent(ev)).toBe(true)\n expect(isMetricEvent(text(\"x\"))).toBe(false)\n expect(isMetricEvent(turnComplete({}))).toBe(false)\n })\n\n it.effect(\"metricEvents projects a mixed stream onto just the samples\", () =>\n Effect.gen(function* () {\n const stream = Stream.make(text(\"hi\"), turnComplete({ output_tokens: 3 })).pipe(\n tokenTotals,\n metricEvents,\n )\n const out = yield* Stream.runCollect(stream)\n expect(out.every(isMetricEvent)).toBe(true)\n expect(out.map((e) => e._tag)).toEqual([\"TokenTotals\"])\n }),\n )\n})\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"Metrics.test.mjs","names":["Arr"],"sources":["../../src/observability/Metrics.test.ts"],"sourcesContent":["import { describe, it } from \"@effect/vitest\"\nimport { Array as Arr, Duration, Effect, Fiber, Option, Stream } from \"effect\"\nimport * as TestClock from \"effect/testing/TestClock\"\nimport { expect } from \"vitest\"\nimport type { Usage } from \"../domain/Items.js\"\nimport { TurnEvent } from \"../domain/Turn.js\"\nimport {\n type MetricEvent,\n type Throughput,\n type TimeToCompletion,\n type TimeToFirstToken,\n type TokenTotals,\n computeThroughputTick,\n isMetricEvent,\n makeEvent,\n metricEvents,\n throughput,\n timeToCompletion,\n timeToFirstToken,\n tokenTotals,\n} from \"./Metrics.js\"\n\nconst turnComplete = (usage: Usage): TurnEvent =>\n TurnEvent.TurnComplete({ turn: { items: [], usage, stop_reason: \"stop\" } })\n\nconst text = (s: string): TurnEvent => TurnEvent.TextDelta({ text: s })\n\nconst toolArgs = (s: string): TurnEvent =>\n TurnEvent.ToolCallArgsDelta({ call_id: \"call_1\", delta: s })\n\nconst samples = (events: ReadonlyArray<unknown>): ReadonlyArray<MetricEvent> =>\n events.filter(isMetricEvent)\n\nconst head = <A>(xs: ReadonlyArray<A>): A => Option.getOrThrow(Arr.head(xs))\n\nconst tagged = <T extends MetricEvent[\"_tag\"]>(\n events: ReadonlyArray<unknown>,\n tag: T,\n): ReadonlyArray<Extract<MetricEvent, { readonly _tag: T }>> =>\n samples(events).filter((e): e is Extract<MetricEvent, { readonly _tag: T }> => e._tag === tag)\n\n// A stream that emits each event after the given delay since the previous one.\nconst timed = <A>(steps: ReadonlyArray<readonly [Duration.Input, A]>): Stream.Stream<A> =>\n Stream.fromIterable(steps).pipe(\n Stream.mapEffect(([delay, ev]) => Effect.as(Effect.sleep(delay), ev)),\n )\n\n// Collect a metered stream while advancing the test clock by each step, so\n// every per-event clock read lands at a known virtual time.\nconst runTimed = <A>(\n metered: Stream.Stream<A>,\n steps: ReadonlyArray<Duration.Input>,\n): Effect.Effect<ReadonlyArray<A>> =>\n Effect.gen(function* () {\n const fiber = yield* Effect.forkChild(Stream.runCollect(metered))\n yield* Effect.forEach(steps, (step) => TestClock.adjust(step), { discard: true })\n return yield* Fiber.join(fiber)\n })\n\ndescribe(\"tokenTotals\", () => {\n it.effect(\"turn level: a single TurnComplete reports that generation's usage\", () =>\n Effect.gen(function* () {\n const stream = Stream.make(\n text(\"hi\"),\n turnComplete({ input_tokens: 100, output_tokens: 20, total_tokens: 120 }),\n )\n const out = yield* Stream.runCollect(stream.pipe(tokenTotals))\n const totals = tagged(out, \"TokenTotals\") as ReadonlyArray<TokenTotals>\n expect(totals).toHaveLength(1)\n expect(head(totals).turnIndex).toBe(0)\n expect(head(totals).usage.output_tokens).toBe(20)\n expect(head(totals).cumulative.output_tokens).toBe(20)\n }),\n )\n\n it.effect(\"loop level: cumulative sums across every turn, usage stays per-turn\", () =>\n Effect.gen(function* () {\n const stream = Stream.make(\n text(\"a\"),\n turnComplete({ input_tokens: 1000, output_tokens: 120 }),\n turnComplete({ input_tokens: 1300, output_tokens: 410 }),\n )\n const out = yield* Stream.runCollect(stream.pipe(tokenTotals))\n const totals = tagged(out, \"TokenTotals\") as ReadonlyArray<TokenTotals>\n expect(totals.map((t) => t.turnIndex)).toEqual([0, 1])\n expect(totals.map((t) => t.usage.output_tokens)).toEqual([120, 410])\n expect(totals.map((t) => t.cumulative.input_tokens)).toEqual([1000, 2300])\n expect(totals.map((t) => t.cumulative.output_tokens)).toEqual([120, 530])\n }),\n )\n\n it.effect(\"emits an incremental output_tokens counter measurement per turn\", () =>\n Effect.gen(function* () {\n const stream = Stream.make(turnComplete({ output_tokens: 7 }))\n const out = yield* Stream.runCollect(stream.pipe(tokenTotals))\n const m = head(samples(out)).measurements.find((m) => m.name === \"effect_uai_output_tokens\")\n expect(m).toEqual({ name: \"effect_uai_output_tokens\", kind: \"counter\", value: 7 })\n }),\n )\n\n it.effect(\"carries cache read and write tokens through usage and measurements\", () =>\n Effect.gen(function* () {\n const stream = Stream.make(\n turnComplete({\n input_tokens: 94,\n input_tokens_details: { cached_tokens: 31851, cache_write_tokens: 604 },\n }),\n turnComplete({ input_tokens: 6, input_tokens_details: { cached_tokens: 32000 } }),\n )\n const out = yield* Stream.runCollect(stream.pipe(tokenTotals))\n const totals = tagged(out, \"TokenTotals\") as ReadonlyArray<TokenTotals>\n expect(totals.map((t) => t.cumulative.input_tokens_details?.cache_write_tokens)).toEqual([\n 604, 604,\n ])\n expect(totals.map((t) => t.cumulative.input_tokens_details?.cached_tokens)).toEqual([\n 31851, 63851,\n ])\n const write = head(totals).measurements.find(\n (m) => m.name === \"effect_uai_cache_write_tokens\",\n )\n expect(write).toEqual({ name: \"effect_uai_cache_write_tokens\", kind: \"counter\", value: 604 })\n }),\n )\n})\n\ndescribe(\"timeToFirstToken\", () => {\n it.effect(\"fires on the first content delta, measured from request (stream init)\", () =>\n Effect.gen(function* () {\n const stream = timed<TurnEvent>([\n [\"300 millis\", text(\"hello\")],\n [\"700 millis\", turnComplete({})],\n ]).pipe(timeToFirstToken())\n const out = yield* runTimed(stream, [\"300 millis\", \"700 millis\"])\n const ttft = tagged(out, \"TimeToFirstToken\") as ReadonlyArray<TimeToFirstToken>\n expect(ttft).toHaveLength(1)\n expect(Duration.toMillis(head(ttft).elapsed)).toBe(300)\n expect(head(ttft).kind).toBe(\"text\")\n }),\n )\n\n it.effect(\"reports kind reasoning when reasoning arrives first\", () =>\n Effect.gen(function* () {\n const stream = timed<TurnEvent>([\n [\"120 millis\", TurnEvent.ReasoningDelta({ text: \"...\", kind: \"trace\" })],\n [\"80 millis\", text(\"answer\")],\n [\"50 millis\", turnComplete({})],\n ]).pipe(timeToFirstToken())\n const out = yield* runTimed(stream, [\"120 millis\", \"80 millis\", \"50 millis\"])\n const ttft = tagged(out, \"TimeToFirstToken\") as ReadonlyArray<TimeToFirstToken>\n expect(ttft).toHaveLength(1)\n expect(head(ttft).kind).toBe(\"reasoning\")\n expect(Duration.toMillis(head(ttft).elapsed)).toBe(120)\n }),\n )\n\n it.effect(\"loop level: one TTFT per turn, turnIndex increments\", () =>\n Effect.gen(function* () {\n const stream = timed<TurnEvent>([\n [\"200 millis\", text(\"a\")],\n [\"100 millis\", turnComplete({})],\n [\"300 millis\", text(\"b\")],\n [\"100 millis\", turnComplete({})],\n ]).pipe(timeToFirstToken())\n const out = yield* runTimed(stream, [\"200 millis\", \"100 millis\", \"300 millis\", \"100 millis\"])\n const ttft = tagged(out, \"TimeToFirstToken\") as ReadonlyArray<TimeToFirstToken>\n expect(ttft.map((t) => t.turnIndex)).toEqual([0, 1])\n // turn 0 from init; turn 1 from the previous TurnComplete (loop-scope approximation)\n expect(ttft.map((t) => Duration.toMillis(t.elapsed))).toEqual([200, 300])\n }),\n )\n})\n\ndescribe(\"timeToCompletion\", () => {\n it.effect(\"reports duration (request->complete) and generation (first token->complete)\", () =>\n Effect.gen(function* () {\n const stream = timed<TurnEvent>([\n [\"300 millis\", text(\"hi\")],\n [\"700 millis\", turnComplete({})],\n ]).pipe(timeToCompletion)\n const out = yield* runTimed(stream, [\"300 millis\", \"700 millis\"])\n const done = tagged(out, \"TimeToCompletion\") as ReadonlyArray<TimeToCompletion>\n expect(done).toHaveLength(1)\n expect(Duration.toMillis(head(done).duration)).toBe(1000)\n expect(Duration.toMillis(head(done).generation)).toBe(700)\n }),\n )\n})\n\ndescribe(\"computeThroughputTick\", () => {\n const base = {\n units: 0,\n lastUnits: 0,\n lastMillis: 0,\n firstMillis: Option.none<number>(),\n smoothed: Option.none<number>(),\n turnIndex: 0,\n }\n\n it(\"returns None before the first token\", () => {\n expect(Option.isNone(computeThroughputTick(base, 500, \"windowed\", undefined))).toBe(true)\n })\n\n it(\"windowed: rate is units in the last interval over that interval\", () => {\n const state = {\n ...base,\n units: 30,\n lastUnits: 10,\n lastMillis: 500,\n firstMillis: Option.some(100),\n }\n const r = computeThroughputTick(state, 1000, \"windowed\", undefined)\n // (30 - 10) units over (1000 - 500) ms = 40/s\n expect(Option.getOrThrow(r).rate).toBe(40)\n })\n\n it(\"cumulative: rate is total units over time since first token\", () => {\n const state = {\n ...base,\n units: 30,\n lastUnits: 10,\n lastMillis: 500,\n firstMillis: Option.some(100),\n }\n const r = computeThroughputTick(state, 1000, \"cumulative\", undefined)\n // 30 units over (1000 - 100) ms ~= 33.33/s\n expect(Option.getOrThrow(r).rate).toBeCloseTo(33.333, 2)\n })\n\n it(\"smooth: blends the instantaneous rate with the carried average\", () => {\n const state = {\n ...base,\n units: 20,\n lastUnits: 0,\n lastMillis: 0,\n firstMillis: Option.some(0),\n smoothed: Option.some(100),\n }\n // windowed instant = 20 units / 1000 ms = 20/s; smoothed = 0.5*20 + 0.5*100 = 60\n const r = computeThroughputTick(state, 1000, \"windowed\", 0.5)\n expect(Option.getOrThrow(r).rate).toBe(60)\n })\n})\n\ndescribe(\"throughput (metronome)\", () => {\n it.effect(\"emits a live char rate on the tick, over the elapsed window\", () =>\n Effect.gen(function* () {\n // 10 chars land at t=0; the source then stays open so the metronome can\n // tick. take(1) ends the stream on the first sample, so termination does\n // not depend on the source closing.\n const source = Stream.make(text(\"aaaaa\"), text(\"aaaaa\")).pipe(\n Stream.concat(timed<TurnEvent>([[\"10 seconds\", turnComplete({})]])),\n )\n const stream = source.pipe(\n throughput({ every: \"1 second\", unit: \"char\" }),\n metricEvents,\n Stream.take(1),\n )\n const out = yield* runTimed(stream, [\"1 second\"])\n const rates = out.filter((e): e is Throughput => e._tag === \"Throughput\")\n expect(rates).toHaveLength(1)\n expect(head(rates).unit).toBe(\"char\")\n expect(head(rates).turnIndex).toBe(0)\n // exact rate math is pinned by the computeThroughputTick tests; here we\n // only assert the metronome wired a positive char rate onto the tick.\n expect(head(rates).ratePerSecond).toBeGreaterThan(0)\n }),\n )\n\n it.effect(\"counts tool-call arguments: a turn with no prose still reports a rate\", () =>\n Effect.gen(function* () {\n // The shape of an agent turn that only calls tools. Before output deltas\n // were counted beyond TextDelta this measured 0 for the whole run.\n const source = Stream.make(toolArgs('{\"city\":'), toolArgs('\"Lisbon\"}')).pipe(\n Stream.concat(timed<TurnEvent>([[\"10 seconds\", turnComplete({})]])),\n )\n const stream = source.pipe(\n throughput({ every: \"1 second\", unit: \"char\" }),\n metricEvents,\n Stream.take(1),\n )\n const out = yield* runTimed(stream, [\"1 second\"])\n const rates = out.filter((e): e is Throughput => e._tag === \"Throughput\")\n expect(rates).toHaveLength(1)\n expect(head(rates).ratePerSecond).toBeGreaterThan(0)\n }),\n )\n\n // Runs on the real clock: this asserts an absence, so there is no sample to\n // wait for and the stream has to end by the source closing. Under TestClock\n // that path does not settle, because the metronome re-arms a sleep that no\n // remaining `adjust` resolves.\n it.live(\"ignores events that carry no generated output\", () =>\n Effect.gen(function* () {\n // ToolCallStart and UsageUpdate are turn bookkeeping, not output.\n const source = Stream.make(\n TurnEvent.ToolCallStart({ call_id: \"call_1\", name: \"get_weather\" }),\n TurnEvent.UsageUpdate({ usage: { output_tokens: 5 } }),\n ).pipe(Stream.concat(timed<TurnEvent>([[\"30 millis\", turnComplete({})]])))\n const stream = source.pipe(throughput({ every: \"10 millis\", unit: \"char\" }), metricEvents)\n const out = yield* Stream.runCollect(stream)\n // No output delta ever landed, so the accumulator never started and the\n // metronome ticks produce nothing.\n expect(out.filter((e): e is Throughput => e._tag === \"Throughput\")).toHaveLength(0)\n }),\n )\n})\n\ndescribe(\"custom events and helpers\", () => {\n it(\"makeEvent stamps the brand so isMetricEvent and the recorder see it\", () => {\n const ev = makeEvent({\n _tag: \"ToolLatency\",\n turnIndex: 0,\n measurements: [{ name: \"myapp_tool_latency\", kind: \"timer\", value: Duration.millis(42) }],\n })\n expect(isMetricEvent(ev)).toBe(true)\n expect(isMetricEvent(text(\"x\"))).toBe(false)\n expect(isMetricEvent(turnComplete({}))).toBe(false)\n })\n\n it.effect(\"metricEvents projects a mixed stream onto just the samples\", () =>\n Effect.gen(function* () {\n const stream = Stream.make(text(\"hi\"), turnComplete({ output_tokens: 3 })).pipe(\n tokenTotals,\n metricEvents,\n )\n const out = yield* Stream.runCollect(stream)\n expect(out.every(isMetricEvent)).toBe(true)\n expect(out.map((e) => e._tag)).toEqual([\"TokenTotals\"])\n }),\n )\n})\n"],"mappings":";;;;;;;AAsBA,MAAM,gBAAgB,UACpB,UAAU,aAAa,EAAE,MAAM;CAAE,OAAO,EAAE;CAAE;CAAO,aAAa;CAAQ,EAAE,CAAC;AAE7E,MAAM,QAAQ,MAAyB,UAAU,UAAU,EAAE,MAAM,GAAG,CAAC;AAEvE,MAAM,YAAY,MAChB,UAAU,kBAAkB;CAAE,SAAS;CAAU,OAAO;CAAG,CAAC;AAE9D,MAAM,WAAW,WACf,OAAO,OAAO,cAAc;AAE9B,MAAM,QAAW,OAA4B,OAAO,WAAWA,MAAI,KAAK,GAAG,CAAC;AAE5E,MAAM,UACJ,QACA,QAEA,QAAQ,OAAO,CAAC,QAAQ,MAAuD,EAAE,SAAS,IAAI;AAGhG,MAAM,SAAY,UAChB,OAAO,aAAa,MAAM,CAAC,KACzB,OAAO,WAAW,CAAC,OAAO,QAAQ,OAAO,GAAG,OAAO,MAAM,MAAM,EAAE,GAAG,CAAC,CACtE;AAIH,MAAM,YACJ,SACA,UAEA,OAAO,IAAI,aAAa;CACtB,MAAM,QAAQ,OAAO,OAAO,UAAU,OAAO,WAAW,QAAQ,CAAC;AACjE,QAAO,OAAO,QAAQ,QAAQ,SAAS,UAAU,OAAO,KAAK,EAAE,EAAE,SAAS,MAAM,CAAC;AACjF,QAAO,OAAO,MAAM,KAAK,MAAM;EAC/B;AAEJ,SAAS,qBAAqB;AAC5B,IAAG,OAAO,2EACR,OAAO,IAAI,aAAa;EACtB,MAAM,SAAS,OAAO,KACpB,KAAK,KAAK,EACV,aAAa;GAAE,cAAc;GAAK,eAAe;GAAI,cAAc;GAAK,CAAC,CAC1E;EAED,MAAM,SAAS,OAAO,OADH,OAAO,WAAW,OAAO,KAAK,YAAY,CAAC,EACnC,cAAc;AACzC,eAAO,OAAO,CAAC,aAAa,EAAE;AAC9B,eAAO,KAAK,OAAO,CAAC,UAAU,CAAC,KAAK,EAAE;AACtC,eAAO,KAAK,OAAO,CAAC,MAAM,cAAc,CAAC,KAAK,GAAG;AACjD,eAAO,KAAK,OAAO,CAAC,WAAW,cAAc,CAAC,KAAK,GAAG;GACtD,CACH;AAED,IAAG,OAAO,6EACR,OAAO,IAAI,aAAa;EACtB,MAAM,SAAS,OAAO,KACpB,KAAK,IAAI,EACT,aAAa;GAAE,cAAc;GAAM,eAAe;GAAK,CAAC,EACxD,aAAa;GAAE,cAAc;GAAM,eAAe;GAAK,CAAC,CACzD;EAED,MAAM,SAAS,OAAO,OADH,OAAO,WAAW,OAAO,KAAK,YAAY,CAAC,EACnC,cAAc;AACzC,eAAO,OAAO,KAAK,MAAM,EAAE,UAAU,CAAC,CAAC,QAAQ,CAAC,GAAG,EAAE,CAAC;AACtD,eAAO,OAAO,KAAK,MAAM,EAAE,MAAM,cAAc,CAAC,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAC;AACpE,eAAO,OAAO,KAAK,MAAM,EAAE,WAAW,aAAa,CAAC,CAAC,QAAQ,CAAC,KAAM,KAAK,CAAC;AAC1E,eAAO,OAAO,KAAK,MAAM,EAAE,WAAW,cAAc,CAAC,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAC;GACzE,CACH;AAED,IAAG,OAAO,yEACR,OAAO,IAAI,aAAa;EACtB,MAAM,SAAS,OAAO,KAAK,aAAa,EAAE,eAAe,GAAG,CAAC,CAAC;AAG9D,eADU,KAAK,QAAQ,OADJ,OAAO,WAAW,OAAO,KAAK,YAAY,CAAC,CACnC,CAAC,CAAC,aAAa,MAAM,MAAM,EAAE,SAAS,2BACzD,CAAC,CAAC,QAAQ;GAAE,MAAM;GAA4B,MAAM;GAAW,OAAO;GAAG,CAAC;GAClF,CACH;AAED,IAAG,OAAO,4EACR,OAAO,IAAI,aAAa;EACtB,MAAM,SAAS,OAAO,KACpB,aAAa;GACX,cAAc;GACd,sBAAsB;IAAE,eAAe;IAAO,oBAAoB;IAAK;GACxE,CAAC,EACF,aAAa;GAAE,cAAc;GAAG,sBAAsB,EAAE,eAAe,MAAO;GAAE,CAAC,CAClF;EAED,MAAM,SAAS,OAAO,OADH,OAAO,WAAW,OAAO,KAAK,YAAY,CAAC,EACnC,cAAc;AACzC,eAAO,OAAO,KAAK,MAAM,EAAE,WAAW,sBAAsB,mBAAmB,CAAC,CAAC,QAAQ,CACvF,KAAK,IACN,CAAC;AACF,eAAO,OAAO,KAAK,MAAM,EAAE,WAAW,sBAAsB,cAAc,CAAC,CAAC,QAAQ,CAClF,OAAO,MACR,CAAC;AAIF,eAHc,KAAK,OAAO,CAAC,aAAa,MACrC,MAAM,EAAE,SAAS,gCAER,CAAC,CAAC,QAAQ;GAAE,MAAM;GAAiC,MAAM;GAAW,OAAO;GAAK,CAAC;GAC7F,CACH;EACD;AAEF,SAAS,0BAA0B;AACjC,IAAG,OAAO,+EACR,OAAO,IAAI,aAAa;EAMtB,MAAM,OAAO,OAAO,OADD,SAJJ,MAAiB,CAC9B,CAAC,cAAc,KAAK,QAAQ,CAAC,EAC7B,CAAC,cAAc,aAAa,EAAE,CAAC,CAAC,CACjC,CAAC,CAAC,KAAK,kBAAkB,CACQ,EAAE,CAAC,cAAc,aAAa,CAAC,EACxC,mBAAmB;AAC5C,eAAO,KAAK,CAAC,aAAa,EAAE;AAC5B,eAAO,SAAS,SAAS,KAAK,KAAK,CAAC,QAAQ,CAAC,CAAC,KAAK,IAAI;AACvD,eAAO,KAAK,KAAK,CAAC,KAAK,CAAC,KAAK,OAAO;GACpC,CACH;AAED,IAAG,OAAO,6DACR,OAAO,IAAI,aAAa;EAOtB,MAAM,OAAO,OAAO,OADD,SALJ,MAAiB;GAC9B,CAAC,cAAc,UAAU,eAAe;IAAE,MAAM;IAAO,MAAM;IAAS,CAAC,CAAC;GACxE,CAAC,aAAa,KAAK,SAAS,CAAC;GAC7B,CAAC,aAAa,aAAa,EAAE,CAAC,CAAC;GAChC,CAAC,CAAC,KAAK,kBAAkB,CACQ,EAAE;GAAC;GAAc;GAAa;GAAY,CAAC,EACpD,mBAAmB;AAC5C,eAAO,KAAK,CAAC,aAAa,EAAE;AAC5B,eAAO,KAAK,KAAK,CAAC,KAAK,CAAC,KAAK,YAAY;AACzC,eAAO,SAAS,SAAS,KAAK,KAAK,CAAC,QAAQ,CAAC,CAAC,KAAK,IAAI;GACvD,CACH;AAED,IAAG,OAAO,6DACR,OAAO,IAAI,aAAa;EAQtB,MAAM,OAAO,OAAO,OADD,SANJ,MAAiB;GAC9B,CAAC,cAAc,KAAK,IAAI,CAAC;GACzB,CAAC,cAAc,aAAa,EAAE,CAAC,CAAC;GAChC,CAAC,cAAc,KAAK,IAAI,CAAC;GACzB,CAAC,cAAc,aAAa,EAAE,CAAC,CAAC;GACjC,CAAC,CAAC,KAAK,kBAAkB,CACQ,EAAE;GAAC;GAAc;GAAc;GAAc;GAAa,CAAC,EACpE,mBAAmB;AAC5C,eAAO,KAAK,KAAK,MAAM,EAAE,UAAU,CAAC,CAAC,QAAQ,CAAC,GAAG,EAAE,CAAC;AAEpD,eAAO,KAAK,KAAK,MAAM,SAAS,SAAS,EAAE,QAAQ,CAAC,CAAC,CAAC,QAAQ,CAAC,KAAK,IAAI,CAAC;GACzE,CACH;EACD;AAEF,SAAS,0BAA0B;AACjC,IAAG,OAAO,qFACR,OAAO,IAAI,aAAa;EAMtB,MAAM,OAAO,OAAO,OADD,SAJJ,MAAiB,CAC9B,CAAC,cAAc,KAAK,KAAK,CAAC,EAC1B,CAAC,cAAc,aAAa,EAAE,CAAC,CAAC,CACjC,CAAC,CAAC,KAAK,iBAC0B,EAAE,CAAC,cAAc,aAAa,CAAC,EACxC,mBAAmB;AAC5C,eAAO,KAAK,CAAC,aAAa,EAAE;AAC5B,eAAO,SAAS,SAAS,KAAK,KAAK,CAAC,SAAS,CAAC,CAAC,KAAK,IAAK;AACzD,eAAO,SAAS,SAAS,KAAK,KAAK,CAAC,WAAW,CAAC,CAAC,KAAK,IAAI;GAC1D,CACH;EACD;AAEF,SAAS,+BAA+B;CACtC,MAAM,OAAO;EACX,OAAO;EACP,WAAW;EACX,YAAY;EACZ,aAAa,OAAO,MAAc;EAClC,UAAU,OAAO,MAAc;EAC/B,WAAW;EACZ;AAED,IAAG,6CAA6C;AAC9C,eAAO,OAAO,OAAO,sBAAsB,MAAM,KAAK,YAAY,KAAA,EAAU,CAAC,CAAC,CAAC,KAAK,KAAK;GACzF;AAEF,IAAG,yEAAyE;EAQ1E,MAAM,IAAI,sBAAsB;GAN9B,GAAG;GACH,OAAO;GACP,WAAW;GACX,YAAY;GACZ,aAAa,OAAO,KAAK,IAAI;GAEM,EAAE,KAAM,YAAY,KAAA,EAAU;AAEnE,eAAO,OAAO,WAAW,EAAE,CAAC,KAAK,CAAC,KAAK,GAAG;GAC1C;AAEF,IAAG,qEAAqE;EAQtE,MAAM,IAAI,sBAAsB;GAN9B,GAAG;GACH,OAAO;GACP,WAAW;GACX,YAAY;GACZ,aAAa,OAAO,KAAK,IAAI;GAEM,EAAE,KAAM,cAAc,KAAA,EAAU;AAErE,eAAO,OAAO,WAAW,EAAE,CAAC,KAAK,CAAC,YAAY,QAAQ,EAAE;GACxD;AAEF,IAAG,wEAAwE;EAUzE,MAAM,IAAI,sBAAsB;GAR9B,GAAG;GACH,OAAO;GACP,WAAW;GACX,YAAY;GACZ,aAAa,OAAO,KAAK,EAAE;GAC3B,UAAU,OAAO,KAAK,IAAI;GAGS,EAAE,KAAM,YAAY,GAAI;AAC7D,eAAO,OAAO,WAAW,EAAE,CAAC,KAAK,CAAC,KAAK,GAAG;GAC1C;EACF;AAEF,SAAS,gCAAgC;AACvC,IAAG,OAAO,qEACR,OAAO,IAAI,aAAa;EAatB,MAAM,SAAQ,OADK,SARJ,OAAO,KAAK,KAAK,QAAQ,EAAE,KAAK,QAAQ,CAAC,CAAC,KACvD,OAAO,OAAO,MAAiB,CAAC,CAAC,cAAc,aAAa,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAEhD,CAAC,KACpB,WAAW;GAAE,OAAO;GAAY,MAAM;GAAQ,CAAC,EAC/C,cACA,OAAO,KAAK,EAAE,CAEkB,EAAE,CAAC,WAAW,CAAC,EAC/B,QAAQ,MAAuB,EAAE,SAAS,aAAa;AACzE,eAAO,MAAM,CAAC,aAAa,EAAE;AAC7B,eAAO,KAAK,MAAM,CAAC,KAAK,CAAC,KAAK,OAAO;AACrC,eAAO,KAAK,MAAM,CAAC,UAAU,CAAC,KAAK,EAAE;AAGrC,eAAO,KAAK,MAAM,CAAC,cAAc,CAAC,gBAAgB,EAAE;GACpD,CACH;AAED,IAAG,OAAO,+EACR,OAAO,IAAI,aAAa;EAYtB,MAAM,SAAQ,OADK,SARJ,OAAO,KAAK,SAAS,aAAW,EAAE,SAAS,cAAY,CAAC,CAAC,KACtE,OAAO,OAAO,MAAiB,CAAC,CAAC,cAAc,aAAa,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CAEhD,CAAC,KACpB,WAAW;GAAE,OAAO;GAAY,MAAM;GAAQ,CAAC,EAC/C,cACA,OAAO,KAAK,EAAE,CAEkB,EAAE,CAAC,WAAW,CAAC,EAC/B,QAAQ,MAAuB,EAAE,SAAS,aAAa;AACzE,eAAO,MAAM,CAAC,aAAa,EAAE;AAC7B,eAAO,KAAK,MAAM,CAAC,cAAc,CAAC,gBAAgB,EAAE;GACpD,CACH;AAMD,IAAG,KAAK,uDACN,OAAO,IAAI,aAAa;EAMtB,MAAM,SAJS,OAAO,KACpB,UAAU,cAAc;GAAE,SAAS;GAAU,MAAM;GAAe,CAAC,EACnE,UAAU,YAAY,EAAE,OAAO,EAAE,eAAe,GAAG,EAAE,CAAC,CACvD,CAAC,KAAK,OAAO,OAAO,MAAiB,CAAC,CAAC,aAAa,aAAa,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,CACpD,CAAC,KAAK,WAAW;GAAE,OAAO;GAAa,MAAM;GAAQ,CAAC,EAAE,aAAa;AAI1F,gBAAO,OAHY,OAAO,WAAW,OAAO,EAGjC,QAAQ,MAAuB,EAAE,SAAS,aAAa,CAAC,CAAC,aAAa,EAAE;GACnF,CACH;EACD;AAEF,SAAS,mCAAmC;AAC1C,IAAG,6EAA6E;AAM9E,eAAO,cALI,UAAU;GACnB,MAAM;GACN,WAAW;GACX,cAAc,CAAC;IAAE,MAAM;IAAsB,MAAM;IAAS,OAAO,SAAS,OAAO,GAAG;IAAE,CAAC;GAC1F,CACsB,CAAC,CAAC,CAAC,KAAK,KAAK;AACpC,eAAO,cAAc,KAAK,IAAI,CAAC,CAAC,CAAC,KAAK,MAAM;AAC5C,eAAO,cAAc,aAAa,EAAE,CAAC,CAAC,CAAC,CAAC,KAAK,MAAM;GACnD;AAEF,IAAG,OAAO,oEACR,OAAO,IAAI,aAAa;EACtB,MAAM,SAAS,OAAO,KAAK,KAAK,KAAK,EAAE,aAAa,EAAE,eAAe,GAAG,CAAC,CAAC,CAAC,KACzE,aACA,aACD;EACD,MAAM,MAAM,OAAO,OAAO,WAAW,OAAO;AAC5C,eAAO,IAAI,MAAM,cAAc,CAAC,CAAC,KAAK,KAAK;AAC3C,eAAO,IAAI,KAAK,MAAM,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,cAAc,CAAC;GACvD,CACH;EACD"}
|
package/dist/retry/Retry.d.mts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { d as RateLimited, m as Unavailable, p as Timeout, t as AiError } from "../AiError-Bf1pq4BB.mjs";
|
|
1
|
+
import { d as RateLimited, m as Unavailable, p as Timeout, t as AiError, u as InvalidRequest } from "../AiError-Bf1pq4BB.mjs";
|
|
2
2
|
import { Effect, Schedule, Stream } from "effect";
|
|
3
3
|
import * as _$effect_Cause0 from "effect/Cause";
|
|
4
4
|
import * as _$effect_Types0 from "effect/Types";
|
|
@@ -37,10 +37,10 @@ declare const Retryable_base: new <A extends Record<string, any> = {}>(args: _$e
|
|
|
37
37
|
* variants if needed (e.g. a custom schedule that observes `cause`).
|
|
38
38
|
*/
|
|
39
39
|
declare class Retryable extends Retryable_base<{
|
|
40
|
-
readonly cause: RateLimited | Unavailable | Timeout;
|
|
40
|
+
readonly cause: RateLimited | Unavailable | Timeout | InvalidRequest;
|
|
41
41
|
}> {}
|
|
42
42
|
/** Type-narrowing predicate for the retryable subset. */
|
|
43
|
-
declare const isRetryable: (e: AiError) => e is RateLimited | Unavailable | Timeout;
|
|
43
|
+
declare const isRetryable: (e: AiError) => e is RateLimited | Unavailable | Timeout | InvalidRequest;
|
|
44
44
|
/**
|
|
45
45
|
* Retry a `Stream<A, AiError, R>` on the retryable subset. Other
|
|
46
46
|
* failures bypass the schedule and propagate unchanged. The whole
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Retry.d.mts","names":[],"sources":["../../src/retry/Retry.ts"],"mappings":";;;;;;;;;cACoD,cAAA;;;;;;;;;;;AAAA;;;;;;;;;;;;;;;;;;cA6BvC,SAAA,SAAkB,cAAA;EAAA,SACpB,KAAA,
|
|
1
|
+
{"version":3,"file":"Retry.d.mts","names":[],"sources":["../../src/retry/Retry.ts"],"mappings":";;;;;;;;;cACoD,cAAA;;;;;;;;;;;AAAA;;;;;;;;;;;;;;;;;;cA6BvC,SAAA,SAAkB,cAAA;EAAA,SACpB,KAAA,EACL,WAAA,GACA,WAAA,GACA,OAAA,GACA,cAAA;AAAA;;cAsCO,WAAA,GACX,CAAA,EAAG,OAAA,KACF,CAAA,IAAK,WAAA,GAAsB,WAAA,GAAsB,OAAA,GAAkB,cAAA;;;;;;;cAmBzD,MAAA,QACL,QAAA,EAAU,QAAA,CAAS,QAAA,CAAS,GAAA,EAAK,SAAA,aAChC,CAAA,EAAG,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA,MAAK,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA;AAlErF;;;;;;;;;;AAAA,cA2Fa,MAAA,QACL,QAAA,EAAU,QAAA,CAAS,QAAA,CAAS,GAAA,EAAK,OAAA,aAChC,GAAA,EAAK,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA,MAAK,MAAA,CAAO,MAAA,CAAO,CAAA,EAAG,OAAA,EAAiB,CAAA"}
|
package/dist/retry/Retry.mjs
CHANGED
|
@@ -34,8 +34,37 @@ var Retry_exports = /* @__PURE__ */ __exportAll({
|
|
|
34
34
|
* variants if needed (e.g. a custom schedule that observes `cause`).
|
|
35
35
|
*/
|
|
36
36
|
var Retryable = class extends Data.TaggedError("RetryableAi") {};
|
|
37
|
+
/**
|
|
38
|
+
* `InvalidRequest` payloads that are a transient fault on the provider's side
|
|
39
|
+
* rather than a defect in the request.
|
|
40
|
+
*
|
|
41
|
+
* The class exists because a provider can only answer within its own error
|
|
42
|
+
* vocabulary: a constrained-decoding grammar that times out while compiling is
|
|
43
|
+
* a cold-start timeout, but it arrives as `invalid_request_error` because that
|
|
44
|
+
* is where the vendor files schema problems. Replaying it succeeds — the same
|
|
45
|
+
* schema compiles on the next call.
|
|
46
|
+
*
|
|
47
|
+
* An allow-list rather than a general softening of `InvalidRequest`, and it
|
|
48
|
+
* must stay one. The whole point of that tag being fatal is that a genuinely
|
|
49
|
+
* malformed request retried on a schedule burns quota to arrive at the same
|
|
50
|
+
* rejection, slowly. An entry earns its place by having been observed to
|
|
51
|
+
* succeed on replay.
|
|
52
|
+
*/
|
|
53
|
+
const TRANSIENT_INVALID_REQUEST = [/grammar compilation timed out/i];
|
|
54
|
+
/** The payload as text, for matching. `raw` is whatever the provider handed
|
|
55
|
+
* back — usually its response body as a string, sometimes a parsed object,
|
|
56
|
+
* and occasionally something that will not serialise. */
|
|
57
|
+
const rawText = (raw) => {
|
|
58
|
+
if (typeof raw === "string") return raw;
|
|
59
|
+
try {
|
|
60
|
+
return JSON.stringify(raw) ?? "";
|
|
61
|
+
} catch {
|
|
62
|
+
return String(raw);
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
const isTransientInvalidRequest = (e) => e._tag === "InvalidRequest" && TRANSIENT_INVALID_REQUEST.some((pattern) => pattern.test(rawText(e.raw)));
|
|
37
66
|
/** Type-narrowing predicate for the retryable subset. */
|
|
38
|
-
const isRetryable = (e) => e._tag === "RateLimited" || e._tag === "Unavailable" || e._tag === "Timeout";
|
|
67
|
+
const isRetryable = (e) => e._tag === "RateLimited" || e._tag === "Unavailable" || e._tag === "Timeout" || isTransientInvalidRequest(e);
|
|
39
68
|
/**
|
|
40
69
|
* Retry a `Stream<A, AiError, R>` on the retryable subset. Other
|
|
41
70
|
* failures bypass the schedule and propagate unchanged. The whole
|
package/dist/retry/Retry.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"Retry.mjs","names":[],"sources":["../../src/retry/Retry.ts"],"sourcesContent":["import { Data, Effect, type Schedule, Stream } from \"effect\"\nimport type * as AiError from \"../domain/AiError.js\"\n\n/**\n * Subset-aware retry helpers for `AiError`.\n *\n * Effect's own `Effect.retry` / `Stream.retry` apply the schedule to\n * every failure. For AI calls that's the wrong policy — replaying a\n * `ContentFiltered` or `InvalidRequest` won't change the outcome and\n * just wastes quota. These helpers gate the schedule on the\n * {@link Retryable} subset (rate limits, transport hiccups, timeouts);\n * everything else propagates unchanged.\n *\n * Two carriers:\n * - {@link stream} — for `Stream<A, AiError, R>` (e.g. `streamTurn`,\n * `streamSynthesis`, `streamTranscriptionFrom`). Caveat: the entire\n * stream re-runs on retry, so deltas before the failure replay on\n * the next attempt.\n * - {@link effect} — for `Effect<A, AiError, R>` (e.g. `turn`, `embed`,\n * `embedMany`, `synthesize`, `transcribe`). At-most-once.\n *\n * Composes with `Stream.retry` / `Effect.retry` directly (no naming\n * collision since these live under the `Retry` namespace).\n */\n\n/**\n * Wrapper around the retryable subset of `AiError`. Internal to the\n * lift/retry/unlift dance — exported so callers can build their own\n * variants if needed (e.g. a custom schedule that observes `cause`).\n */\nexport class Retryable extends Data.TaggedError(\"RetryableAi\")<{\n readonly cause
|
|
1
|
+
{"version":3,"file":"Retry.mjs","names":[],"sources":["../../src/retry/Retry.ts"],"sourcesContent":["import { Data, Effect, type Schedule, Stream } from \"effect\"\nimport type * as AiError from \"../domain/AiError.js\"\n\n/**\n * Subset-aware retry helpers for `AiError`.\n *\n * Effect's own `Effect.retry` / `Stream.retry` apply the schedule to\n * every failure. For AI calls that's the wrong policy — replaying a\n * `ContentFiltered` or `InvalidRequest` won't change the outcome and\n * just wastes quota. These helpers gate the schedule on the\n * {@link Retryable} subset (rate limits, transport hiccups, timeouts);\n * everything else propagates unchanged.\n *\n * Two carriers:\n * - {@link stream} — for `Stream<A, AiError, R>` (e.g. `streamTurn`,\n * `streamSynthesis`, `streamTranscriptionFrom`). Caveat: the entire\n * stream re-runs on retry, so deltas before the failure replay on\n * the next attempt.\n * - {@link effect} — for `Effect<A, AiError, R>` (e.g. `turn`, `embed`,\n * `embedMany`, `synthesize`, `transcribe`). At-most-once.\n *\n * Composes with `Stream.retry` / `Effect.retry` directly (no naming\n * collision since these live under the `Retry` namespace).\n */\n\n/**\n * Wrapper around the retryable subset of `AiError`. Internal to the\n * lift/retry/unlift dance — exported so callers can build their own\n * variants if needed (e.g. a custom schedule that observes `cause`).\n */\nexport class Retryable extends Data.TaggedError(\"RetryableAi\")<{\n readonly cause:\n | AiError.RateLimited\n | AiError.Unavailable\n | AiError.Timeout\n | AiError.InvalidRequest\n}> {}\n\n/**\n * `InvalidRequest` payloads that are a transient fault on the provider's side\n * rather than a defect in the request.\n *\n * The class exists because a provider can only answer within its own error\n * vocabulary: a constrained-decoding grammar that times out while compiling is\n * a cold-start timeout, but it arrives as `invalid_request_error` because that\n * is where the vendor files schema problems. Replaying it succeeds — the same\n * schema compiles on the next call.\n *\n * An allow-list rather than a general softening of `InvalidRequest`, and it\n * must stay one. The whole point of that tag being fatal is that a genuinely\n * malformed request retried on a schedule burns quota to arrive at the same\n * rejection, slowly. An entry earns its place by having been observed to\n * succeed on replay.\n */\nconst TRANSIENT_INVALID_REQUEST: ReadonlyArray<RegExp> = [/grammar compilation timed out/i]\n\n/** The payload as text, for matching. `raw` is whatever the provider handed\n * back — usually its response body as a string, sometimes a parsed object,\n * and occasionally something that will not serialise. */\nconst rawText = (raw: unknown): string => {\n if (typeof raw === \"string\") return raw\n try {\n return JSON.stringify(raw) ?? \"\"\n } catch {\n return String(raw)\n }\n}\n\nconst isTransientInvalidRequest = (e: AiError.AiError): boolean =>\n e._tag === \"InvalidRequest\" &&\n TRANSIENT_INVALID_REQUEST.some((pattern) => pattern.test(rawText(e.raw)))\n\n/** Type-narrowing predicate for the retryable subset. */\nexport const isRetryable = (\n e: AiError.AiError,\n): e is AiError.RateLimited | AiError.Unavailable | AiError.Timeout | AiError.InvalidRequest =>\n e._tag === \"RateLimited\" ||\n e._tag === \"Unavailable\" ||\n e._tag === \"Timeout\" ||\n isTransientInvalidRequest(e)\n\n// Lift events to Items, non-retryable failures to Terminal values\n// (escape retry), retryable failures to wrapped errors (only thing\n// retry sees).\ntype Lifted<A> =\n | { readonly _tag: \"Item\"; readonly value: A }\n | { readonly _tag: \"Terminal\"; readonly cause: AiError.AiError }\n\n/**\n * Retry a `Stream<A, AiError, R>` on the retryable subset. Other\n * failures bypass the schedule and propagate unchanged. The whole\n * stream replays on each attempt — deltas emitted before the failure\n * are re-emitted on the next try.\n */\nexport const stream =\n <Out>(schedule: Schedule.Schedule<Out, Retryable>) =>\n <A, R>(s: Stream.Stream<A, AiError.AiError, R>): Stream.Stream<A, AiError.AiError, R> =>\n s.pipe(\n Stream.map((value): Lifted<A> => ({ _tag: \"Item\", value })),\n Stream.catchIf(\n isRetryable,\n (cause) => Stream.fail(new Retryable({ cause })),\n (cause) => Stream.succeed<Lifted<A>>({ _tag: \"Terminal\", cause }),\n ),\n Stream.retry(schedule),\n Stream.catchTag(\"RetryableAi\", (e) => Stream.fail<AiError.AiError>(e.cause)),\n Stream.flatMap((item) =>\n item._tag === \"Item\" ? Stream.succeed(item.value) : Stream.fail(item.cause),\n ),\n )\n\n/**\n * Retry an `Effect<A, AiError, R>` on the retryable subset. Other\n * failures bypass the schedule and propagate unchanged. At-most-once\n * — the underlying call only re-runs if the previous attempt failed\n * with a retryable tag.\n *\n * Skips the lift/unlift dance the Stream variant needs: `Effect.retry`\n * exposes a `while:` predicate so the error channel stays a plain\n * `AiError` throughout.\n */\nexport const effect =\n <Out>(schedule: Schedule.Schedule<Out, AiError.AiError>) =>\n <A, R>(eff: Effect.Effect<A, AiError.AiError, R>): Effect.Effect<A, AiError.AiError, R> =>\n Effect.retry(eff, { schedule, while: isRetryable })\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA8BA,IAAa,YAAb,cAA+B,KAAK,YAAY,cAAc,CAM3D;;;;;;;;;;;;;;;;;AAkBH,MAAM,4BAAmD,CAAC,iCAAiC;;;;AAK3F,MAAM,WAAW,QAAyB;AACxC,KAAI,OAAO,QAAQ,SAAU,QAAO;AACpC,KAAI;AACF,SAAO,KAAK,UAAU,IAAI,IAAI;SACxB;AACN,SAAO,OAAO,IAAI;;;AAItB,MAAM,6BAA6B,MACjC,EAAE,SAAS,oBACX,0BAA0B,MAAM,YAAY,QAAQ,KAAK,QAAQ,EAAE,IAAI,CAAC,CAAC;;AAG3E,MAAa,eACX,MAEA,EAAE,SAAS,iBACX,EAAE,SAAS,iBACX,EAAE,SAAS,aACX,0BAA0B,EAAE;;;;;;;AAe9B,MAAa,UACL,cACC,MACL,EAAE,KACA,OAAO,KAAK,WAAsB;CAAE,MAAM;CAAQ;CAAO,EAAE,EAC3D,OAAO,QACL,cACC,UAAU,OAAO,KAAK,IAAI,UAAU,EAAE,OAAO,CAAC,CAAC,GAC/C,UAAU,OAAO,QAAmB;CAAE,MAAM;CAAY;CAAO,CAAC,CAClE,EACD,OAAO,MAAM,SAAS,EACtB,OAAO,SAAS,gBAAgB,MAAM,OAAO,KAAsB,EAAE,MAAM,CAAC,EAC5E,OAAO,SAAS,SACd,KAAK,SAAS,SAAS,OAAO,QAAQ,KAAK,MAAM,GAAG,OAAO,KAAK,KAAK,MAAM,CAC5E,CACF;;;;;;;;;;;AAYL,MAAa,UACL,cACC,QACL,OAAO,MAAM,KAAK;CAAE;CAAU,OAAO;CAAa,CAAC"}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { o as HistoryItem } from "../Items-
|
|
1
|
+
import { o as HistoryItem } from "../Items-BKQgqJ8A.mjs";
|
|
2
2
|
import { Turn } from "../domain/Turn.mjs";
|
|
3
3
|
import { LanguageModel, LanguageModelService } from "../language-model/LanguageModel.mjs";
|
|
4
4
|
import { Duration, Effect, Layer } from "effect";
|
|
@@ -582,7 +582,7 @@ type MockSandboxScript = {
|
|
|
582
582
|
* exercises any of them.
|
|
583
583
|
*/
|
|
584
584
|
declare const layer: (script?: MockSandboxScript) => {
|
|
585
|
-
layer: Layer.Layer<
|
|
585
|
+
layer: Layer.Layer<SandboxHostnameAllowlist | SandboxSecretInjection | SandboxSnapshots | SandboxPauseResume | SandboxVolumes | SandboxKernelSession | SandboxPty | SandboxCustomImage | SandboxPortExposure | Sandbox, never, never>;
|
|
586
586
|
recorder: Effect.Effect<MockSandboxRecorder, never, never>;
|
|
587
587
|
};
|
|
588
588
|
/**
|
package/dist/tool/Approval.d.mts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
import { v as ToolCall } from "../Items-
|
|
2
|
-
import { t as ToolResult } from "../ToolResult-
|
|
3
|
-
import { t as ToolEvent } from "../ToolEvent-
|
|
1
|
+
import { v as ToolCall } from "../Items-BKQgqJ8A.mjs";
|
|
2
|
+
import { t as ToolResult } from "../ToolResult-CRpmkBXR.mjs";
|
|
3
|
+
import { t as ToolEvent } from "../ToolEvent-BBfOdhF_.mjs";
|
|
4
4
|
import { Data, Duration, Effect, Queue, Scope, Stream } from "effect";
|
|
5
5
|
import * as _$effect_Unify0 from "effect/Unify";
|
|
6
6
|
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { o as HistoryItem, v as ToolCall } from "../Items-
|
|
2
|
-
import { t as ToolResult } from "../ToolResult-
|
|
1
|
+
import { o as HistoryItem, v as ToolCall } from "../Items-BKQgqJ8A.mjs";
|
|
2
|
+
import { t as ToolResult } from "../ToolResult-CRpmkBXR.mjs";
|
|
3
3
|
|
|
4
4
|
//#region src/tool/HistoryCheck.d.ts
|
|
5
5
|
declare namespace HistoryCheck_d_exports {
|
package/dist/tool/Tool.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as providerToolsOf, C as fromEffectSchema, D as make, E as isProviderTool, M as toDescriptors, N as withName, O as noInput, P as withRun, S as fail, T as interaction, a as Emit, b as descriptorsOf, c as SignalTool, d as ToolE, f as ToolError, g as ToolValidationError, h as ToolR, i as DecodeResult, j as signal, k as provider, l as Tool, m as ToolInputSchema, n as AnyTool, o as InteractionTool, p as ToolFailed, r as DecodableTool, s as ProviderTool, t as AnyLocalTool, u as ToolDescriptor, v as decodeArgs, w as fromStandardSchema, x as execute, y as decodeCallInput } from "../Tool-
|
|
1
|
+
import { A as providerToolsOf, C as fromEffectSchema, D as make, E as isProviderTool, M as toDescriptors, N as withName, O as noInput, P as withRun, S as fail, T as interaction, a as Emit, b as descriptorsOf, c as SignalTool, d as ToolE, f as ToolError, g as ToolValidationError, h as ToolR, i as DecodeResult, j as signal, k as provider, l as Tool, m as ToolInputSchema, n as AnyTool, o as InteractionTool, p as ToolFailed, r as DecodableTool, s as ProviderTool, t as AnyLocalTool, u as ToolDescriptor, v as decodeArgs, w as fromStandardSchema, x as execute, y as decodeCallInput } from "../Tool-u5bag8SO.mjs";
|
|
2
2
|
export { AnyLocalTool, AnyTool, DecodableTool, DecodeResult, Emit, InteractionTool, ProviderTool, SignalTool, Tool, ToolDescriptor, ToolE, ToolError, ToolFailed, ToolInputSchema, ToolR, ToolValidationError, decodeArgs, decodeCallInput, descriptorsOf, execute, fail, fromEffectSchema, fromStandardSchema, interaction, isProviderTool, make, noInput, provider, providerToolsOf, signal, toDescriptors, withName, withRun };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as isProgress, i as isOutput, r as isApprovalRequested, t as ToolEvent } from "../ToolEvent-
|
|
1
|
+
import { a as isProgress, i as isOutput, r as isApprovalRequested, t as ToolEvent } from "../ToolEvent-BBfOdhF_.mjs";
|
|
2
2
|
export { ToolEvent, isApprovalRequested, isOutput, isProgress };
|
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { a as executionError, c as isOk, d as toToolCallOutput, f as validationError, i as denied, l as nonLocalTool, o as failed, r as cancelled, s as isFailure, t as ToolResult, u as serializeValue } from "../ToolResult-
|
|
1
|
+
import { a as executionError, c as isOk, d as toToolCallOutput, f as validationError, i as denied, l as nonLocalTool, o as failed, r as cancelled, s as isFailure, t as ToolResult, u as serializeValue } from "../ToolResult-CRpmkBXR.mjs";
|
|
2
2
|
export { ToolResult, cancelled, denied, executionError, failed, isFailure, isOk, nonLocalTool, serializeValue, toToolCallOutput, validationError };
|
package/dist/tool/Toolkit.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { S as wrap, _ as fromArray, a as ToolMap, b as namespace, c as ToolkitR, d as appendToolResults, f as collectResults, g as descriptors, h as describeFailures, i as NoDuplicateNames, m as continueWithResults, n as ExecuteOptions, o as Toolkit, p as compose, r as Middleware, s as ToolkitE, t as DuplicateToolName, u as UniqueTools, v as make, x as run, y as makeNamespaced } from "../Toolkit-
|
|
1
|
+
import { S as wrap, _ as fromArray, a as ToolMap, b as namespace, c as ToolkitR, d as appendToolResults, f as collectResults, g as descriptors, h as describeFailures, i as NoDuplicateNames, m as continueWithResults, n as ExecuteOptions, o as Toolkit, p as compose, r as Middleware, s as ToolkitE, t as DuplicateToolName, u as UniqueTools, v as make, x as run, y as makeNamespaced } from "../Toolkit-D4mQvhjq.mjs";
|
|
2
2
|
export { DuplicateToolName, ExecuteOptions, Middleware, NoDuplicateNames, ToolMap, Toolkit, ToolkitE, ToolkitR, UniqueTools, appendToolResults, collectResults, compose, continueWithResults, describeFailures, descriptors, fromArray, make, makeNamespaced, namespace, run, wrap };
|
package/package.json
CHANGED
package/src/domain/Items.ts
CHANGED
|
@@ -111,6 +111,12 @@ export type Role = typeof Role.Type
|
|
|
111
111
|
// The framework never reads or interprets it; provider modules decode
|
|
112
112
|
// their own data via their own typed readers (see e.g.
|
|
113
113
|
// the `@effect-uai/responses` package).
|
|
114
|
+
//
|
|
115
|
+
// The slot is shared, so a provider keys its data under its own name
|
|
116
|
+
// (`{ responses: ... }`, `{ gemini: ... }`) and reads only that key. One item
|
|
117
|
+
// can pass through several providers - falling back from one to another
|
|
118
|
+
// leaves both keys on it - and a provider that finds no key of its own must
|
|
119
|
+
// encode the item normally rather than assume the slot is its own.
|
|
114
120
|
// ---------------------------------------------------------------------------
|
|
115
121
|
|
|
116
122
|
const ProviderData = Schema.optional(Schema.Unknown)
|
|
@@ -181,8 +187,20 @@ export const isReasoning = Schema.is(Reasoning)
|
|
|
181
187
|
// Usage and stop reason
|
|
182
188
|
// ---------------------------------------------------------------------------
|
|
183
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Prompt-cache token breakdown. `cached_tokens` is cache reads;
|
|
192
|
+
* `cache_write_tokens` is tokens written to the cache this turn (billed at a
|
|
193
|
+
* premium). Field names mirror the OpenAI Responses usage shape.
|
|
194
|
+
*
|
|
195
|
+
* Whether these are a subset of `input_tokens` or a separate bucket is
|
|
196
|
+
* provider-specific: OpenAI counts cache reads inside `input_tokens`, while
|
|
197
|
+
* Anthropic reports `input_tokens` as post-breakpoint tokens only, with reads
|
|
198
|
+
* and writes as separate additive buckets. Read a provider's usage in its own
|
|
199
|
+
* terms; do not assume one relationship holds everywhere.
|
|
200
|
+
*/
|
|
184
201
|
export const InputTokensDetails = Schema.Struct({
|
|
185
202
|
cached_tokens: Schema.optional(Schema.Number),
|
|
203
|
+
cache_write_tokens: Schema.optional(Schema.Number),
|
|
186
204
|
})
|
|
187
205
|
export type InputTokensDetails = typeof InputTokensDetails.Type
|
|
188
206
|
|
|
@@ -25,6 +25,9 @@ const turnComplete = (usage: Usage): TurnEvent =>
|
|
|
25
25
|
|
|
26
26
|
const text = (s: string): TurnEvent => TurnEvent.TextDelta({ text: s })
|
|
27
27
|
|
|
28
|
+
const toolArgs = (s: string): TurnEvent =>
|
|
29
|
+
TurnEvent.ToolCallArgsDelta({ call_id: "call_1", delta: s })
|
|
30
|
+
|
|
28
31
|
const samples = (events: ReadonlyArray<unknown>): ReadonlyArray<MetricEvent> =>
|
|
29
32
|
events.filter(isMetricEvent)
|
|
30
33
|
|
|
@@ -94,6 +97,30 @@ describe("tokenTotals", () => {
|
|
|
94
97
|
expect(m).toEqual({ name: "effect_uai_output_tokens", kind: "counter", value: 7 })
|
|
95
98
|
}),
|
|
96
99
|
)
|
|
100
|
+
|
|
101
|
+
it.effect("carries cache read and write tokens through usage and measurements", () =>
|
|
102
|
+
Effect.gen(function* () {
|
|
103
|
+
const stream = Stream.make(
|
|
104
|
+
turnComplete({
|
|
105
|
+
input_tokens: 94,
|
|
106
|
+
input_tokens_details: { cached_tokens: 31851, cache_write_tokens: 604 },
|
|
107
|
+
}),
|
|
108
|
+
turnComplete({ input_tokens: 6, input_tokens_details: { cached_tokens: 32000 } }),
|
|
109
|
+
)
|
|
110
|
+
const out = yield* Stream.runCollect(stream.pipe(tokenTotals))
|
|
111
|
+
const totals = tagged(out, "TokenTotals") as ReadonlyArray<TokenTotals>
|
|
112
|
+
expect(totals.map((t) => t.cumulative.input_tokens_details?.cache_write_tokens)).toEqual([
|
|
113
|
+
604, 604,
|
|
114
|
+
])
|
|
115
|
+
expect(totals.map((t) => t.cumulative.input_tokens_details?.cached_tokens)).toEqual([
|
|
116
|
+
31851, 63851,
|
|
117
|
+
])
|
|
118
|
+
const write = head(totals).measurements.find(
|
|
119
|
+
(m) => m.name === "effect_uai_cache_write_tokens",
|
|
120
|
+
)
|
|
121
|
+
expect(write).toEqual({ name: "effect_uai_cache_write_tokens", kind: "counter", value: 604 })
|
|
122
|
+
}),
|
|
123
|
+
)
|
|
97
124
|
})
|
|
98
125
|
|
|
99
126
|
describe("timeToFirstToken", () => {
|
|
@@ -238,6 +265,44 @@ describe("throughput (metronome)", () => {
|
|
|
238
265
|
expect(head(rates).ratePerSecond).toBeGreaterThan(0)
|
|
239
266
|
}),
|
|
240
267
|
)
|
|
268
|
+
|
|
269
|
+
it.effect("counts tool-call arguments: a turn with no prose still reports a rate", () =>
|
|
270
|
+
Effect.gen(function* () {
|
|
271
|
+
// The shape of an agent turn that only calls tools. Before output deltas
|
|
272
|
+
// were counted beyond TextDelta this measured 0 for the whole run.
|
|
273
|
+
const source = Stream.make(toolArgs('{"city":'), toolArgs('"Lisbon"}')).pipe(
|
|
274
|
+
Stream.concat(timed<TurnEvent>([["10 seconds", turnComplete({})]])),
|
|
275
|
+
)
|
|
276
|
+
const stream = source.pipe(
|
|
277
|
+
throughput({ every: "1 second", unit: "char" }),
|
|
278
|
+
metricEvents,
|
|
279
|
+
Stream.take(1),
|
|
280
|
+
)
|
|
281
|
+
const out = yield* runTimed(stream, ["1 second"])
|
|
282
|
+
const rates = out.filter((e): e is Throughput => e._tag === "Throughput")
|
|
283
|
+
expect(rates).toHaveLength(1)
|
|
284
|
+
expect(head(rates).ratePerSecond).toBeGreaterThan(0)
|
|
285
|
+
}),
|
|
286
|
+
)
|
|
287
|
+
|
|
288
|
+
// Runs on the real clock: this asserts an absence, so there is no sample to
|
|
289
|
+
// wait for and the stream has to end by the source closing. Under TestClock
|
|
290
|
+
// that path does not settle, because the metronome re-arms a sleep that no
|
|
291
|
+
// remaining `adjust` resolves.
|
|
292
|
+
it.live("ignores events that carry no generated output", () =>
|
|
293
|
+
Effect.gen(function* () {
|
|
294
|
+
// ToolCallStart and UsageUpdate are turn bookkeeping, not output.
|
|
295
|
+
const source = Stream.make(
|
|
296
|
+
TurnEvent.ToolCallStart({ call_id: "call_1", name: "get_weather" }),
|
|
297
|
+
TurnEvent.UsageUpdate({ usage: { output_tokens: 5 } }),
|
|
298
|
+
).pipe(Stream.concat(timed<TurnEvent>([["30 millis", turnComplete({})]])))
|
|
299
|
+
const stream = source.pipe(throughput({ every: "10 millis", unit: "char" }), metricEvents)
|
|
300
|
+
const out = yield* Stream.runCollect(stream)
|
|
301
|
+
// No output delta ever landed, so the accumulator never started and the
|
|
302
|
+
// metronome ticks produce nothing.
|
|
303
|
+
expect(out.filter((e): e is Throughput => e._tag === "Throughput")).toHaveLength(0)
|
|
304
|
+
}),
|
|
305
|
+
)
|
|
241
306
|
})
|
|
242
307
|
|
|
243
308
|
describe("custom events and helpers", () => {
|