An Effect bridge for @coder/ai-sdk-provider (Coder
AI Gateway), @coder/ai-sdk-agent (Coder Agents), and @coder/ai-sdk-sandbox
(workspace sandboxes):
| Feature | API |
|---|---|
effect/ai LanguageModel over AI Gateway |
CoderLanguageModel.layer, CoderLanguageModel.fromModel |
effect/ai LanguageModel over Coder Agents |
CoderAgentModel.layer, CoderAgentModel.withAgentOptions |
Typed AiError failures |
classifyError, isTransient |
| Effect Schema → Vercel AI SDK schemas | toAiSdkSchema |
Scoped workspace Layers |
acquireWorkspace / layerWorkspace, acquireSession / layerSession |
pnpm add @coder/ai-sdk-effect effect@~4.0.0 ai@^7Requires Node ≥ 22, effect 4.0.x, and ai v7. The package depends on
@coder/ai-sdk-provider, @coder/ai-sdk-agent, and @coder/ai-sdk-sandbox,
so you do not install them separately.
Important
Stability. Effect 4 marks its AI modules (effect/ai/*) as unstable,
and unstable Effect APIs can break in a minor release. The effect peer
range is therefore patch-only (~4.0.0). Support for a new effect minor
comes in a new release of this package. This package is pre-1.0, and its own
API can also change between minor versions.
CoderLanguageModel.layer(modelId, source, generation?) implements Effect 4's
effect/ai LanguageModel service on top of the gateway.
source:CoderProviderSettings, or{ provider }for an existingCoderProvider. Both auth modes (centralized and BYOK) work unchanged.- Supported calls:
generateText,generateObject, andstreamText. Tool parameters and structured outputs derive their JSON schema from the Effect Schema you pass (see JSON Schema dialect). toolChoice: forwarded on every call, including"none"when there is no toolkit. AoneOfsubset sends only those tools with itsmode. A choice that requires a tool call fails withInvalidRequestErrorwhen no tool is left to call.generation(GenerationOptions):maxOutputTokens,temperature,topP,topK, penalties,stopSequences,seed,reasoning,providerOptions. Set at construction time and forwarded on every call.- Per-call overrides:
CoderLanguageModel.withGenerationOptions(overrides)provides theGenerationConfigservice to one effect. Its keys win over the construction options; nested overrides merge, innermost first.
import * as LanguageModel from "effect/ai/LanguageModel";
import * as Effect from "effect/Effect";
import { CoderLanguageModel } from "@coder/ai-sdk-effect";
const program = Effect.gen(function* () {
const response = yield* LanguageModel.generateText({
prompt: "In one sentence, what is Coder AI Gateway?",
});
return response.text;
});
program.pipe(
Effect.provide(
CoderLanguageModel.layer("gpt-5.1", {
baseURL: "https://coder.example.com",
apiKey: process.env.CODER_SESSION_TOKEN,
}),
),
Effect.runPromise,
);import * as LanguageModel from "effect/ai/LanguageModel";
import { CoderLanguageModel } from "@coder/ai-sdk-effect";
const deterministic = LanguageModel.generateText({ prompt: "Name a color." }).pipe(
CoderLanguageModel.withGenerationOptions({ temperature: 0, seed: 1 }),
);CoderLanguageModel.fromModel (the bridge core) adapts any AI SDK
LanguageModelV4; unit tests use it to run without HTTP.
The finish part's usage holds the normalized token counts. effect/ai's
Response.Usage has no field for the provider's raw usage
(LanguageModelV4Usage.raw), so the bridge puts it in the finish part's
metadata.coder.rawUsage as plain JSON. The key is present only when the
provider reports raw usage. For the Coder Agents model, it is chatd's wire
usage (snake_case fields such as input_tokens and context_limit). The
CoderFinishMetadata type describes metadata.coder, and the same rule
applies on the generate and stream paths.
import * as LanguageModel from "effect/ai/LanguageModel";
import * as Effect from "effect/Effect";
import type { CoderFinishMetadata } from "@coder/ai-sdk-effect";
const rawUsage = Effect.gen(function* () {
const response = yield* LanguageModel.generateText({ prompt: "hello" });
const finish = response.content.find((part) => part.type === "finish");
const coder = finish?.metadata.coder as CoderFinishMetadata | undefined;
return coder?.rawUsage;
});CoderAgentModel.layer(settings) implements LanguageModel on top of a Coder
Agents chat (chatd) through @coder/ai-sdk-agent's CoderLanguageModel. The
agent loop runs server-side; the layer adapts it to Effect.
settings: the agent'sCoderLanguageModelConfig(organizationId,model,reasoningEffort,workspaceId,chatId,requestTimeoutMs, ...) plusclient, orbaseUrl+token(default:CODER_URL+CODER_SESSION_TOKEN).- One chat per layer. chatd keeps the history. Each call sends only the prompt's newest user message, or the results of client tools. System messages apply when the chat is created. Calls are single-flight.
- Tools. Toolkit tools run client-side:
effect/airesolves the tool calls, and the next call sends the results back to the same chat. chatd's server-side tools are reported in the finish part'smetadata.coder.serverToolCalls. chatd registers client tools only when the chat is created, so the toolkit cannot change afterwards, and only theautotool choice is supported. Other cases fail withInvalidRequestError.toolChoicegoverns toolkit tools only; chatd's server-side tools come fromsettings(workspaceId,mcpServerIds). A chat resumed withsettings.chatIdkeeps the client tools it was created with, and the layer cannot verify them: pass the same toolkit, and setrequestTimeoutMs. - Structured output.
generateObjectfails withInvalidRequestError: chatd enforces no schema. Use the AI Gateway model for structured output. - Interruption. Interrupting the fiber aborts the call. The agent then interrupts the chat's run server-side, exactly once.
- Scope. Closing the layer's scope disposes the model and closes its
event stream. The chat is not archived. To archive chats, collect their ids
from
segment:*events inonTransportEvent. - Per-call options.
CoderAgentModel.withAgentOptions({ model, reasoningEffort })changes these for one call on the same chat. A change is refused while client tool results are submitted, because that continues a turn in progress. - Generation options. Sampling controls (
temperature,maxOutputTokens, ...) andproviderOptionsother thancoderfail withInvalidRequestError, because chatd chooses them.
import * as LanguageModel from "effect/ai/LanguageModel";
import * as Effect from "effect/Effect";
import { CoderAgentModel } from "@coder/ai-sdk-effect";
const program = Effect.gen(function* () {
const first = yield* LanguageModel.generateText({ prompt: "Summarize the repo README." });
const second = yield* LanguageModel.generateText({ prompt: "Now in one line." }).pipe(
CoderAgentModel.withAgentOptions({ reasoningEffort: "high" }),
);
return [first.text, second.text];
});
program.pipe(
Effect.provide(
CoderAgentModel.layer({ organizationId: "<org-uuid>", model: "claude-sonnet-4-5" }),
),
Effect.runPromise,
);classifyError maps an AiError, a raw AI SDK error, or a raw
@coder/ai-sdk-agent error to one of: auth, rate-limit, quota,
provider-unavailable, malformed-response, transport, timeout,
unknown. isTransient composes with Effect.retry:
import * as LanguageModel from "effect/ai/LanguageModel";
import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";
import { isTransient } from "@coder/ai-sdk-effect";
const resilient = LanguageModel.generateText({ prompt: "hello" }).pipe(
Effect.retry({
while: (error) => isTransient(error),
schedule: Schedule.exponential("250 millis"),
times: 3,
}),
);effect/ai fails with one AiError class whose reason is one of a closed
set of tagged classes, so the bridge maps every failure onto those reasons
instead of adding its own error classes:
- HTTP failures keep the request method and URL, the response status and
headers, and the body in the reason's
httpfield. - A
RateLimitErrorfrom an AI Gateway response setsretryAfterfrom a validRetry-Afterheader (delay-seconds, or an HTTP-date). Read it aserror.retryAfter, for example to pick a retry delay. - HTTP 402 means the account's quota or billing limit is exhausted. It maps
to
QuotaExhaustedErrorand thequotaclassification, which is not transient: a retry cannot succeed until someone raises the limit. HTTP 429 stays a transientrate-limit. - The reason's
metadata.coderrecords the classification and, when the source error has them, the HTTPstatus, the Coder Agentsretryableverdict, the chat error'skindandprovider, and the stream'schatId. - An explicit Coder Agents verdict decides
isTransient. The agent setsCoderStreamError.isRetryabletofalsewhen replaying the prompt would repeat server-side effects. - A
falseverdict turns a reason that Effect calls retryable into anUnknownError, so the reason's ownisRetryablenever invites that retry. - Fiber interruption stays Effect interruption. It never becomes an
AiError.
How source errors map to AiError reasons
| Source error | AiError reason |
classifyError |
isTransient |
Reason isRetryable |
|---|---|---|---|---|
APICallError or CoderApiError, status 401 / 403 |
AuthenticationError (InvalidKey / InsufficientPermissions) |
auth |
false |
false |
| status 402 | QuotaExhaustedError |
quota |
false |
false |
| status 429 | RateLimitError (retryAfter from Retry-After) |
rate-limit |
true |
true |
| status 5xx | InternalProviderError |
provider-unavailable |
true |
true |
| status 400 | InvalidRequestError |
unknown |
false |
false |
| any other status | UnknownError |
unknown |
false |
false |
APICallError without a status (network failure) |
NetworkError (TransportError) |
transport |
true |
true |
CoderStreamError |
UnknownError |
transport |
isRetryable |
false |
CoderChatError with kind: "timeout" |
UnknownError |
timeout |
retryable |
false |
CoderChatError with kind: "stream_closed" |
UnknownError |
transport |
retryable |
false |
any other CoderChatError |
from statusCode as above, else UnknownError |
from statusCode, else unknown |
retryable |
false if retryable is false |
JSONParseError, TypeValidationError, EmptyResponseBodyError |
InvalidOutputError |
malformed-response |
false |
true (Effect's view) |
NoSuchModelError, InvalidArgumentError |
InvalidRequestError |
unknown |
false |
false |
| anything else | UnknownError |
unknown |
false |
false |
classifyError reads metadata.coder.classification. For an AiError that
effect/ai raised itself (no metadata.coder), it classifies by the reason
tag, and isTransient follows the classification.
toAiSdkSchema makes an Effect Schema usable in AI SDK APIs such as tool(),
with inputs fully decoded and typed:
import * as Schema from "effect/Schema";
import { tool } from "ai";
import { toAiSdkSchema } from "@coder/ai-sdk-effect";
const getWeather = tool({
description: "Look up the current weather",
inputSchema: toAiSdkSchema(Schema.Struct({ city: Schema.String })),
execute: async ({ city }) => ({ city, temperature: 21 }),
});Why a bare Effect standard schema fails
AI SDK v7 accepts Standard Schema V1 values, but deriving the wire JSON schema
requires the optional ~standard.jsonSchema converter. Schema.toStandardSchemaV1
does not emit it, so passing a bare Effect standard schema throws
Standard schema vendor 'effect' does not support JSON Schema conversion.
toAiSdkSchema derives the JSON schema from the Effect Schema (the same
derivation effect/ai uses), and validation decodes through it.
Effect 4 derives JSON Schema draft 2020-12. The AI SDK's JSONSchema7 type is
draft-07, so the bridge converts every schema it sends (tool parameters,
structured outputs, toJsonSchema, toAiSdkSchema) with Effect's
JsonSchema.toDocumentDraft07. The conversion moves $defs to definitions
and rewrites tuples to array items. It throws on keywords draft-07 cannot
express.
Two effects of Effect 4's derivation show on the wire:
- Objects set
additionalProperties: true, which matches the decoder's default of ignoring extra keys. Schema.Numberalso accepts the strings"Infinity","-Infinity", and"NaN". UseSchema.Finite(orSchema.Int) for plain JSON numbers.
acquireWorkspace / layerWorkspace wrap ensureCoderWorkspace in
Effect.acquireRelease:
- Scope opens: the workspace is provisioned (get-or-create, start, agent-readiness wait).
- Scope closes: it is torn down, including when the fiber is interrupted after acquisition.
- Teardown policy:
delete-if-createdby default. It never touches a workspace the acquisition merely attached to.
acquireSession / layerSession do the same for
createCoderWorkspace(...).createSession().
import * as Effect from "effect/Effect";
import { CoderWorkspace, layerWorkspace } from "@coder/ai-sdk-effect";
const program = Effect.gen(function* () {
const workspace = yield* CoderWorkspace;
yield* Effect.log(`workspace ${workspace.name} ready`);
});
program.pipe(
Effect.provide(
layerWorkspace({
workspace: "agent-sandbox",
create: { template: "docker" },
}),
),
Effect.runPromise,
);Unsupported inputs fail loudly with InvalidRequestError; response parts with
no effect/ai equivalent are dropped.
| Limitation | Behavior |
|---|---|
Provider-defined tools (not expressible in LanguageModelV4 call options) |
InvalidRequestError |
Response parts of type custom, reasoning-file, tool-approval-request; file payloads that are not raw data (URL / provider-reference / inline-text) |
Dropped |
Provider-executed tool calls and results for tools outside the call's toolkit (effect/ai types tool parts by toolkit) |
Moved to the finish part's metadata.coder.serverToolCalls |
Provider raw usage (effect/ai's Response.Usage has no field for it) |
Moved to the finish part's metadata.coder.rawUsage |
Tool approval parts in the prompt (effect/ai resolves and strips them before the call) |
Not forwarded |
Prompt provider options (per-part metadata) |
Not forwarded |
ProviderOptions.span (telemetry) |
Not wired into request headers |
Workspace acquisition is uninterruptible (standard acquireRelease
semantics): a slow ensureCoderWorkspace cannot be cancelled mid-flight. If an
acquisition creates a workspace and then fails (e.g. readiness timeout), the
workspace is rolled back best-effort per the teardown policy.
Version ranges and the API surface the bridge codes against
| Dependency | Range | Surface used |
|---|---|---|
effect (peer) |
~4.0.0 |
Effect, Layer, Stream, Schema, JsonSchema, Result, Context, Data, Option, Function.dual |
effect (effect/ai/*) |
~4.0.0 |
LanguageModel.make (ProviderOptions → encoded response parts), AiError, Prompt, Response, Tool |
ai (peer) |
^7.0.0 |
jsonSchema (for toAiSdkSchema) |
@ai-sdk/provider |
exact (same pin as @coder/ai-sdk-provider) |
LanguageModelV4 spec types |
@coder/ai-sdk-provider |
^ its current release |
createCoder, CoderProviderSettings |
@coder/ai-sdk-sandbox |
^ its current release |
ensureCoderWorkspace, createCoderWorkspace, CoderTransport |
@coder/ai-sdk-agent |
^ its current release |
CoderLanguageModel (+ chatId, lastSeenMessageId, dispose), CoderChatClient, classifyTurnAction, error classes |
In this repository the @coder/ai-sdk-* dependencies are workspace:^. pnpm
replaces each one with ^<version> when it publishes, where <version> is
the sibling package's version in the released commit.
Runnable against a real deployment (see each file's header):
examples/01-generate.ts: text generation.examples/02-structured.ts: structured output via Effect Schema.examples/03-sandbox.ts: scoped workspace acquisition.
Apache-2.0