New:Socket for Asana Is Now Available.Learn more
Get Started

@opencode-ai/ai

Package Overview
Dependencies
Maintainers
2
Versions
1089
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@opencode-ai/ai - npm Package Compare versions

Comparing version
0.0.0-beta-18414
to
0.0.0-beta-18593
+2
-0
dist/llm.d.ts

@@ -48,2 +48,3 @@ import { Effect, JsonSchema, Schema } from "effect";

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -74,2 +75,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -76,0 +78,0 @@ readonly [x: string]: {

@@ -132,2 +132,3 @@ import { Schema } from "effect";

readonly tools: ToolStream.State<number>;
readonly finishedTools: ReadonlySet<number>;
readonly pendingFinish: {

@@ -134,0 +135,0 @@ readonly reason: FinishReasonDetails;

@@ -288,2 +288,9 @@ import { Effect, Schema } from "effect";

}
if (signature === undefined || signature.trim().length === 0) {
// Interrupted streams and model switches can leave unsigned reasoning.
// Preserve readable history as text rather than replay invalid reasoningContent.
if (part.text.trim().length > 0)
content.push(...textWithCache(breakpoints, part.text, part.cache));
continue;
}
content.push({ reasoningContent: { reasoningText: { text: part.text, signature } } });

@@ -297,3 +304,4 @@ continue;

}
messages.push({ role: "assistant", content });
if (content.length > 0)
messages.push({ role: "assistant", content });
continue;

@@ -452,2 +460,4 @@ }

const index = event.contentBlockDelta.contentBlockIndex;
if (state.finishedTools.has(index))
return [state, []];
const result = ToolStream.appendExisting(ADAPTER, state.tools, index, event.contentBlockDelta.delta.toolUse.input, "Bedrock Converse tool delta is missing its tool call");

@@ -479,2 +489,3 @@ if (ToolStream.isError(result))

tools: result.tools,
finishedTools: resultEvents.length > 0 ? new Set([...state.finishedTools, index]) : state.finishedTools,
reasoningSignatures: Object.fromEntries(Object.entries(state.reasoningSignatures).filter(([key]) => key !== String(index))),

@@ -559,2 +570,3 @@ },

tools: ToolStream.empty(),
finishedTools: new Set(),
pendingFinish: undefined,

@@ -561,0 +573,0 @@ hasToolCalls: false,

+8
-2

@@ -568,7 +568,10 @@ import { Effect, Schema } from "effect";

readonly tools: ToolStream.State<string>;
readonly completedTools: ReadonlySet<string>;
readonly hasFunctionCall: boolean;
readonly lifecycle: Lifecycle.State;
readonly outputItems: Readonly<Record<number, string>>;
readonly messageItems: ReadonlySet<string>;
readonly messagePhases: Readonly<Record<string, MessagePhase | null>>;
readonly message: {
readonly id: string;
readonly phase: MessagePhase | null | undefined;
} | undefined;
readonly reasoningItems: Readonly<Record<string, ReasoningStreamItem>>;

@@ -578,2 +581,3 @@ }

