@opencode-ai/ai
Advanced tools
+2
-0
@@ -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, |
@@ -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]: { |
+19
-34
@@ -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", { |
+32
-14
@@ -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); |
+41
-2
| 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>; |
+39
-18
@@ -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)); |
+5
-5
| { | ||
| "$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
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
URL strings
Supply chain riskPackage contains fragments of external URLs or IP addresses, which the package may be accessing at runtime.
1479606
8.25%34070
8.49%422
4.46%+ Added
+ Added
- Removed
- Removed
Updated