Skip to content

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

BeforeAfterKind
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 encodingreshape
{ _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

BeforeAfter
{ 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

// Before
const ev: TurnEvent = { type: "text_delta", text: "hello" }
// After
import { 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

// Before
if (event.type === "turn_complete") use(event.turn)
Match.value(event).pipe(
Match.discriminators("type")({
text_delta: (e) => ...,
turn_complete: (e) => ...,
}),
Match.exhaustive,
)
// After
if (event._tag === "TurnComplete") use(event.turn)
// or
if (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.

ToolCallDecisionData.TaggedEnum

// Before
const d: ToolCallDecision = { _tag: "Approved", call }
// After
import { 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.

// Before
import { outputEvent, outputEvents } from "@effect-uai/core/Toolkit"
const ev = outputEvent(result)
const stream = outputEvents(results)
// After
import { 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: EncodingEmbedEncoding

Avoids the clash with Effect’s own Encoding module.

// Before
import type { Encoding } from "@effect-uai/core/EmbeddingModel"
// After
import 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 runtime
const { embedding } = yield * embed({ model, input, encoding: "float32" })
if (embedding._tag !== "float32") return // runtime guard
// After — narrowed at the type level
const { embedding } = yield * embed({ model, input, encoding: "float32" })
embedding.vector // Float32Array directly, no narrowing

Provider-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. loopFrom threads it to the next input; loopWithState writes it to the SubscriptionRef before ending. Plain loop treats it like stop.
  • Loop.loopFrom(input, initial, body) — input-driven sibling of loop. For each item pulled from input, runs an inner seed-driven loop with (s) => body(s, item). State threads across input items via next / stopWith. The natural shape for “stream of documents, multi-turn conversation per document.”
  • Loop.nextAfter / nextAfterFold / onTurnComplete dual — data-first and data-last forms now both work via Function.dual. Existing call sites keep compiling.
  • LanguageModel.turn(request) — drains streamTurn and returns the assembled Turn from the terminal TurnComplete event. Fails with IncompleteTurn if absent.
  • Retry.stream(schedule) / Retry.effect(schedule) — combinators that retry only the retryable subset of AiError (RateLimited, Unavailable, Timeout). Other failures propagate unchanged. Works across every model surface: Retry.stream for streamTurn / streamSynthesis / streamTranscriptionFrom, Retry.effect for turn / embed / synthesize / transcribe.
  • Turn.assistantText(turn) / Turn.assistantTexts(turn) — concatenated string / per-message array of output_text payloads.
  • 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) — yields Result<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.retryRetry.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:

// Before
const service: LanguageModelService = {
streamTurn: () => Stream.fromIterable([...]),
}
// After
import { 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.

  1. Find-and-replace the TurnEvent discriminator: event.typeevent._tag and each snake_case literal to its PascalCase counterpart.
  2. Swap construction sites to TurnEvent.TextDelta({...}) etc. (or leave object literals if your tests don’t construct them — only construction breaks).
  3. Fix Match.discriminators("type")({ text_delta: ... })Match.discriminators("_tag")({ TextDelta: ... }).
  4. Replace Toolkit.outputEvent / outputEvents with the ToolEvent.Output({...}) patterns above.
  5. Rename EncodingEmbedEncoding on imports from @effect-uai/core/EmbeddingModel.
  6. Run typecheck. The EmbedResponse<E> reshape is type-level only — if you weren’t narrowing at runtime, nothing changes.
  7. Run tests.

Using Claude to migrate

The effect-uai-migrate skill encodes these rules. From Claude Code:

/skill effect-uai-migrate

Or just point Claude at this page — the rewrites are mechanical enough that any tool-using LLM can apply them.