interface ReasoningStreamItem {
readonly open: boolean;
readonly encryptedContent: string | null | undefined;

@@ -833,2 +837,3 @@ readonly summaryParts: Readonly<Record<number, ReasoningSummaryStatus>>;

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -859,2 +864,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -861,0 +867,0 @@ readonly [x: string]: {

@@ -610,6 +610,6 @@ import { Effect, Schema } from "effect";

const onOutputTextDelta = (state, event, id) => {
if (!event.delta || !state.messageItems.has(id))
if (!event.delta || state.message?.id !== id)
return [state, NO_EVENTS];
const events = [];
const phase = state.messagePhases[id];
const phase = state.message.phase;
const metadata = providerMetadata(state, { itemId: id, ...(phase === undefined ? {} : { phase }) });

@@ -620,3 +620,3 @@ const lifecycle = Lifecycle.textStart(state.lifecycle, events, id, metadata);

const onOutputTextDone = (state, event, id) => {
if (state.messageItems.has(id)) {
if (state.message?.id === id) {
if (state.lifecycle.text.has(id) || event.text === undefined)

@@ -630,15 +630,23 @@ return [state, NO_EVENTS];

export const outputItemID = (state, event) => event.output_index === undefined ? event.item_id : (state.outputItems[event.output_index] ?? event.item_id);
export const onReasoningDelta = (state, event, itemID) => {
const startReasoningSummaryPart = (state, itemID, index) => {
const item = state.reasoningItems[itemID];
if (!event.delta || !item)
if (!item?.open || index === 0 || item.summaryParts[index] !== undefined)
return [state, NO_EVENTS];
const index = event.summary_index ?? 0;
const events = [];
const lifecycle = Object.entries(item.summaryParts)
.filter((entry) => entry[1] !== "concluded")
.reduce((lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${itemID}:${entry[0]}`, providerMetadata(state, { itemId: itemID })), state.lifecycle);
return [
{
...state,
lifecycle: Lifecycle.reasoningDelta(state.lifecycle, events, `${itemID}:${index}`, event.delta),
lifecycle: Lifecycle.reasoningStart(lifecycle, events, `${itemID}:${index}`, providerMetadata(state, { itemId: itemID, reasoningEncryptedContent: item.encryptedContent ?? null })),
reasoningItems: {
...state.reasoningItems,
[itemID]: { ...item, deltaIndexes: new Set([...item.deltaIndexes, index]) },
[itemID]: {
...item,
summaryParts: {
...Object.fromEntries(Object.entries(item.summaryParts).map((entry) => entry[1] === "concluded" ? entry : [entry[0], "concluded"])),
[index]: "active",
},
},
},

@@ -649,2 +657,26 @@ },

};
export const onReasoningDelta = (state, event, itemID) => {
const item = state.reasoningItems[itemID];
if (!event.delta || !item?.open)
return [state, NO_EVENTS];
const index = event.summary_index ?? 0;
if (item.summaryParts[index] === "concluded")
return [state, NO_EVENTS];
const [started, emitted] = startReasoningSummaryPart(state, itemID, index);
const current = started.reasoningItems[itemID];
if (!current)
return [started, emitted];
const events = [...emitted];
return [
{
...started,
lifecycle: Lifecycle.reasoningDelta(started.lifecycle, events, `${itemID}:${index}`, event.delta),
reasoningItems: {
...started.reasoningItems,
[itemID]: { ...current, deltaIndexes: new Set([...current.deltaIndexes, index]) },
},
},
events,
];
};
// Some compatible gateways emit a reasoning final without streaming any

@@ -655,3 +687,3 @@ // deltas, mirroring `response.output_text.done`. Reconcile the complete text

const item = state.reasoningItems[itemID];
if (!item || typeof event.text !== "string")
if (!item?.open || typeof event.text !== "string")
return [state, NO_EVENTS];

@@ -664,3 +696,3 @@ const index = event.summary_index ?? 0;

const reasoningMetadata = (state, item) => providerMetadata(state, { itemId: item.id, reasoningEncryptedContent: item.encrypted_content ?? null });
// Responses APIs stream reasoning items in a stable order:
// Responses APIs normally stream reasoning items in this order:
// `output_item.added` (reasoning) →

@@ -672,21 +704,33 @@ // `reasoning_summary_part.added` (index=0) →

// `output_item.done` (reasoning).
// The handlers below rely on this ordering: `onOutputItemAdded` seeds the
// per-item entry, `onReasoningSummaryPartAdded` for `summary_index === 0`
// short-circuits when the entry already exists, and higher-index handlers
// fold against the same entry. Behaviour for out-of-order events is
// best-effort, not guaranteed.
// `onOutputItemAdded` seeds the per-item entry, while each later part start is
// also an implicit boundary for the previous part. This keeps the common event
// lifecycle ordered when a compatible provider omits or delays a part-done event.
const onOutputItemAdded = (state, event) => {
const item = event.item;
if (item?.type === "message" && item.id !== undefined) {
const itemID = item.id;
const phase = messagePhase(item.phase);
// A new message closes earlier messages, including ones that never streamed.
const events = [];
const lifecycle = [...state.lifecycle.text]
.filter((id) => id !== itemID)
.reduce((lifecycle, id) => {
const openPhase = state.message?.id === id ? state.message.phase : undefined;
return Lifecycle.textEnd(lifecycle, events, id, providerMetadata(state, { itemId: id, ...(openPhase === undefined ? {} : { phase: openPhase }) }));
}, state.lifecycle);
return [
{
...state,
messageItems: new Set([...state.messageItems, item.id]),
messagePhases: phase === undefined ? state.messagePhases : { ...state.messagePhases, [item.id]: phase },
lifecycle,
message: {
id: itemID,
phase: phase === undefined && state.message?.id === itemID ? state.message.phase : phase,
},
},
NO_EVENTS,
events,
];
}
if (item && isReasoningItem(item)) {
if (state.reasoningItems[item.id] !== undefined)
return [state, NO_EVENTS];
const events = [];

@@ -700,2 +744,3 @@ return [

[item.id]: {
open: true,
encryptedContent: item.encrypted_content,

@@ -713,2 +758,4 @@ summaryParts: { 0: "active" },

const id = item.id ?? item.call_id;
if (Object.values(state.tools).some((tool) => tool?.id === item.call_id) || state.completedTools.has(item.call_id))
return [state, NO_EVENTS];
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined;

@@ -734,28 +781,3 @@ const events = [];

return [state, NO_EVENTS];
const item = state.reasoningItems[event.item_id];
if (!item)
return [state, NO_EVENTS];
if (event.summary_index === 0)
return [state, NO_EVENTS];
const events = [];
const closed = Object.entries(item.summaryParts)
.filter((entry) => entry[1] === "can-conclude")
.reduce((lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${event.item_id}:${entry[0]}`, providerMetadata(state, { itemId: event.item_id })), state.lifecycle);
return [
{
...state,
lifecycle: Lifecycle.reasoningStart(closed, events, `${event.item_id}:${event.summary_index}`, providerMetadata(state, { itemId: event.item_id, reasoningEncryptedContent: item.encryptedContent ?? null })),
reasoningItems: {
...state.reasoningItems,
[event.item_id]: {
...item,
summaryParts: {
...Object.fromEntries(Object.entries(item.summaryParts).map((entry) => entry[1] === "can-conclude" ? [entry[0], "concluded"] : entry)),
[event.summary_index]: "active",
},
},
},
},
events,
];
return startReasoningSummaryPart(state, event.item_id, event.summary_index);
};

@@ -766,4 +788,6 @@ const onReasoningSummaryPartDone = (state, event) => {

const item = state.reasoningItems[event.item_id];
if (!item)
if (!item?.open)
return [state, NO_EVENTS];
if (item.summaryParts[event.summary_index] !== "active")
return [state, NO_EVENTS];
return [

@@ -817,7 +841,4 @@ {

const itemPhase = messagePhase(item.phase);
const phase = itemPhase === undefined ? state.messagePhases[item.id] : itemPhase;
const phase = itemPhase === undefined && state.message?.id === item.id ? state.message.phase : itemPhase;
const events = [];
const messageItems = new Set(state.messageItems);
messageItems.delete(item.id);
const { [item.id]: _phase, ...messagePhases } = state.messagePhases;
return [

@@ -827,4 +848,3 @@ {

lifecycle: Lifecycle.textEnd(state.lifecycle, events, item.id, providerMetadata(state, { itemId: item.id, ...(phase === undefined ? {} : { phase }) })),
messageItems,
messagePhases,
message: state.message?.id === item.id ? undefined : state.message,
},

@@ -837,9 +857,19 @@ events,

return [state, NO_EVENTS];
const id = item.id ?? item.call_id;
const tools = state.tools[id]
const callID = item.call_id;
if (state.completedTools.has(callID))
return [state, NO_EVENTS];
const metadata = item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined;
const fallback = item.id ?? callID;
// Match the pending tool by call id so item events that disagree on
// whether `item.id` is present still resolve the same call.
const registered = state.tools[fallback] !== undefined
? fallback
: Object.keys(state.tools).find((key) => state.tools[key]?.id === callID);
const id = registered ?? fallback;
const tools = registered !== undefined
? state.tools
: ToolStream.start(state.tools, id, {
id: item.call_id,
id: callID,
name: item.name,
providerMetadata: item.id !== undefined ? providerMetadata(state, { itemId: item.id }) : undefined,
providerMetadata: metadata,
});

@@ -850,3 +880,7 @@ const result = item.arguments === undefined

const events = [];
const resultEvents = result.events ?? [];
const finished = result.events ?? [];
// A done-only call never streamed a start event, so open its lifecycle here.
const resultEvents = registered !== undefined || finished.length === 0
? finished
: [LLMEvent.toolInputStart({ id: callID, name: item.name, providerMetadata: metadata }), ...finished];
const lifecycle = resultEvents.length ? Lifecycle.stepStart(state.lifecycle, events) : state.lifecycle;

@@ -861,2 +895,3 @@ events.push(...resultEvents);

tools: result.tools,
completedTools: new Set([...state.completedTools, callID]),
},

@@ -871,7 +906,22 @@ events,

if (reasoningItem) {
if (!reasoningItem.open)
return [state, NO_EVENTS];
const lifecycle = Object.entries(reasoningItem.summaryParts)
.filter((entry) => entry[1] === "active" || entry[1] === "can-conclude")
.reduce((lifecycle, entry) => Lifecycle.reasoningEnd(lifecycle, events, `${item.id}:${entry[0]}`, metadata), state.lifecycle);
const { [item.id]: _removed, ...reasoningItems } = state.reasoningItems;
return [{ ...state, lifecycle, reasoningItems }, events];
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
...reasoningItem,
open: false,
encryptedContent: item.encrypted_content ?? reasoningItem.encryptedContent,
},
},
},
events,
];
}

@@ -882,3 +932,18 @@ if (!state.lifecycle.reasoning.has(item.id)) {

events.push(LLMEvent.reasoningEnd({ id: item.id, providerMetadata: metadata }));
return [{ ...state, lifecycle }, events];
return [
{
...state,
lifecycle,
reasoningItems: {
...state.reasoningItems,
[item.id]: {
open: false,
encryptedContent: item.encrypted_content,
summaryParts: { 0: "concluded" },
deltaIndexes: new Set(),
},
},
},
events,
];
}

@@ -898,3 +963,3 @@ return [

((item.type !== "function_call" || !current.tools[id]) &&
(item.type !== "reasoning" || !current.reasoningItems[id])))
(item.type !== "reasoning" || !current.reasoningItems[id]?.open)))
return Effect.succeed([current, events]);

@@ -1001,2 +1066,7 @@ return onOutputItemDone(current, { type: "response.output_item.done", item }).pipe(Effect.map(([next, emitted]) => [next, [...events, ...emitted]]));

return ProviderShared.eventError(state.id, `${event.type} message is missing id`);
if (event.item &&
isReasoningItem(event.item) &&
state.reasoningItems[event.item.id] === undefined &&
state.lifecycle.reasoning.size > 0)
return ProviderShared.eventError(state.id, `${event.type} started reasoning before the previous item ended`);
const id = event.item?.id ?? (event.item?.type === "function_call" ? event.item.call_id : undefined);

@@ -1037,6 +1107,6 @@ return Effect.succeed(onOutputItemAdded(event.output_index !== undefined && id !== undefined

tools: ToolStream.empty(),
completedTools: new Set(),
lifecycle: Lifecycle.initial(),
outputItems: {},
messageItems: new Set(),
messagePhases: {},
message: undefined,
reasoningItems: {},

@@ -1043,0 +1113,0 @@ });

import { Effect, Schema } from "effect";
import { Route } from "../route/client.js";
import { Framing } from "../route/framing.js";
import { HttpTransport } from "../route/transport/index.js";

@@ -590,3 +591,3 @@ import { Protocol } from "../route/protocol.js";

readonly presence_penalty?: number | undefined;
}, string, {
}, string, "[DONE]" | {
readonly [x: string]: unknown;

@@ -659,2 +660,3 @@ readonly error?: {

}, ParserState>;
export declare const framing: Framing.Definition<string>;
export declare const httpTransport: HttpTransport.HttpJsonTransport<{

@@ -661,0 +663,0 @@ readonly model: string;

@@ -6,2 +6,3 @@ import { Effect, Schema } from "effect";

import { Endpoint } from "../route/endpoint.js";
import { Framing } from "../route/framing.js";
import { HttpTransport } from "../route/transport/index.js";

@@ -180,2 +181,4 @@ import { Protocol } from "../route/protocol.js";

}), [Schema.Record(Schema.String, Schema.Unknown)]);
const DONE = "[DONE]";
const OpenAIChatStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(OpenAIChatEvent)]);
const lowerTool = (tool, inputSchema, options, supportsStrictMode) => ({

@@ -801,10 +804,9 @@ type: "function",

const reasoningEmitted = state.reasoningEmitted || lifecycle.reasoning.has("reasoning-0");
if (delta?.content) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0", reasoningMetadata(state.providerMetadataKey, reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined));
// Reasoning is one response-wide channel: it stays open alongside text and
// refusal output so late reasoning deltas and details join the same block,
// and `finishEvents` closes it once with the complete metadata.
if (delta?.content)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.content);
}
if (delta?.refusal) {
lifecycle = Lifecycle.reasoningEnd(lifecycle, events, "reasoning-0", reasoningMetadata(state.providerMetadataKey, reasoningField, reasoningDetailsObserved ? state.reasoningDetails : undefined));
if (delta?.refusal)
lifecycle = Lifecycle.textDelta(lifecycle, events, "text-0", delta.refusal);
}
// Compatible providers may omit indexes. Prefer durable identity, then use

