@effect-uai/ai-sdk 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +80 -0
- package/dist/Messages.d.mts +40 -0
- package/dist/Messages.d.mts.map +1 -0
- package/dist/Messages.mjs +76 -0
- package/dist/Messages.mjs.map +1 -0
- package/dist/UIMessageStream.d.mts +112 -0
- package/dist/UIMessageStream.d.mts.map +1 -0
- package/dist/UIMessageStream.mjs +160 -0
- package/dist/UIMessageStream.mjs.map +1 -0
- package/dist/UIMessageStream.test.d.mts +1 -0
- package/dist/UIMessageStream.test.mjs +239 -0
- package/dist/UIMessageStream.test.mjs.map +1 -0
- package/dist/chunk-CfYAbeIz.mjs +13 -0
- package/dist/conformance.test.d.mts +1 -0
- package/dist/conformance.test.mjs +11374 -0
- package/dist/conformance.test.mjs.map +1 -0
- package/dist/dist-DaKrk-s5.mjs +13769 -0
- package/dist/dist-DaKrk-s5.mjs.map +1 -0
- package/dist/index.d.mts +3 -0
- package/dist/index.mjs +3 -0
- package/dist/magic-string.es-CaWgF-3p.mjs +1013 -0
- package/dist/magic-string.es-CaWgF-3p.mjs.map +1 -0
- package/package.json +66 -0
- package/src/Messages.ts +122 -0
- package/src/UIMessageStream.test.ts +219 -0
- package/src/UIMessageStream.ts +233 -0
- package/src/conformance.test.ts +93 -0
- package/src/index.ts +2 -0
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project an effect-uai loop onto the Vercel AI SDK "UI Message Stream"
|
|
3
|
+
* protocol (the `v1` wire format, stable across AI SDK v5–v7). The output is a
|
|
4
|
+
* `Stream<SSE.Event>` ready for `SSE.toBytes`; a `useChat` client on the other
|
|
5
|
+
* end consumes it with no frontend changes.
|
|
6
|
+
*
|
|
7
|
+
* The encoder input is an `Emission`: every `InteractionEvent` the loop
|
|
8
|
+
* produces, plus optional `dataPart` and `messageMetadata` emissions a caller
|
|
9
|
+
* interleaves to stream custom typed data (e.g. live metrics) and settled
|
|
10
|
+
* per-message metadata (e.g. usage). A plain `Stream<InteractionEvent>` is a
|
|
11
|
+
* valid `Stream<Emission>`, so existing callers are unaffected.
|
|
12
|
+
*
|
|
13
|
+
* The protocol wants a stable id and explicit start/end lifecycle around each
|
|
14
|
+
* text and reasoning block, which effect-uai's delta events don't carry. This
|
|
15
|
+
* module synthesizes them: one block id per turn, opened on the first delta
|
|
16
|
+
* and closed on `TurnComplete`.
|
|
17
|
+
*/
|
|
18
|
+
import type * as SSE from "@effect-uai/core/SSE"
|
|
19
|
+
import * as Turn from "@effect-uai/core/Turn"
|
|
20
|
+
import { Match, Option, Stream } from "effect"
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* The stream parts `useChat` consumes. Names and shapes are the wire
|
|
24
|
+
* contract with the AI SDK client; keep them literal.
|
|
25
|
+
*/
|
|
26
|
+
export type Part =
|
|
27
|
+
| { readonly type: "start"; readonly messageId: string }
|
|
28
|
+
| { readonly type: "text-start"; readonly id: string }
|
|
29
|
+
| { readonly type: "text-delta"; readonly id: string; readonly delta: string }
|
|
30
|
+
| { readonly type: "text-end"; readonly id: string }
|
|
31
|
+
| { readonly type: "reasoning-start"; readonly id: string }
|
|
32
|
+
| { readonly type: "reasoning-delta"; readonly id: string; readonly delta: string }
|
|
33
|
+
| { readonly type: "reasoning-end"; readonly id: string }
|
|
34
|
+
| { readonly type: "tool-input-start"; readonly toolCallId: string; readonly toolName: string }
|
|
35
|
+
| {
|
|
36
|
+
readonly type: "tool-input-delta"
|
|
37
|
+
readonly toolCallId: string
|
|
38
|
+
readonly inputTextDelta: string
|
|
39
|
+
}
|
|
40
|
+
| {
|
|
41
|
+
readonly type: "tool-input-available"
|
|
42
|
+
readonly toolCallId: string
|
|
43
|
+
readonly toolName: string
|
|
44
|
+
readonly input: unknown
|
|
45
|
+
}
|
|
46
|
+
| {
|
|
47
|
+
readonly type: "tool-output-available"
|
|
48
|
+
readonly toolCallId: string
|
|
49
|
+
readonly output: unknown
|
|
50
|
+
}
|
|
51
|
+
| {
|
|
52
|
+
readonly type: `data-${string}`
|
|
53
|
+
readonly id?: string
|
|
54
|
+
readonly data: unknown
|
|
55
|
+
readonly transient?: boolean
|
|
56
|
+
}
|
|
57
|
+
| { readonly type: "message-metadata"; readonly messageMetadata: unknown }
|
|
58
|
+
| { readonly type: "error"; readonly errorText: string }
|
|
59
|
+
| { readonly type: "finish" }
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* A custom typed data part (`data-<name>`). `transient: true` delivers it to
|
|
63
|
+
* the client's `onData` callback only (not persisted into `message.parts`);
|
|
64
|
+
* reuse an `id` across emissions to reconcile/update one part in place.
|
|
65
|
+
*/
|
|
66
|
+
export type DataEmission = {
|
|
67
|
+
readonly kind: "data"
|
|
68
|
+
readonly name: string
|
|
69
|
+
readonly data: unknown
|
|
70
|
+
readonly id?: string
|
|
71
|
+
readonly transient?: boolean
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** Settled per-message metadata, surfaced as `message.metadata` on the client. */
|
|
75
|
+
export type MetadataEmission = {
|
|
76
|
+
readonly kind: "metadata"
|
|
77
|
+
readonly metadata: unknown
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* What `toUIMessageStream` encodes: loop `InteractionEvent`s plus optional
|
|
82
|
+
* data / metadata emissions a caller interleaves into the stream.
|
|
83
|
+
*/
|
|
84
|
+
export type Emission = Turn.InteractionEvent | DataEmission | MetadataEmission
|
|
85
|
+
|
|
86
|
+
/** Construct a `data-<name>` emission. See {@link DataEmission}. */
|
|
87
|
+
export const dataPart = (
|
|
88
|
+
name: string,
|
|
89
|
+
data: unknown,
|
|
90
|
+
options?: { readonly id?: string; readonly transient?: boolean },
|
|
91
|
+
): DataEmission => ({ kind: "data", name, data, ...options })
|
|
92
|
+
|
|
93
|
+
/** Construct a message-metadata emission. See {@link MetadataEmission}. */
|
|
94
|
+
export const messageMetadata = (metadata: unknown): MetadataEmission => ({
|
|
95
|
+
kind: "metadata",
|
|
96
|
+
metadata,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Headers the UI Message Stream protocol requires on the HTTP response.
|
|
101
|
+
* `x-vercel-ai-ui-message-stream: v1` is mandatory; without it the client
|
|
102
|
+
* falls back to the plain text-stream protocol and ignores non-text parts.
|
|
103
|
+
* Pure data, so it composes with any server (web `Response`, `@effect/platform`
|
|
104
|
+
* `HttpServerResponse`, raw Node) - the package deliberately owns no HTTP layer.
|
|
105
|
+
*/
|
|
106
|
+
export const responseHeaders: Record<string, string> = {
|
|
107
|
+
"content-type": "text/event-stream",
|
|
108
|
+
"cache-control": "no-cache",
|
|
109
|
+
connection: "keep-alive",
|
|
110
|
+
"x-vercel-ai-ui-message-stream": "v1",
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const parseJson = Option.liftThrowable(JSON.parse)
|
|
114
|
+
|
|
115
|
+
// Tool arguments/output cross effect-uai as JSON strings; hand the client the
|
|
116
|
+
// decoded value, falling back to the raw string when it isn't JSON.
|
|
117
|
+
const decode = (raw: string): unknown => Option.getOrElse(parseJson(raw), () => raw)
|
|
118
|
+
|
|
119
|
+
const event = (part: Part | "[DONE]"): SSE.Event => ({
|
|
120
|
+
data: part === "[DONE]" ? "[DONE]" : JSON.stringify(part),
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
type State = {
|
|
124
|
+
readonly messageId: string
|
|
125
|
+
readonly textId: string | null
|
|
126
|
+
readonly reasoningId: string | null
|
|
127
|
+
readonly seq: number
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
type Step = readonly [State, ReadonlyArray<Part>]
|
|
131
|
+
|
|
132
|
+
const toDataPart = (d: DataEmission): Part => {
|
|
133
|
+
const base: Part = { type: `data-${d.name}`, data: d.data }
|
|
134
|
+
const withId = d.id !== undefined ? { ...base, id: d.id } : base
|
|
135
|
+
return d.transient !== undefined ? { ...withId, transient: d.transient } : withId
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// One emission fans out to zero or more parts (a first TextDelta opens a block
|
|
139
|
+
// *and* emits its delta), so each step yields an array.
|
|
140
|
+
const step = (s: State, ev: Emission): Step =>
|
|
141
|
+
Match.value(ev).pipe(
|
|
142
|
+
Match.tags({
|
|
143
|
+
TextDelta: (e): Step => {
|
|
144
|
+
if (s.textId !== null) return [s, [{ type: "text-delta", id: s.textId, delta: e.text }]]
|
|
145
|
+
const id = `${s.messageId}:t${s.seq}`
|
|
146
|
+
return [
|
|
147
|
+
{ ...s, textId: id, seq: s.seq + 1 },
|
|
148
|
+
[
|
|
149
|
+
{ type: "text-start", id },
|
|
150
|
+
{ type: "text-delta", id, delta: e.text },
|
|
151
|
+
],
|
|
152
|
+
]
|
|
153
|
+
},
|
|
154
|
+
ReasoningDelta: (e): Step => {
|
|
155
|
+
if (s.reasoningId !== null)
|
|
156
|
+
return [s, [{ type: "reasoning-delta", id: s.reasoningId, delta: e.text }]]
|
|
157
|
+
const id = `${s.messageId}:r${s.seq}`
|
|
158
|
+
return [
|
|
159
|
+
{ ...s, reasoningId: id, seq: s.seq + 1 },
|
|
160
|
+
[
|
|
161
|
+
{ type: "reasoning-start", id },
|
|
162
|
+
{ type: "reasoning-delta", id, delta: e.text },
|
|
163
|
+
],
|
|
164
|
+
]
|
|
165
|
+
},
|
|
166
|
+
ToolCallStart: (e): Step => [
|
|
167
|
+
s,
|
|
168
|
+
[{ type: "tool-input-start", toolCallId: e.call_id, toolName: e.name }],
|
|
169
|
+
],
|
|
170
|
+
ToolCallArgsDelta: (e): Step => [
|
|
171
|
+
s,
|
|
172
|
+
[{ type: "tool-input-delta", toolCallId: e.call_id, inputTextDelta: e.delta }],
|
|
173
|
+
],
|
|
174
|
+
RefusalDelta: (e): Step => [s, [{ type: "error", errorText: e.text }]],
|
|
175
|
+
UsageUpdate: (): Step => [s, []],
|
|
176
|
+
TurnComplete: (e): Step => {
|
|
177
|
+
const closing: ReadonlyArray<Part> = [
|
|
178
|
+
...(s.textId !== null ? [{ type: "text-end", id: s.textId } as const] : []),
|
|
179
|
+
...(s.reasoningId !== null
|
|
180
|
+
? [{ type: "reasoning-end", id: s.reasoningId } as const]
|
|
181
|
+
: []),
|
|
182
|
+
...Turn.getToolCalls(e.turn).map(
|
|
183
|
+
(call): Part => ({
|
|
184
|
+
type: "tool-input-available",
|
|
185
|
+
toolCallId: call.call_id,
|
|
186
|
+
toolName: call.name,
|
|
187
|
+
input: decode(call.arguments),
|
|
188
|
+
}),
|
|
189
|
+
),
|
|
190
|
+
]
|
|
191
|
+
// Reset block ids; the loop may run further turns after tools resolve.
|
|
192
|
+
return [{ ...s, textId: null, reasoningId: null }, closing]
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
// The non-`_tag` arm of InteractionEvent: a resolved tool result the loop
|
|
196
|
+
// appended after running the tool.
|
|
197
|
+
Match.when(
|
|
198
|
+
{ type: "function_call_output" },
|
|
199
|
+
(o): Step => [
|
|
200
|
+
s,
|
|
201
|
+
[{ type: "tool-output-available", toolCallId: o.call_id, output: decode(o.output) }],
|
|
202
|
+
],
|
|
203
|
+
),
|
|
204
|
+
// Caller-interleaved emissions.
|
|
205
|
+
Match.when({ kind: "data" }, (d): Step => [s, [toDataPart(d)]]),
|
|
206
|
+
Match.when(
|
|
207
|
+
{ kind: "metadata" },
|
|
208
|
+
(m): Step => [s, [{ type: "message-metadata", messageMetadata: m.metadata }]],
|
|
209
|
+
),
|
|
210
|
+
Match.exhaustive,
|
|
211
|
+
)
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Encode an `InteractionEvent` stream as UI Message Stream `SSE.Event`s,
|
|
215
|
+
* bracketed by the protocol's `start` and terminal `finish` / `[DONE]`
|
|
216
|
+
* markers. `messageId` identifies the assistant message on the client.
|
|
217
|
+
*/
|
|
218
|
+
export const toUIMessageStream =
|
|
219
|
+
(messageId: string) =>
|
|
220
|
+
<E, R>(self: Stream.Stream<Emission, E, R>): Stream.Stream<SSE.Event, E, R> =>
|
|
221
|
+
Stream.make(event({ type: "start", messageId })).pipe(
|
|
222
|
+
Stream.concat(
|
|
223
|
+
Stream.mapAccum(
|
|
224
|
+
self,
|
|
225
|
+
(): State => ({ messageId, textId: null, reasoningId: null, seq: 0 }),
|
|
226
|
+
(s, ev: Emission): readonly [State, ReadonlyArray<SSE.Event>] => {
|
|
227
|
+
const [next, parts] = step(s, ev)
|
|
228
|
+
return [next, parts.map(event)]
|
|
229
|
+
},
|
|
230
|
+
),
|
|
231
|
+
),
|
|
232
|
+
Stream.concat(Stream.make(event({ type: "finish" }), event("[DONE]"))),
|
|
233
|
+
)
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conformance against the AI SDK's own client reader. We encode an
|
|
3
|
+
* InteractionEvent script, strip the SSE framing, and hand the raw chunks to
|
|
4
|
+
* `readUIMessageStream` - the same assembly `useChat` runs in the browser -
|
|
5
|
+
* then assert the reconstructed `UIMessage`. This pins the encoder to the
|
|
6
|
+
* real protocol implementation: a future SDK bump our bytes no longer satisfy
|
|
7
|
+
* fails here rather than in someone's app.
|
|
8
|
+
*/
|
|
9
|
+
import * as Items from "@effect-uai/core/Items"
|
|
10
|
+
import * as Turn from "@effect-uai/core/Turn"
|
|
11
|
+
import { describe, it } from "@effect/vitest"
|
|
12
|
+
import {
|
|
13
|
+
isTextUIPart,
|
|
14
|
+
isToolUIPart,
|
|
15
|
+
readUIMessageStream,
|
|
16
|
+
type UIMessage,
|
|
17
|
+
type UIMessageChunk,
|
|
18
|
+
} from "ai"
|
|
19
|
+
import { Array as Arr, Effect, Option, Stream } from "effect"
|
|
20
|
+
import { expect } from "vitest"
|
|
21
|
+
import { toUIMessageStream } from "./UIMessageStream.js"
|
|
22
|
+
|
|
23
|
+
const turn = (items: ReadonlyArray<Items.HistoryItem>, stop: Items.StopReason): Turn.Turn => ({
|
|
24
|
+
items,
|
|
25
|
+
usage: {},
|
|
26
|
+
stop_reason: stop,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
// Encode the script, drop the `[DONE]` terminator, parse each part back into a
|
|
30
|
+
// chunk, and feed them through the AI SDK reader - yielding the final message.
|
|
31
|
+
const reconstruct = (
|
|
32
|
+
events: ReadonlyArray<Turn.InteractionEvent>,
|
|
33
|
+
): Effect.Effect<Option.Option<UIMessage>> => {
|
|
34
|
+
const chunks = Stream.fromIterable(events).pipe(
|
|
35
|
+
toUIMessageStream("m1"),
|
|
36
|
+
Stream.map((e) => e.data),
|
|
37
|
+
Stream.filter((data) => data !== "[DONE]"),
|
|
38
|
+
Stream.map((data) => JSON.parse(data) as UIMessageChunk),
|
|
39
|
+
)
|
|
40
|
+
const stream = Stream.toReadableStream(chunks)
|
|
41
|
+
return Stream.fromAsyncIterable(readUIMessageStream({ stream }), (cause) => cause).pipe(
|
|
42
|
+
Stream.runLast,
|
|
43
|
+
Effect.orDie,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const textOf = (message: UIMessage): string =>
|
|
48
|
+
message.parts
|
|
49
|
+
.filter(isTextUIPart)
|
|
50
|
+
.map((part) => part.text)
|
|
51
|
+
.join("")
|
|
52
|
+
|
|
53
|
+
describe("AI SDK conformance (readUIMessageStream)", () => {
|
|
54
|
+
it.effect("reconstructs assistant text", () =>
|
|
55
|
+
Effect.gen(function* () {
|
|
56
|
+
const message = yield* reconstruct([
|
|
57
|
+
Turn.TurnEvent.TextDelta({ text: "Hel" }),
|
|
58
|
+
Turn.TurnEvent.TextDelta({ text: "lo" }),
|
|
59
|
+
Turn.TurnEvent.TurnComplete({ turn: turn([Items.assistantText("Hello")], "stop") }),
|
|
60
|
+
])
|
|
61
|
+
|
|
62
|
+
expect(Option.map(message, (m) => m.role)).toEqual(Option.some("assistant"))
|
|
63
|
+
expect(Option.map(message, textOf)).toEqual(Option.some("Hello"))
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
it.effect("reconstructs a tool call with its resolved output", () =>
|
|
68
|
+
Effect.gen(function* () {
|
|
69
|
+
const call: Items.ToolCall = {
|
|
70
|
+
type: "function_call",
|
|
71
|
+
call_id: "c1",
|
|
72
|
+
name: "search",
|
|
73
|
+
arguments: `{"q":"lisbon"}`,
|
|
74
|
+
}
|
|
75
|
+
const message = yield* reconstruct([
|
|
76
|
+
Turn.TurnEvent.ToolCallStart({ call_id: "c1", name: "search" }),
|
|
77
|
+
Turn.TurnEvent.ToolCallArgsDelta({ call_id: "c1", delta: `{"q":"lisbon"}` }),
|
|
78
|
+
Turn.TurnEvent.TurnComplete({ turn: turn([call], "tool_calls") }),
|
|
79
|
+
Items.toolCallOutput("c1", `{"hits":3}`),
|
|
80
|
+
])
|
|
81
|
+
|
|
82
|
+
const tool = Option.flatMap(message, (m) => Arr.findFirst(m.parts, isToolUIPart))
|
|
83
|
+
|
|
84
|
+
expect(Option.map(tool, (t) => t.state)).toEqual(Option.some("output-available"))
|
|
85
|
+
expect(
|
|
86
|
+
Option.map(tool, (t) => (t.state === "output-available" ? t.input : undefined)),
|
|
87
|
+
).toEqual(Option.some({ q: "lisbon" }))
|
|
88
|
+
expect(
|
|
89
|
+
Option.map(tool, (t) => (t.state === "output-available" ? t.output : undefined)),
|
|
90
|
+
).toEqual(Option.some({ hits: 3 }))
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
})
|
package/src/index.ts
ADDED