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 / 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

Point Claude Code (or Cursor, …) at this page. The rewrites are mechanical enough that any tool-using LLM can apply them across your codebase.