@@ -897,3 +899,5 @@ // batch position for parallel deltas or the latest call for sparse chunks.

: { normalized: hasToolCalls ? "tool-calls" : "stop" };
const metadata = reasoningMetadata(state.providerMetadataKey, state.reasoningField, state.reasoningDetailsObserved ? state.reasoningDetails : undefined);
// Snapshot details at publish time so the emitted event never observes later
// mutation of the accumulated `reasoningDetails` array.
const metadata = reasoningMetadata(state.providerMetadataKey, state.reasoningField, state.reasoningDetailsObserved ? [...state.reasoningDetails] : undefined);
const started = state.reasoningDetailsObserved && !state.reasoningEmitted

@@ -924,3 +928,3 @@ ? Lifecycle.reasoningStart(state.lifecycle, events, "reasoning-0", reasoningMetadata(state.providerMetadataKey, state.reasoningField))

stream: {
event: Protocol.jsonEvent(OpenAIChatEvent),
event: OpenAIChatStreamEvent,
initial: (request) => ({

@@ -939,7 +943,9 @@ providerMetadataKey: request.model.route.providerMetadataKey ?? String(request.model.provider),

}),
step,
step: (state, event) => (event === DONE ? Effect.succeed([state, []]) : step(state, event)),
terminal: (event) => event === DONE,
onHalt: finishEvents,
},
});
export const httpTransport = HttpTransport.sseJson.with();
export const framing = Framing.sseWithDone;
export const httpTransport = HttpTransport.sseJson.with().with({ framing });
export const route = Route.make({

@@ -946,0 +952,0 @@ id: ADAPTER,

import { Route } from "../route/client.js";
import { Endpoint } from "../route/endpoint.js";
import { Framing } from "../route/framing.js";
import * as OpenAIChat from "./openai-chat.js";

@@ -18,4 +17,4 @@ const ADAPTER = "openai-compatible-chat";

endpoint: Endpoint.path("/chat/completions"),
framing: Framing.sse,
framing: OpenAIChat.framing,
});
export * as OpenAICompatibleChat from "./openai-compatible-chat.js";

