@effect-uai/ai-sdk 0.18.0 → 0.19.0-dev-20261109183959

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@effect-uai/ai-sdk",
3
- "version": "0.18.0",
3
+ "version": "0.19.0-dev-20261109183959",
4
4
  "description": "Vercel AI SDK (useChat) UI Message Stream compatibility for @effect-uai/core.",
5
5
  "keywords": [
6
6
  "agents",
@@ -51,12 +51,12 @@
51
51
  "dependencies": {},
52
52
  "devDependencies": {
53
53
  "ai": "^7.0.3",
54
- "effect": "4.0.1",
54
+ "effect": "4.1.2",
55
55
  "typescript": "^7.0.2",
56
- "@effect-uai/core": "0.18.0"
56
+ "@effect-uai/core": "0.19.0-dev-20261109183959"
57
57
  },
58
58
  "peerDependencies": {
59
- "@effect-uai/core": ">=0.5.0 <1",
59
+ "@effect-uai/core": "0.19.0-dev-20261109183959",
60
60
  "effect": ">=4.0.1 <5.0.0"
61
61
  },
62
62
  "scripts": {
package/src/Messages.ts CHANGED
@@ -11,7 +11,9 @@
11
11
  */
12
12
  import * as Image from "@effect-uai/core/Image"
13
13
  import * as Items from "@effect-uai/core/Items"
14
- import { Match, Schema } from "effect"
14
+ import { Match, Option, Predicate, Schema } from "effect"
15
+
16
+ const asJson = Schema.decodeUnknownOption(Schema.Json)
15
17
 
16
18
  // The inbound part variants we understand. Tool parts are identified
17
19
  // structurally by `toolCallId` - their wire `type` is a dynamic `tool-<name>`
@@ -97,8 +99,17 @@ const toolItems = (part: ToolPart): ReadonlyArray<Items.HistoryItem> => {
97
99
  arguments: JSON.stringify(part.input ?? {}),
98
100
  }
99
101
  if (part.state !== "output-available") return [call]
100
- const output = typeof part.output === "string" ? part.output : JSON.stringify(part.output)
101
- return [call, Items.toolCallOutput(part.toolCallId, output)]
102
+ // Text stays text; any other JSON stays a value (the provider codec serializes it).
103
+ const output: Items.ToolOutputContent = Match.value(part.output).pipe(
104
+ Match.when(Predicate.isString, (text) => Items.text(text)),
105
+ Match.orElse((value) =>
106
+ Option.match(asJson(value), {
107
+ onNone: () => Items.text(String(value)),
108
+ onSome: (json) => Items.json(json),
109
+ }),
110
+ ),
111
+ )
112
+ return [call, { type: "function_call_output", call_id: part.toolCallId, output: [output] }]
102
113
  }
103
114
 
104
115
  const assistantItems = (message: UIMessage): ReadonlyArray<Items.HistoryItem> => {
@@ -63,7 +63,7 @@ describe("toUIMessageStream", () => {
63
63
  Turn.TurnEvent.ToolCallStart({ call_id: "c1", name: "search" }),
64
64
  Turn.TurnEvent.ToolCallArgsDelta({ call_id: "c1", delta: `{"q":"lisbon"}` }),
65
65
  Turn.TurnEvent.TurnComplete({ turn: turn([call], "tool_calls") }),
66
- Items.toolCallOutput("c1", `{"hits":3}`),
66
+ { type: "function_call_output", call_id: "c1", output: [Items.json({ hits: 3 })] },
67
67
  ])
68
68
 
69
69
  expect(types(parts)).toEqual([
@@ -90,6 +90,39 @@ describe("toUIMessageStream", () => {
90
90
  }),
91
91
  )
92
92
 
93
+ it.effect("maps a failed call to tool-output-error and media to a data URL", () =>
94
+ Effect.gen(function* () {
95
+ const parts = yield* encode([
96
+ {
97
+ type: "function_call_output",
98
+ call_id: "c1",
99
+ output: [Items.json({ _tag: "CityNotFound", city: "Atlantis" })],
100
+ isError: true,
101
+ },
102
+ {
103
+ type: "function_call_output",
104
+ call_id: "c2",
105
+ output: [
106
+ Items.text("Home"),
107
+ Items.image({ _tag: "bytes", bytes: new Uint8Array([1, 2, 3]), mimeType: "image/png" }),
108
+ ],
109
+ },
110
+ ])
111
+ expect(parts.slice(1, 3)).toEqual([
112
+ {
113
+ type: "tool-output-error",
114
+ toolCallId: "c1",
115
+ errorText: '{"_tag":"CityNotFound","city":"Atlantis"}',
116
+ },
117
+ {
118
+ type: "tool-output-available",
119
+ toolCallId: "c2",
120
+ output: ["Home", { type: "image", url: "data:image/png;base64,AQID" }],
121
+ },
122
+ ])
123
+ }),
124
+ )
125
+
93
126
  it.effect("surfaces a refusal as an error part", () =>
94
127
  Effect.gen(function* () {
95
128
  const parts = yield* encode([Turn.TurnEvent.RefusalDelta({ text: "cannot help" })])
@@ -192,7 +225,8 @@ describe("decodeMessages", () => {
192
225
  expect(items).toEqual([
193
226
  Items.assistantText("Here"),
194
227
  { type: "function_call", call_id: "c1", name: "search", arguments: `{"q":"lisbon"}` },
195
- Items.toolCallOutput("c1", `{"hits":3}`),
228
+ // The output stays a value; the provider codec serializes it.
229
+ { type: "function_call_output", call_id: "c1", output: [Items.json({ hits: 3 })] },
196
230
  ])
197
231
  })
198
232
 
@@ -15,9 +15,14 @@
15
15
  * module synthesizes them: one block id per turn, opened on the first delta
16
16
  * and closed on `TurnComplete`.
17
17
  */
18
+ import type { FileSource } from "@effect-uai/core/File"
19
+ import type { ImageSource } from "@effect-uai/core/Image"
20
+ import type * as Items from "@effect-uai/core/Items"
18
21
  import type * as SSE from "@effect-uai/core/SSE"
22
+ import * as ToolOutput from "@effect-uai/core/ToolOutput"
19
23
  import * as Turn from "@effect-uai/core/Turn"
20
24
  import { Match, Option, Stream } from "effect"
25
+ import { Base64 } from "effect/encoding"
21
26
 
22
27
  /**
23
28
  * The stream parts `useChat` consumes. Names and shapes are the wire
@@ -48,6 +53,7 @@ export type Part =
48
53
  readonly toolCallId: string
49
54
  readonly output: unknown
50
55
  }
56
+ | { readonly type: "tool-output-error"; readonly toolCallId: string; readonly errorText: string }
51
57
  | {
52
58
  readonly type: `data-${string}`
53
59
  readonly id?: string
@@ -112,10 +118,45 @@ export const responseHeaders: Record<string, string> = {
112
118
 
113
119
  const parseJson = Option.liftThrowable(JSON.parse)
114
120
 
115
- // Tool arguments/output cross effect-uai as JSON strings; hand the client the
121
+ // Tool arguments cross effect-uai as a JSON string; hand the client the
116
122
  // decoded value, falling back to the raw string when it isn't JSON.
117
123
  const decode = (raw: string): unknown => Option.getOrElse(parseJson(raw), () => raw)
118
124
 
125
+ // Media goes to the client as a URL; inline bytes as a data URL.
126
+ const sourceUrl = Match.type<ImageSource | FileSource>().pipe(
127
+ Match.tagsExhaustive({
128
+ url: (s) => s.url,
129
+ base64: (s) => `data:${s.mimeType};base64,${s.base64}`,
130
+ bytes: (s) => `data:${s.mimeType};base64,${Base64.encode(s.bytes)}`,
131
+ }),
132
+ )
133
+
134
+ const partJson = Match.type<Items.ToolOutputContent>().pipe(
135
+ Match.discriminatorsExhaustive("type")({
136
+ input_text: (p): unknown => p.text,
137
+ input_json: (p) => p.value,
138
+ input_image: (p) => ({ type: "image", url: sourceUrl(p.source) }),
139
+ input_file: (p) => ({
140
+ type: "file",
141
+ url: sourceUrl(p.source),
142
+ ...(p.filename !== undefined && { filename: p.filename }),
143
+ }),
144
+ }),
145
+ )
146
+
147
+ const outputJson = (o: Items.ToolCallOutput): unknown => ToolOutput.single(o.output.map(partJson))
148
+
149
+ const toolOutputPart = (o: Items.ToolCallOutput): Part => {
150
+ const output = outputJson(o)
151
+ return o.isError === true
152
+ ? {
153
+ type: "tool-output-error",
154
+ toolCallId: o.call_id,
155
+ errorText: typeof output === "string" ? output : JSON.stringify(output),
156
+ }
157
+ : { type: "tool-output-available", toolCallId: o.call_id, output }
158
+ }
159
+
119
160
  const event = (part: Part | "[DONE]"): SSE.Event => ({
120
161
  data: part === "[DONE]" ? "[DONE]" : JSON.stringify(part),
121
162
  })
@@ -199,10 +240,7 @@ const step = (s: State, ev: Emission): Step =>
199
240
  }),
200
241
  // The non-`_tag` arm of InteractionEvent: a resolved tool result the loop
201
242
  // appended after running the tool.
202
- Match.when({ type: "function_call_output" }, (o): Step => [
203
- s,
204
- [{ type: "tool-output-available", toolCallId: o.call_id, output: decode(o.output) }],
205
- ]),
243
+ Match.when({ type: "function_call_output" }, (o): Step => [s, [toolOutputPart(o)]]),
206
244
  // Caller-interleaved emissions.
207
245
  Match.when({ kind: "data" }, (d): Step => [s, [toDataPart(d)]]),
208
246
  Match.when({ kind: "metadata" }, (m): Step => [
@@ -76,7 +76,7 @@ describe("AI SDK conformance (readUIMessageStream)", () => {
76
76
  Turn.TurnEvent.ToolCallStart({ call_id: "c1", name: "search" }),
77
77
  Turn.TurnEvent.ToolCallArgsDelta({ call_id: "c1", delta: `{"q":"lisbon"}` }),
78
78
  Turn.TurnEvent.TurnComplete({ turn: turn([call], "tool_calls") }),
79
- Items.toolCallOutput("c1", `{"hits":3}`),
79
+ { type: "function_call_output", call_id: "c1", output: [Items.json({ hits: 3 })] },
80
80
  ])
81
81
 
82
82
  const tool = Option.flatMap(message, (m) => Arr.findFirst(m.parts, isToolUIPart))