Migrating to 0.5
0.5 has two breaking-but-mechanical sweeps (TurnEvent migrated to a
Data.TaggedEnum, Encoding renamed to EmbedEncoding), one small
removal (Toolkit.outputEvent / outputEvents), one type-level reshape
on embeddings (precise return types via EmbeddingFor<E>), and a pile
of additive helpers. This page is the consolidated “old → new” view;
the changelog
covers the why.
At a glance
| Before | After | Kind |
|---|---|---|
event.type === "text_delta" | event._tag === "TextDelta" | reshape |
{ type: "turn_complete", turn } | TurnEvent.TurnComplete({ turn }) | reshape |
Match.discriminators("type")({ text_delta: ... }) | Match.discriminators("_tag")({ TextDelta: ... }) | reshape |
Toolkit.outputEvent(result) | ToolEvent.Output({ result }) | removed |
Toolkit.outputEvents(results) | Stream.fromIterable(results.map((r) => ToolEvent.Output({ result: r }))) | removed |
import { Encoding } from "@effect-uai/core/EmbeddingModel" | import { EmbedEncoding } from "@effect-uai/core/EmbeddingModel" | rename |
EmbedResponse (always Embedding) | EmbedResponse<"float32"> (Float32Embedding), narrows by encoding | reshape |
{ _tag: "Approved", call } | ToolCallDecision.Approved({ call }) (or keep using Resolvers.approve) | reshape |
TurnEvent → Data.TaggedEnum
The biggest sweep. The wire shape changes from type to _tag, and
every variant name moves from snake_case to PascalCase.
Variant name table
| Before | After |
|---|---|
{ type: "text_delta", text } | TurnEvent.TextDelta({ text }) |
{ type: "reasoning_delta", text, kind } | TurnEvent.ReasoningDelta({ text, kind }) |
{ type: "refusal_delta", text } | TurnEvent.RefusalDelta({ text }) |
{ type: "tool_call_start", call_id, name } | TurnEvent.ToolCallStart({ call_id, name }) |
{ type: "tool_call_args_delta", call_id, delta } | TurnEvent.ToolCallArgsDelta({ call_id, delta }) |
{ type: "usage_update", usage } | TurnEvent.UsageUpdate({ usage }) |
{ type: "turn_complete", turn } | TurnEvent.TurnComplete({ turn }) |
Construction
// Beforeconst ev: TurnEvent = { type: "text_delta", text: "hello" }
// Afterimport { TurnEvent } from "@effect-uai/core/Turn"const ev: TurnEvent = TurnEvent.TextDelta({ text: "hello" })Note the import switch: TurnEvent is now a value-and-type, so
import type { TurnEvent } will break at construction sites. Drop the
type keyword.
Predicates and pattern-matching
// Beforeif (event.type === "turn_complete") use(event.turn)
Match.value(event).pipe( Match.discriminators("type")({ text_delta: (e) => ..., turn_complete: (e) => ..., }), Match.exhaustive,)
// Afterif (event._tag === "TurnComplete") use(event.turn)// orif (Turn.isTurnComplete(event)) use(event.turn) // unchanged helper
Match.value(event).pipe( Match.discriminators("_tag")({ TextDelta: (e) => ..., TurnComplete: (e) => ..., }), Match.exhaustive,)Turn.isTurnComplete and Turn.textDeltas keep working unchanged —
they were updated internally.
Anywhere you currently use "_tag" in event to distinguish
TurnEvent from ToolEvent
Both unions now use _tag, so this discrimination no longer works.
Switch to the $is predicates on whichever union you care about
(ToolEvent.isOutput, ToolEvent.isIntermediate,
Turn.isTurnComplete, etc.) or split your stream upstream.
ToolCallDecision → Data.TaggedEnum
// Beforeconst d: ToolCallDecision = { _tag: "Approved", call }
// Afterimport { ToolCallDecision } from "@effect-uai/core/Resolvers"const d: ToolCallDecision = ToolCallDecision.Approved({ call })The Resolvers.approve(call) / Resolvers.reject(result) helpers are
unchanged — most call sites won’t need to touch anything.
Removed: Toolkit.outputEvent / Toolkit.outputEvents
Redundant since ToolEvent got constructors via Data.TaggedEnum.
// Beforeimport { outputEvent, outputEvents } from "@effect-uai/core/Toolkit"const ev = outputEvent(result)const stream = outputEvents(results)
// Afterimport { ToolEvent } from "@effect-uai/core/ToolEvent"import { Stream } from "effect"const ev = ToolEvent.Output({ result })const stream = Stream.fromIterable(results.map((result) => ToolEvent.Output({ result })))Renamed: Encoding → EmbedEncoding
Avoids the clash with Effect’s own Encoding module.
// Beforeimport type { Encoding } from "@effect-uai/core/EmbeddingModel"
// Afterimport type { EmbedEncoding } from "@effect-uai/core/EmbeddingModel"Provider-specific encoding types (JinaEncoding, etc.) are unchanged.
Reshape: EmbedResponse<E> is now generic
embed / embedMany infer the embedding variant from the request’s
encoding field. The bare EmbedResponse name still works (defaults
to Float32Embedding).
// Before — always typed as `Embedding`, callers narrow at runtimeconst { embedding } = yield * embed({ model, input, encoding: "float32" })if (embedding._tag !== "float32") return // runtime guard
// After — narrowed at the type levelconst { embedding } = yield * embed({ model, input, encoding: "float32" })embedding.vector // Float32Array directly, no narrowingProvider-specific layers (OpenAIEmbedding, GeminiEmbedding,
JinaEmbedding) all return EmbeddingFor<E> now. OpenAI and Gemini
only emit float32 at runtime; on the generic EmbeddingModel tag a
caller asking for a non-float32 encoding gets the type they requested
but a float32 value (the provider ignores the field).
Behavior changes
Provider emitter rewrite
All three language providers (@effect-uai/anthropic,
@effect-uai/responses, @effect-uai/google) now use
TurnEvent.TextDelta({...}) etc. constructors internally. No
wire-shape change for consumers — but if you were intercepting
provider event objects directly (rare), the _tag field is now
present.
Gemini tool calling
@effect-uai/google now translates function_call /
function_call_output items to Gemini’s functionDeclarations +
functionCall / functionResponse parts. If you previously had a
Gemini-only path that skipped tool calls, it now works. Tool param
schemas are sanitized to the OpenAPI 3.0 subset Gemini accepts
($schema, $ref, $defs, additionalProperties, oneOf are
stripped) — most schemas pass through unchanged.
New capabilities (additive — no migration needed)
Loop.stopWith(state)/Loop.stopWithAfter(stream, state)— terminal event that ends the loop AND carries final state.loopFromthreads it to the next input;loopWithStatewrites it to theSubscriptionRefbefore ending. Plainlooptreats it likestop.Loop.loopFrom(input, initial, body)— input-driven sibling ofloop. For each item pulled frominput, runs an inner seed-drivenloopwith(s) => body(s, item). State threads across input items vianext/stopWith. The natural shape for “stream of documents, multi-turn conversation per document.”Loop.nextAfter/nextAfterFold/onTurnCompletedual — data-first and data-last forms now both work viaFunction.dual. Existing call sites keep compiling.LanguageModel.turn(request)— drainsstreamTurnand returns the assembledTurnfrom the terminalTurnCompleteevent. Fails withIncompleteTurnif absent.Retry.stream(schedule)/Retry.effect(schedule)— combinators that retry only the retryable subset ofAiError(RateLimited,Unavailable,Timeout). Other failures propagate unchanged. Works across every model surface:Retry.streamforstreamTurn/streamSynthesis/streamTranscriptionFrom,Retry.effectforturn/embed/synthesize/transcribe.Turn.assistantText(turn)/Turn.assistantTexts(turn)— concatenated string / per-message array ofoutput_textpayloads.Tool.fromStandardSchema(schema)— adapt any schema library that implements both Standard Schema and Standard JSON Schema (Zod 4.2+, Valibot 1.2+, ArkType 2.1.28+) as a tool input schema.StructuredFormat.decodeJsonLinesRecoverable(format)— yieldsResult<A, JsonParseError | StructuredDecodeError>per line instead of failing the stream on the first bad frame.
Updating within 0.5.x (0.5.0 / 0.5.1 → 0.5.2)
Two small breaking renames landed in 0.5.2. Both have a mechanical one-line fix.
LanguageModel.retry → Retry.stream
The retry helper was never LanguageModel-specific. It moved into its
own @effect-uai/core/Retry module, with a sibling Retry.effect for
the non-streaming surfaces (turn, embed, synthesize,
transcribe).
// Before (0.5.0 / 0.5.1)import { retry, Retryable } from "@effect-uai/core/LanguageModel"streamTurn(req).pipe(retry(schedule))
// After (0.5.2)import * as Retry from "@effect-uai/core/Retry"streamTurn(req).pipe(Retry.stream(schedule))embed(req).pipe(Retry.effect(schedule))Retryable and isRetryable move to the same module
(Retry.Retryable, Retry.isRetryable).
Hand-rolled LanguageModelService values need a turn field
turn is now a method on LanguageModelService (so providers with a
native non-streaming endpoint can override it). Provider layers and
MockProvider are unaffected. Custom services — most commonly used
in tests — now need both fields:
// Beforeconst service: LanguageModelService = { streamTurn: () => Stream.fromIterable([...]),}
// Afterimport { turnFromStream } from "@effect-uai/core/LanguageModel"const streamTurn: LanguageModelService["streamTurn"] = () => Stream.fromIterable([...])const service: LanguageModelService = { streamTurn, turn: turnFromStream(streamTurn) }The top-level LanguageModel.turn(request) helper is unchanged at
call sites.
Recommended order
- Find-and-replace the
TurnEventdiscriminator:event.type→event._tagand each snake_case literal to its PascalCase counterpart. - Swap construction sites to
TurnEvent.TextDelta({...})etc. (or leave object literals if your tests don’t construct them — only construction breaks). - Fix
Match.discriminators("type")({ text_delta: ... })→Match.discriminators("_tag")({ TextDelta: ... }). - Replace
Toolkit.outputEvent/outputEventswith theToolEvent.Output({...})patterns above. - Rename
Encoding→EmbedEncodingon imports from@effect-uai/core/EmbeddingModel. - Run typecheck. The
EmbedResponse<E>reshape is type-level only — if you weren’t narrowing at runtime, nothing changes. - Run tests.
Using Claude to migrate
The effect-uai-migrate skill encodes these rules. From Claude Code:
/skill effect-uai-migrateOr just point Claude at this page — the rewrites are mechanical enough that any tool-using LLM can apply them.