@@ -120,9 +120,9 @@ import { Tool } from "@opencode-ai/schema/tool";

* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty / `[DONE]`
* keep-alive events so the protocol event schema sees one JSON string per
* element. Retry control events are ignored without interrupting the stream.
* decoder, optionally filters named events, and drops empty events. `[DONE]`
* is dropped by default or retained for protocols that use it as their stream
* boundary. Retry control events are ignored without interrupting the stream.
* Decoder failures become provider output errors so the public error channel
* stays `AIError`.
*/
export declare const sseFraming: (bytes: Stream.Stream<Uint8Array, AIError>, events?: ReadonlySet<string>) => Stream.Stream<string, AIError>;
export declare const sseFraming: (bytes: Stream.Stream<Uint8Array, AIError>, events?: ReadonlySet<string>, includeDone?: boolean) => Stream.Stream<string, AIError>;
/**

@@ -129,0 +129,0 @@ * Canonical invalid-request constructor shared by protocol lowering.

@@ -161,9 +161,9 @@ import { Buffer } from "node:buffer";

* `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
* decoder, optionally filters named events, and drops empty / `[DONE]`
* keep-alive events so the protocol event schema sees one JSON string per
* element. Retry control events are ignored without interrupting the stream.
* decoder, optionally filters named events, and drops empty events. `[DONE]`
* is dropped by default or retained for protocols that use it as their stream
* boundary. Retry control events are ignored without interrupting the stream.
* Decoder failures become provider output errors so the public error channel
* stays `AIError`.
*/
export const sseFraming = (bytes, events) => bytes.pipe(Stream.decodeText(), Stream.mapAccumEffect(() => {
export const sseFraming = (bytes, events, includeDone = false) => bytes.pipe(Stream.decodeText(), Stream.mapAccumEffect(() => {
const output = [];

@@ -184,3 +184,3 @@ return {

event.data.length > 0 &&
(event.data !== "[DONE]" || (events !== undefined && event.event !== "message"))), Stream.map((event) => event.data));
(event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message"))), Stream.map((event) => event.data));
/**

@@ -187,0 +187,0 @@ * Canonical invalid-request constructor shared by protocol lowering.

@@ -13,4 +13,8 @@ import { LLMEvent, type FinishReasonDetails, type ProviderMetadata, type Usage } from "../../schema/index.js";

export declare const reasoningDelta: (state: State, events: LLMEvent[], id: string, text: string, providerMetadata?: ProviderMetadata) => State;
export declare const reasoningEnd: (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata) => State;
export declare const textEnd: (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata) => State;
export declare const reasoningEnd: (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text?: string) => State;
export declare const textEnd: (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text?: string) => State;
export declare const finish: (state: State, events: LLMEvent[], input: {

@@ -17,0 +21,0 @@ readonly reason: FinishReasonDetails;

@@ -33,7 +33,9 @@ import { LLMEvent } from "../../schema/index.js";

};
export const reasoningEnd = (state, events, id, providerMetadata) => {
export const reasoningEnd = (state, events, id, providerMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text) => {
if (!state.reasoning.has(id))
return state;
const stepped = stepStart(state, events);
events.push(LLMEvent.reasoningEnd({ id, providerMetadata }));
events.push(LLMEvent.reasoningEnd({ id, text, providerMetadata }));
const reasoning = new Set(stepped.reasoning);

@@ -43,10 +45,12 @@ reasoning.delete(id);

};
export const textEnd = (state, events, id, providerMetadata) => {
export const textEnd = (state, events, id, providerMetadata,
/** Authoritative complete value; replaces accumulated deltas when present. */
text) => {
if (!state.text.has(id))
return state;
const stepped = stepStart(state, events);
events.push(LLMEvent.textEnd({ id, providerMetadata }));
const text = new Set(stepped.text);
text.delete(id);
return { ...stepped, text };
events.push(LLMEvent.textEnd({ id, text, providerMetadata }));
const open = new Set(stepped.text);
open.delete(id);
return { ...stepped, text: open };
};

@@ -53,0 +57,0 @@ const closeOpenBlocks = (state, events) => {

@@ -96,2 +96,3 @@ import { Effect } from "effect";

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -122,2 +123,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -292,2 +294,3 @@ readonly [x: string]: {

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -318,2 +321,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -485,2 +489,3 @@ readonly [x: string]: {

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -511,2 +516,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -513,0 +519,0 @@ readonly [x: string]: {

@@ -27,2 +27,3 @@ import { Option, Schema } from "effect";

/model_context_window_exceeded/i,
/range of input length should be/i,
/too many tokens/i,

@@ -42,2 +43,3 @@ /token limit exceeded/i,

const QUOTA_CODES = new Set(["insufficient_quota", "usage_not_included", "billing_error"]);
const AUTH_CODES = new Set(["authentication_error", "permission_error"]);
const SERVER_CODES = new Set([

@@ -58,5 +60,8 @@ "api_error",

const CONTENT_POLICY_TEXT = /content[-_\s]?policy|content_filter|safety/i;
const NETWORK_ERROR_TEXT = /network[-_\s]error/i;
// Keep HTTP failures and provider-reported stream failures on one typed path so
// session retry policy never needs provider-specific string matching.
// Classification records affirmative evidence about a failure. Deterministic
// failures need positive identification (a 4xx status, quota/auth/policy
// signals); anything unrecognized stays UnknownProvider, which the session
// retry policy treats as retry-eligible because transient failures arrive in
// unpredictable shapes while deterministic rejections almost always carry a
// status or known code.
export function classifyProviderFailure(input) {

@@ -82,11 +87,7 @@ const details = { message: input.message, body: input.rawBody, http: input.http, cause: input.cause };

return new QuotaExceededError(details);
if (input.status === 401)
return new AuthenticationError({ ...details, kind: "invalid" });
if (input.status === 403)
return new AuthenticationError({ ...details, kind: "insufficient-permissions" });
if (codes.includes("authentication_error"))
return new AuthenticationError({ ...details, kind: "invalid" });
if (codes.includes("permission_error"))
return new AuthenticationError({ ...details, kind: "insufficient-permissions" });
if (codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception"))
if (input.status === 401 || input.status === 403 || codes.some((code) => AUTH_CODES.has(code)))
return new AuthenticationError(details);
if (input.status === 429 ||
codes.some((code) => code.includes("rate_limit") || code === "too_many_requests" || code === "throttlingexception") ||
RATE_LIMIT_TEXT.test(text))
return new RateLimitError({

@@ -97,11 +98,6 @@ ...details,

});
if (RATE_LIMIT_TEXT.test(text))
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
});
if (NETWORK_ERROR_TEXT.test(text))
return new ProviderInternalError(details);
if (codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
if (input.status === 408 ||
input.status === 409 ||
(input.status !== undefined && input.status >= 500) ||
codes.some((code) => SERVER_CODES.has(code) || code.includes("exhausted") || code.includes("unavailable")))
return new ProviderInternalError({

@@ -111,17 +107,6 @@ ...details,

});
if (input.status === 429) {
return new RateLimitError({
...details,
retryAfterMs: input.retryAfterMs,
rateLimit: input.rateLimit,
});
}
if (input.status === 408 || input.status === 409 || (input.status !== undefined && input.status >= 500))
return new ProviderInternalError({
...details,
retryAfterMs: input.retryAfterMs,
});
if (codes.some((code) => INVALID_REQUEST_CODES.has(code)))
return new InvalidRequestError(details);
if (input.status === 400 || input.status === 404 || input.status === 413 || input.status === 422)
// Any remaining 4xx is a deterministic rejection of this request.
if (input.status !== undefined && input.status >= 400 && input.status < 500)
return new InvalidRequestError(details);

@@ -128,0 +113,0 @@ return new UnknownProviderError(details);

@@ -128,3 +128,3 @@ import type { ProviderPackage } from "../provider-package.js";

readonly include_reasoning?: boolean | undefined;
}, string, {
}, string, "[DONE]" | {
readonly [x: string]: unknown;

@@ -131,0 +131,0 @@ readonly error?: {

@@ -7,3 +7,2 @@ import { Effect, Schema } from "effect";

import { Endpoint } from "../route/endpoint.js";
import { Framing } from "../route/framing.js";
import { Protocol } from "../route/protocol.js";

@@ -51,3 +50,3 @@ import { ProviderID } from "../schema/index.js";

endpoint: Endpoint.path("/chat/completions", { baseURL: profiles.groq.baseURL }),
framing: Framing.sse,
framing: OpenAIChat.framing,
});

@@ -54,0 +53,0 @@ export const configure = (input = {}) => {

@@ -285,3 +285,3 @@ import { Schema } from "effect";

readonly presence_penalty?: number | undefined;
}, string, {
}, string, "[DONE]" | {
readonly [x: string]: unknown;

@@ -288,0 +288,0 @@ readonly error?: {

import { Effect, Schema } from "effect";
import { Route } from "../route/client.js";
import { Endpoint } from "../route/endpoint.js";
import { Framing } from "../route/framing.js";
import { Protocol } from "../route/protocol.js";

@@ -90,3 +89,3 @@ import { AuthOptions } from "../route/auth-options.js";

endpoint: Endpoint.path("/chat/completions", { baseURL: profile.baseURL }),
framing: Framing.sse,
framing: OpenAIChat.framing,
});

@@ -93,0 +92,0 @@ export const routes = [route];

@@ -80,3 +80,3 @@ import { Config, Effect, Redacted } from "effect";

reason: error instanceof MissingCredentialError
? new AuthenticationError({ message: error.message, cause: error, kind: "missing" })
? new AuthenticationError({ message: error.message, cause: error })
: new InvalidRequestError({ message: `Failed to resolve auth config: ${error.message}`, cause: error }),

@@ -83,0 +83,0 @@ });

@@ -176,2 +176,3 @@ import { Context, Effect, Layer, Schema, Stream } from "effect";

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -202,2 +203,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -204,0 +206,0 @@ readonly [x: string]: {

@@ -9,4 +9,4 @@ import type { Stream } from "effect";

* - SSE (`Framing.sse`) — UTF-8 decode the body, run the SSE channel decoder,
* drop empty / `[DONE]` keep-alives. Each emitted frame is the JSON `data:`
* payload of one event.
* and emit the `data:` payload of each non-empty event. The default drops
* `[DONE]`; protocols that use it as a terminal select `sseWithDone`.
* - AWS event stream — length-prefixed binary frames with CRC checksums.

@@ -26,4 +26,6 @@ * Each emitted frame is one parsed binary event record.

export declare const sse: Definition<string>;
/** Server-Sent Events framing that retains the conventional `[DONE]` sentinel. */
export declare const sseWithDone: Definition<string>;
/** SSE framing restricted to protocol-recognized event names. */
export declare const sseEvents: (events: ReadonlySet<string>) => Definition<string>;
export * as Framing from "./framing.js";
import * as ProviderShared from "../protocols/shared.js";
/** Server-Sent Events framing. Used by every JSON-streaming HTTP provider. */
export const sse = { id: "sse", frame: ProviderShared.sseFraming };
/** Server-Sent Events framing that retains the conventional `[DONE]` sentinel. */
export const sseWithDone = {
id: "sse",
frame: (bytes) => ProviderShared.sseFraming(bytes, undefined, true),
};
/** SSE framing restricted to protocol-recognized event names. */

@@ -5,0 +10,0 @@ export const sseEvents = (events) => ({

@@ -42,7 +42,6 @@ import { Schema } from "effect";

declare const AuthenticationError_base: Schema.Class<AuthenticationError, Schema.TaggedStruct<"Authentication", {
readonly kind: Schema.Literals<readonly ["missing", "invalid", "expired", "insufficient-permissions", "unknown"]>;
readonly message: Schema.String;
readonly body: Schema.optional<Schema.String>;
readonly http: Schema.optional<typeof HttpContext>;
readonly cause: Schema.optional<Schema.Defect>;
message: Schema.String;
body: Schema.optional<Schema.String>;
http: Schema.optional<typeof HttpContext>;
cause: Schema.optional<Schema.Defect>;
}>, import("effect/Cause").YieldableError>;

@@ -49,0 +48,0 @@ export declare class AuthenticationError extends AuthenticationError_base {

@@ -38,6 +38,3 @@ import { Schema } from "effect";

}
export class AuthenticationError extends Schema.TaggedError("AI.Error.Authentication")("Authentication", {
...ReasonFields,
kind: Schema.Literals(["missing", "invalid", "expired", "insufficient-permissions", "unknown"]),
}) {
export class AuthenticationError extends Schema.TaggedError("AI.Error.Authentication")("Authentication", ReasonFields) {
}

@@ -44,0 +41,0 @@ export class RateLimitError extends Schema.TaggedError("AI.Error.RateLimit")("RateLimit", {

@@ -94,2 +94,4 @@ import { Schema } from "effect";

id: ContentBlockID,
/** Authoritative complete value; replaces accumulated deltas when present. */
text: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),

@@ -111,2 +113,4 @@ }).annotate({ identifier: "LLM.Event.TextEnd" });

id: ContentBlockID,
/** Authoritative complete value; replaces accumulated deltas when present. */
text: Schema.optional(Schema.String),
providerMetadata: Schema.optional(ProviderMetadata),

@@ -264,10 +268,22 @@ }).annotate({ identifier: "LLM.Event.ReasoningEnd" });

});
const responseText = (events) => events
.filter(LLMEvent.is.textDelta)
.map((event) => event.text)
.join("");
const responseReasoning = (events) => events
.filter(LLMEvent.is.reasoningDelta)
.map((event) => event.text)
.join("");
/** Joins deltas per fragment, letting an authoritative end value replace that fragment's accumulated deltas. */
const joinFragments = (events, isDelta, isEnd) => {
const order = [];
const parts = new Map();
for (const event of events) {
if (isDelta(event)) {
if (!parts.has(event.id))
order.push(event.id);
parts.set(event.id, (parts.get(event.id) ?? "") + event.text);
}
if (isEnd(event) && event.text !== undefined) {
if (!parts.has(event.id))
order.push(event.id);
parts.set(event.id, event.text);
}
}
return order.map((id) => parts.get(id)).join("");
};
const responseText = (events) => joinFragments(events, LLMEvent.is.textDelta, LLMEvent.is.textEnd);
const responseReasoning = (events) => joinFragments(events, LLMEvent.is.reasoningDelta, LLMEvent.is.reasoningEnd);
const responseUsage = (events) => events.reduce((usage, event) => ("usage" in event && event.usage !== undefined ? event.usage : usage), undefined);

@@ -339,6 +355,7 @@ const emptyResponseState = () => ({

return state;
const text = event.text ?? current.text;
const providerMetadata = event.providerMetadata ?? current.providerMetadata;
return {
...replaceContent(state, current.contentIndex, textContent(current.text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, providerMetadata } },
...replaceContent(state, current.contentIndex, textContent(text, providerMetadata)),
textParts: { ...state.textParts, [event.id]: { ...current, text, providerMetadata } },
};

@@ -373,6 +390,7 @@ };

return state;
const text = event.text ?? current.text;
const providerMetadata = event.providerMetadata ?? current.providerMetadata;
return {
...replaceContent(state, current.contentIndex, reasoningContent(current.text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, providerMetadata } },
...replaceContent(state, current.contentIndex, reasoningContent(text, providerMetadata)),
reasoningParts: { ...state.reasoningParts, [event.id]: { ...current, text, providerMetadata } },
};

@@ -465,7 +483,7 @@ };

}) {
/** Concatenated assistant text assembled from streamed `text-delta` events. */
/** Concatenated assistant text; each fragment's `text-end` value replaces its accumulated deltas when present. */
get text() {
return responseText(this.events);
}
/** Concatenated reasoning text assembled from streamed `reasoning-delta` events. */
/** Concatenated reasoning text; each fragment's `reasoning-end` value replaces its accumulated deltas when present. */
get reasoning() {

@@ -472,0 +490,0 @@ return responseReasoning(this.events);

export * as TestLLM from "./testing.js";
import { type Interface as LLMClientShape } from "./route/client.js";
import { LLMClient } from "./route/client.js";
import { LLMEvent, type FinishReasonDetails, type AIError, type LLMRequest, type ProviderMetadata, type UsageInput } from "./schema/index.js";

@@ -10,2 +10,20 @@ import { Context, Effect, Layer, Scope, Stream } from "effect";

}>;
type ClientInterface = Context.Service.Shape<typeof LLMClient.Service>;
export type Responder = (request: LLMRequest) => Response;
export interface TestInterface extends ClientInterface {
/** Returns a snapshot of requests observed at execution time. */
readonly requests: () => Effect.Effect<readonly LLMRequest[]>;
readonly push: (...responses: readonly Response[]) => Effect.Effect<void>;
/** Replaces the fallback without changing queued responses. */
readonly always: (response: Response) => Effect.Effect<void>;
/** Answers requests after the one-shot queue is exhausted; receives the original request. */
readonly serve: (responder: Responder) => Effect.Effect<void>;
/** Waits for request arrivals, not output or completion. */
readonly wait: (count: number) => Effect.Effect<void>;
readonly gate: () => Effect.Effect<Gate, never, Scope.Scope>;
}
declare const Test_base: Context.ServiceClass<Test, "@opencode/ai/TestLLM/Test", TestInterface>;
export declare class Test extends Test_base {
}
/** @deprecated Use TestInterface through Test and testLayer. */
export interface Interface {

@@ -17,3 +35,3 @@ readonly requests: LLMRequest[];

readonly gate: Effect.Effect<Gate, never, Scope.Scope>;
readonly client: LLMClientShape;
readonly client: ClientInterface;
}

@@ -26,2 +44,3 @@ export interface LayerOptions {

declare const Service_base: Context.ServiceClass<Service, "@opencode/ai/TestLLM", Interface>;
/** @deprecated Use Test and testLayer for normal client methods and test controls. */
export declare class Service extends Service_base {

@@ -56,2 +75,3 @@ }

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -82,2 +102,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -241,2 +262,3 @@ readonly [x: string]: {

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -267,2 +289,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -426,2 +449,3 @@ readonly [x: string]: {

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -452,2 +476,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -611,2 +636,3 @@ readonly [x: string]: {

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -637,2 +663,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -796,2 +823,3 @@ readonly [x: string]: {

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -822,2 +850,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -981,2 +1010,3 @@ readonly [x: string]: {

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -1007,2 +1037,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -1166,2 +1197,3 @@ readonly [x: string]: {

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -1192,2 +1224,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -1351,2 +1384,3 @@ readonly [x: string]: {

readonly type: "text-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -1377,2 +1411,3 @@ readonly [x: string]: {

readonly type: "reasoning-end";
readonly text?: string | undefined;
readonly providerMetadata?: {

@@ -1513,3 +1548,7 @@ readonly [x: string]: {

}, never, never>;
/** Provides one shared implementation under the normal client and test-control tags. */
export declare const testLayer: (options?: LayerOptions) => Layer.Layer<import("./route/client.js").Service | Test, never, never>;
/** @deprecated Use testLayer; retained for published callers of the legacy control interface. */
export declare const layer: (options?: LayerOptions) => Layer.Layer<Service, never, never>;
/** @deprecated testLayer provides LLMClient.Service directly. */
export declare const clientLayer: Layer.Layer<import("./route/client.js").Service, never, Service>;

@@ -1516,0 +1555,0 @@ export declare const push: (...responses: readonly Response[]) => Effect.Effect<void, never, Service>;

@@ -5,2 +5,5 @@ export * as TestLLM from "./testing.js";

import { Context, Deferred, Effect, Latch, Layer, Queue, Scope, Stream } from "effect";
export class Test extends Context.Service()("@opencode/ai/TestLLM/Test") {
}
/** @deprecated Use Test and testLayer for normal client methods and test controls. */
export class Service extends Context.Service()("@opencode/ai/TestLLM") {

@@ -32,3 +35,3 @@ }

const toStream = (response) => (Stream.isStream(response) ? response : Stream.fromIterable(response));
export const layer = (options = {}) => Layer.effect(Service, Effect.gen(function* () {
const make = (options) => Effect.sync(() => {
const requests = [];

@@ -40,17 +43,22 @@ const responses = [];

const wait = (count) => Effect.suspend(() => requests.length >= count ? Effect.void : Deferred.await(started).pipe(Effect.andThen(wait(count))));
const stream = ((request) => {
requests.push(options.transformRequest?.(request) ?? request);
const stream = (request) => Stream.suspend(() => {
const count = requests.push(options.transformRequest?.(request) ?? request);
const waiting = started;
started = Deferred.makeUnsafe();
Deferred.doneUnsafe(waiting, Effect.void);
const response = responses.shift() ?? fallback;
if (!response)
return Stream.die(new Error(`TestLLM has no response for request ${requests.length}`));
const streamed = toStream(response);
const gate = activeGate;
if (!gate)
return streamed;
return Stream.unwrap(Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)));
try {
const response = responses.shift() ?? (typeof fallback === "function" ? fallback(request) : fallback);
if (!response)
return Stream.die(new Error(`TestLLM has no response for request ${count}`));
const streamed = toStream(response);
if (!gate)
return streamed;
return Stream.unwrap(Queue.offer(gate.started, undefined).pipe(Effect.andThen(gate.release.await), Effect.as(streamed)));
}
finally {
// Waiters can resume synchronously; assign the reply and gate before notifying them.
Deferred.doneUnsafe(waiting, Effect.void);
}
});
const client = LLMClient.Service.of({
const test = Test.of({
stream,

@@ -63,5 +71,3 @@ generate: (request) => stream(request).pipe(Stream.runFold(LLMResponse.empty, LLMResponse.reduce), Effect.flatMap((state) => {

})),
});
return Service.of({
requests,
requests: () => Effect.sync(() => [...requests]),
push: (...input) => Effect.sync(() => {

@@ -73,4 +79,7 @@ responses.push(...input);

}),
serve: (responder) => Effect.sync(() => {
fallback = responder;
}),
wait,
gate: Effect.gen(function* () {
gate: () => Effect.gen(function* () {
const gate = {

@@ -91,5 +100,17 @@ started: yield* Effect.acquireRelease(Queue.unbounded(), Queue.shutdown),

}),
client,
});
}));
return { test, requests };
});
/** Provides one shared implementation under the normal client and test-control tags. */
export const testLayer = (options = {}) => Layer.effectContext(Effect.map(make(options), (implementation) => Context.make(LLMClient.Service, implementation.test).pipe(Context.add(Test, implementation.test))));
/** @deprecated Use testLayer; retained for published callers of the legacy control interface. */
export const layer = (options = {}) => Layer.effect(Service, Effect.map(make(options), (implementation) => Service.of({
requests: implementation.requests,
push: implementation.test.push,
always: implementation.test.always,
wait: implementation.test.wait,
gate: implementation.test.gate(),
client: implementation.test,
})));
/** @deprecated testLayer provides LLMClient.Service directly. */
export const clientLayer = Layer.effect(LLMClient.Service, Effect.map(Service, (service) => service.client));

@@ -96,0 +117,0 @@ export const push = (...responses) => Service.use((service) => service.push(...responses));

{
"$schema": "https://json.schemastore.org/package.json",
"version": "0.0.0-beta-18414",
"version": "0.0.0-beta-18593",
"name": "@opencode-ai/ai",

@@ -32,4 +32,4 @@ "type": "module",

"@clack/prompts": "1.0.0-alpha.1",
"@effect/platform-node": "4.0.0-rc.111",
"@opencode-ai/http-recorder": "0.0.0-beta-18414",
"@effect/platform-node": "4.0.0-rc.112",
"@opencode-ai/http-recorder": "0.0.0-beta-18593",
"@tsconfig/bun": "1.0.9",

@@ -43,7 +43,7 @@ "@types/bun": "1.3.13",

"@smithy/util-utf8": "4.2.2",
"@opencode-ai/schema": "0.0.0-beta-18414",
"@opencode-ai/schema": "0.0.0-beta-18593",
"aws4fetch": "1.0.20",
"effect": "4.0.0-rc.111",
"effect": "4.0.0-rc.112",
"google-auth-library": "10.5.0"
}
}
+29
-11

@@ -217,19 +217,37 @@ # @opencode-ai/ai

const testLLM = TestLLM.layer({
fallback: TestLLM.text("Hello from the test model", "text-1"),
})
// TestLLM.clientLayer provides LLMClient.Service and consumes TestLLM.Service.
const programWithTestClient = Effect.gen(function* () {
const test = yield* TestLLM.Test
yield* test.push(TestLLM.text("Hello from the test model", "text-1"))
const result = yield* program
const test = yield* TestLLM.Service
console.log(test.requests)
console.log(yield* test.requests())
return result
}).pipe(Effect.provide(TestLLM.clientLayer), Effect.provide(testLLM))
}).pipe(Effect.provide(TestLLM.testLayer()))
```
`TestLLM.push(...)` scripts one-shot responses, `TestLLM.always(...)` changes the fallback, and
`TestLLM.wait(...)` lets concurrent tests wait until a request has arrived. Every received canonical request is
available on the yielded `TestLLM.Service`.
`testLayer()` provides the same object under `LLMClient.Service` and `TestLLM.Test`. Production consumes the
normal client; tests use the additional controls. Each layer build has fresh state.
- `test.push(...)` queues one-shot responses in execution order. Each argument is one response.
- `test.always(response)` installs a repeatable fallback. The layer's `fallback` option sets its initial value.
- `test.serve(request => response)` installs a request-dependent fallback. `always` and `serve` replace each
other without changing queued replies; queued replies take precedence.
- `test.requests()` returns an array snapshot. `transformRequest` changes only the recorded observation;
`serve` receives the original canonical request.
- `test.wait(count)` waits for request arrivals, not output or completion, and supports concurrent waiters.
- `test.gate()` returns a scoped gate with countable `started` notifications and a `release` Effect. Release
unblocks all requests captured by that gate; closing its scope also releases it. Effect-aware test runners
already provide Scope.
Constructing `stream()` or `generate()` does not record a request, invoke a responder, or consume a script.
Each execution does. An exhausted queue without a fallback defects immediately rather than waiting for a
future reply.
Responses remain canonical event arrays or arbitrary `Stream<LLMEvent, AIError>` values. The client consumes
supplied streams directly, preserving failure identity, finalizers, incomplete output, and post-finish tails;
it does not repair or truncate them.
The published legacy `Service`, `layer`, `clientLayer`, and module-level controls remain available as adapters
over the same implementation, including the legacy live `requests` array. New tests should use `Test` and
`testLayer`.
## Caching

@@ -236,0 +254,0 @@

Sorry, the diff of this file is too big to display