@opencode/ai 2.0.14 → 2.0.15

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.
Files changed (86) hide show
  1. package/README.md +115 -56
  2. package/dist/experimental/evaluation-client.d.ts +1 -1
  3. package/dist/experimental/evaluation-client.js +39 -3
  4. package/dist/experimental/evaluation.d.ts +4 -4
  5. package/dist/experimental/evaluation.js +2 -2
  6. package/dist/experimental/system-one.d.ts +3 -3
  7. package/dist/experimental/system-one.js +40 -51
  8. package/dist/generation.d.ts +69 -0
  9. package/dist/generation.js +84 -0
  10. package/dist/image-client.d.ts +6 -4
  11. package/dist/image-client.js +11 -11
  12. package/dist/image.d.ts +1040 -62
  13. package/dist/image.js +80 -52
  14. package/dist/index.d.ts +5 -2
  15. package/dist/index.js +3 -1
  16. package/dist/llm.d.ts +9 -1
  17. package/dist/media.d.ts +212 -0
  18. package/dist/media.js +228 -0
  19. package/dist/promise.d.ts +554 -0
  20. package/dist/promise.js +44 -0
  21. package/dist/protocols/anthropic-messages.js +7 -17
  22. package/dist/protocols/bedrock-converse.d.ts +4 -4
  23. package/dist/protocols/bedrock-converse.js +1 -6
  24. package/dist/protocols/gemini.d.ts +21 -0
  25. package/dist/protocols/gemini.js +42 -6
  26. package/dist/protocols/google-images.d.ts +9 -21
  27. package/dist/protocols/google-images.js +169 -132
  28. package/dist/protocols/meta-images.d.ts +7 -12
  29. package/dist/protocols/meta-images.js +92 -66
  30. package/dist/protocols/mistral-chat.js +7 -6
  31. package/dist/protocols/open-responses.d.ts +11 -3
  32. package/dist/protocols/open-responses.js +23 -12
  33. package/dist/protocols/openai-chat.d.ts +34 -1
  34. package/dist/protocols/openai-chat.js +100 -31
  35. package/dist/protocols/openai-images.d.ts +9 -20
  36. package/dist/protocols/openai-images.js +112 -142
  37. package/dist/protocols/shared.d.ts +17 -17
  38. package/dist/protocols/shared.js +32 -35
  39. package/dist/protocols/utils/bedrock-media.d.ts +2 -3
  40. package/dist/protocols/utils/bedrock-media.js +4 -4
  41. package/dist/protocols/utils/media-input.d.ts +10 -0
  42. package/dist/protocols/utils/media-input.js +17 -0
  43. package/dist/protocols/utils/responses-compaction.js +6 -5
  44. package/dist/protocols/utils/tool-stream.d.ts +27 -3
  45. package/dist/protocols/xai-images.d.ts +9 -15
  46. package/dist/protocols/xai-images.js +85 -83
  47. package/dist/protocols/zai-images.d.ts +9 -13
  48. package/dist/protocols/zai-images.js +59 -57
  49. package/dist/providers/amazon-bedrock.d.ts +2 -2
  50. package/dist/providers/cerebras.js +6 -1
  51. package/dist/providers/deepinfra.js +6 -1
  52. package/dist/providers/google-vertex.d.ts +7 -0
  53. package/dist/providers/google.d.ts +7 -0
  54. package/dist/providers/index.d.ts +1 -0
  55. package/dist/providers/index.js +1 -0
  56. package/dist/providers/openrouter.d.ts +19 -0
  57. package/dist/providers/openrouter.js +13 -1
  58. package/dist/providers/vercel-ai-gateway.d.ts +41 -0
  59. package/dist/providers/vercel-ai-gateway.js +85 -0
  60. package/dist/route/client.d.ts +9 -1
  61. package/dist/route/endpoint.d.ts +10 -10
  62. package/dist/route/executor-service.d.ts +12 -0
  63. package/dist/route/executor-service.js +3 -0
  64. package/dist/route/executor.d.ts +4 -9
  65. package/dist/route/executor.js +3 -3
  66. package/dist/route/index.d.ts +2 -0
  67. package/dist/route/index.js +2 -0
  68. package/dist/route/media-protocol.d.ts +45 -0
  69. package/dist/route/media-protocol.js +40 -0
  70. package/dist/route/media.d.ts +46 -0
  71. package/dist/route/media.js +64 -0
  72. package/dist/schema/errors.d.ts +13 -3
  73. package/dist/schema/errors.js +7 -0
  74. package/dist/schema/events.d.ts +563 -40
  75. package/dist/schema/events.js +35 -2
  76. package/dist/schema/messages.d.ts +98 -8
  77. package/dist/schema/messages.js +8 -6
  78. package/dist/schema/options.d.ts +2 -0
  79. package/dist/schema/options.js +3 -0
  80. package/dist/testing.d.ts +72 -8
  81. package/dist/utils/media-type.d.ts +6 -0
  82. package/dist/utils/media-type.js +49 -0
  83. package/dist/utils/sanitize.js +3 -1
  84. package/package.json +7 -3
  85. package/dist/protocols/utils/image-input.d.ts +0 -21
  86. package/dist/protocols/utils/image-input.js +0 -20
@@ -1,15 +1,20 @@
1
- import { Effect, Encoding, Schema } from "effect";
2
- import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
3
- import { ImageModel, GeneratedImage, ImageResponse, } from "../image.js";
4
- import { Auth } from "../route/auth.js";
5
- import { Usage, mergeHttpOptions, mergeJsonRecords } from "../schema/index.js";
1
+ import { Effect, Schema } from "effect";
2
+ import { ImageModel, ImageResponse } from "../image.js";
3
+ import { Media } from "../media.js";
4
+ import { MediaProtocol } from "../route/media-protocol.js";
5
+ import { MediaRoute } from "../route/media.js";
6
+ import { ProviderID, mergeJsonRecords } from "../schema/index.js";
6
7
  import { ProviderShared } from "./shared.js";
7
- import { ImageInputs } from "./utils/image-input.js";
8
- import { OpenAIImage } from "./utils/openai-image.js";
8
+ import { MediaInput } from "./utils/media-input.js";
9
9
  const ADAPTER = "openai-images";
10
+ const NAME = "OpenAI Images";
11
+ const PROVIDER = ProviderID.make("openai");
10
12
  export const DEFAULT_BASE_URL = "https://api.openai.com/v1";
11
13
  export const PATH = "/images/generations";
12
14
  export const EDIT_PATH = "/images/edits";
15
+ // ---------------------------------------------------------------------------
16
+ // 2. Response schema
17
+ // ---------------------------------------------------------------------------
13
18
  const OpenAIImageResponse = Schema.Struct({
14
19
  data: Schema.Array(Schema.Struct({
15
20
  b64_json: Schema.optional(Schema.String),
@@ -25,159 +30,124 @@ const OpenAIImageResponse = Schema.Struct({
25
30
  output_tokens_details: Schema.optional(Schema.Record(Schema.String, Schema.Unknown)),
26
31
  })),
27
32
  });
33
+ // ---------------------------------------------------------------------------
34
+ // 5. Request body construction
35
+ // ---------------------------------------------------------------------------
36
+ /** Multipart field names the route owns; `http.body` overlays cannot smuggle replacements for them. */
37
+ const RESERVED_FORM_FIELDS = new Set(["model", "prompt", "image", "image[]", "images", "mask"]);
28
38
  const nativeOptions = (options) => {
29
39
  if (!options)
30
40
  return undefined;
31
- const { mask: _, outputFormat, outputCompression, ...native } = options;
32
- return {
33
- output_format: outputFormat,
34
- output_compression: outputCompression,
35
- ...native,
36
- };
41
+ const { outputCompression, ...native } = options;
42
+ return { output_compression: outputCompression, ...native };
37
43
  };
38
- const applyQuery = (url, query) => {
39
- if (!query)
40
- return url;
41
- const next = new URL(url);
42
- Object.entries(query).forEach(([key, value]) => next.searchParams.set(key, value));
43
- return next.toString();
44
+ const isEdit = (request) => (request.images?.length ?? 0) > 0;
45
+ const isInline = (asset) => asset.inline() !== undefined;
46
+ const blob = (data, mediaType) => {
47
+ const buffer = new ArrayBuffer(data.byteLength);
48
+ new Uint8Array(buffer).set(data);
49
+ return new Blob([buffer], { type: mediaType });
44
50
  };
45
- export const model = (input) => {
46
- const route = {
47
- id: ADAPTER,
48
- generate: Effect.fn("OpenAIImages.generate")(function* (request, execute) {
49
- const mask = request.options?.mask;
50
- if (mask !== undefined && (request.images?.length ?? 0) === 0)
51
- return yield* ImageInputs.invalid("An OpenAI image mask requires at least one input image");
52
- const http = mergeHttpOptions(request.model.http, request.http);
53
- const sourceImages = request.images ?? [];
54
- const multipartImages = yield* Effect.forEach(sourceImages, (image) => {
55
- if (image.type === "bytes")
56
- return Effect.succeed({ data: image.data, mediaType: image.mediaType });
57
- if (image.type === "url")
58
- return ImageInputs.decodeDataUrl(image.url);
59
- return Effect.undefined;
60
- });
61
- const multipartMask = mask === undefined
62
- ? undefined
63
- : mask.type === "bytes"
64
- ? { data: mask.data, mediaType: mask.mediaType }
65
- : mask.type === "url"
66
- ? yield* ImageInputs.decodeDataUrl(mask.url)
67
- : undefined;
68
- const useMultipart = sourceImages.length > 0 &&
69
- multipartImages.every((image) => image !== undefined) &&
70
- (mask === undefined || multipartMask !== undefined);
71
- const path = sourceImages.length === 0 ? PATH : EDIT_PATH;
72
- const url = applyQuery(`${(input.baseURL ?? DEFAULT_BASE_URL).replace(/\/$/, "")}${path}`, http?.query);
73
- if (useMultipart) {
74
- const form = new FormData();
75
- form.append("model", request.model.id);
76
- form.append("prompt", request.prompt);
77
- Object.entries(mergeJsonRecords(nativeOptions(request.options), http?.body) ?? {}).forEach(([key, value]) => {
78
- if (["model", "prompt", "image", "image[]", "images", "mask"].includes(key))
79
- return;
80
- form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value));
81
- });
82
- multipartImages.forEach((image, index) => {
83
- if (image === undefined)
84
- return;
85
- form.append("image[]", imageBlob(image.data, image.mediaType), `image-${index}`);
86
- });
87
- if (multipartMask !== undefined)
88
- form.append("mask", imageBlob(multipartMask.data, multipartMask.mediaType), "mask");
89
- const headers = yield* Auth.toEffect(input.auth)({
90
- request,
91
- method: "POST",
92
- url,
93
- body: "[multipart/form-data]",
94
- headers: Headers.remove(Headers.fromInput({ ...input.headers, ...http?.headers }), "content-type"),
95
- });
96
- const response = yield* execute(HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyFormData(form)));
97
- return yield* parseResponse(response, request.options, http?.body);
98
- }
99
- const references = sourceImages.map((image) => {
100
- if (image.type === "bytes")
101
- return { image_url: ImageInputs.dataUrl(image) };
102
- if (image.type === "url")
103
- return { image_url: image.url };
104
- if (image.type === "file-id")
105
- return { file_id: image.id };
106
- return undefined;
107
- });
108
- if (references.some((image) => image === undefined))
109
- return yield* ImageInputs.invalid("OpenAI Images accepts image URLs, data URLs, bytes, and file IDs");
110
- const maskReference = mask === undefined
111
- ? undefined
112
- : mask.type === "bytes"
113
- ? { image_url: ImageInputs.dataUrl(mask) }
114
- : mask.type === "url"
115
- ? { image_url: mask.url }
116
- : mask.type === "file-id"
117
- ? { file_id: mask.id }
118
- : undefined;
119
- if (mask !== undefined && maskReference === undefined)
120
- return yield* ImageInputs.invalid("OpenAI Images accepts masks as URLs, data URLs, bytes, or file IDs");
121
- const requestBody = mergeJsonRecords({
122
- model: request.model.id,
123
- prompt: request.prompt,
124
- images: references.length === 0 ? undefined : references,
125
- mask: maskReference,
126
- }, nativeOptions(request.options), http?.body);
127
- const text = ProviderShared.encodeJson(requestBody);
128
- const headers = yield* Auth.toEffect(input.auth)({
129
- request,
130
- method: "POST",
131
- url,
132
- body: text,
133
- headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
134
- });
135
- const response = yield* execute(HttpClientRequest.post(url).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyText(text, "application/json")));
136
- return yield* parseResponse(response, request.options, http?.body);
137
- }),
138
- };
139
- return ImageModel.make({ id: input.id, provider: "openai", route, http: input.http });
51
+ const reference = (asset) => {
52
+ const inline = asset.inline();
53
+ if (inline)
54
+ return Effect.succeed({ image_url: inline.dataUrl });
55
+ const url = ProviderShared.mediaUrl(asset);
56
+ if (url)
57
+ return Effect.succeed({ image_url: url });
58
+ const id = MediaInput.refID(asset, PROVIDER);
59
+ if (id)
60
+ return Effect.succeed({ file_id: id });
61
+ return Effect.fail(ProviderShared.invalidRequest("OpenAI Images accepts image URLs, data URLs, bytes, and OpenAI file IDs"));
62
+ };
63
+ const fromRequest = Effect.fn("OpenAIImages.fromRequest")(function* (request) {
64
+ const images = request.images ?? [];
65
+ const mask = request.mask;
66
+ if (mask !== undefined && images.length === 0)
67
+ return yield* ProviderShared.invalidRequest("An OpenAI image mask requires at least one input image");
68
+ const fields = mergeJsonRecords({ n: request.n, size: request.size, output_format: request.format }, nativeOptions(request.providerOptions), request.http?.body);
69
+ // Owned bytes go through multipart edits; remote URLs and file IDs use the JSON edits body instead.
70
+ if (images.length > 0 && images.every(isInline) && (mask === undefined || isInline(mask))) {
71
+ const form = new FormData();
72
+ form.append("model", request.model.id);
73
+ form.append("prompt", request.prompt);
74
+ Object.entries(fields ?? {}).forEach(([key, value]) => {
75
+ if (RESERVED_FORM_FIELDS.has(key))
76
+ return;
77
+ form.append(key, typeof value === "string" ? value : ProviderShared.encodeJson(value));
78
+ });
79
+ const uploads = yield* Effect.forEach(images, (image) => MediaInput.inlineBytes(ADAPTER, image));
80
+ uploads.forEach((data, index) => form.append("image[]", blob(data, images[index].mediaType), `image-${index}`));
81
+ if (mask !== undefined)
82
+ form.append("mask", blob(yield* MediaInput.inlineBytes(ADAPTER, mask), mask.mediaType), "mask");
83
+ return MediaProtocol.multipart(form);
84
+ }
85
+ const references = yield* Effect.forEach(images, reference);
86
+ const maskReference = mask === undefined ? undefined : yield* reference(mask);
87
+ return MediaProtocol.json(mergeJsonRecords({
88
+ model: request.model.id,
89
+ prompt: request.prompt,
90
+ images: references.length === 0 ? undefined : references,
91
+ mask: maskReference,
92
+ }, fields) ?? {});
93
+ });
94
+ // ---------------------------------------------------------------------------
95
+ // 6. Response decoding
96
+ // ---------------------------------------------------------------------------
97
+ const requestedFormat = (body) => {
98
+ const value = body.type === "json" ? body.value.output_format : body.value.get("output_format");
99
+ return typeof value === "string" ? value : undefined;
140
100
  };
141
- const parseResponse = Effect.fn("OpenAIImages.parseResponse")(function* (response, options, overlay) {
142
- const output = yield* ProviderShared.imageResponse(ADAPTER, "OpenAI Images", response);
143
- const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(OpenAIImageResponse))(output.body).pipe(Effect.mapError((cause) => output.invalid("OpenAI Images returned an invalid response", cause)));
144
- const requestBody = mergeJsonRecords(nativeOptions(options), overlay);
145
- const format = decoded.output_format ?? (typeof requestBody?.output_format === "string" ? requestBody.output_format : "png");
101
+ const decodeResponse = Effect.fn("OpenAIImages.decodeResponse")(function* (response, context) {
102
+ const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, OpenAIImageResponse)(response);
103
+ const decoded = output.value;
104
+ const format = decoded.output_format ?? requestedFormat(context.body) ?? "png";
105
+ const mediaType = `image/${format}`;
146
106
  const images = yield* Effect.forEach(decoded.data, (item, index) => {
107
+ const providerMetadata = item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } };
147
108
  if (item.b64_json)
148
- return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(Effect.mapError((cause) => output.invalid(`OpenAI Images result ${index} contains invalid base64 data`, cause)), Effect.map((data) => new GeneratedImage({
149
- mediaType: `image/${format}`,
150
- data,
151
- providerMetadata: item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
152
- })));
109
+ return MediaInput.decodedAsset(output.invalid, `${NAME} result ${index}`, item.b64_json, mediaType, {
110
+ info: { format },
111
+ providerMetadata,
112
+ });
153
113
  if (item.url)
154
- return Effect.succeed(new GeneratedImage({
155
- mediaType: `image/${format}`,
156
- data: item.url,
157
- providerMetadata: item.revised_prompt === undefined ? undefined : { openai: { revisedPrompt: item.revised_prompt } },
158
- }));
159
- return Effect.fail(output.invalid(`OpenAI Images result ${index} has neither image data nor a URL`));
114
+ return Effect.succeed(Media.url(item.url, { mediaType, info: { format }, providerMetadata }));
115
+ return Effect.fail(output.invalid(`${NAME} result ${index} has neither image data nor a URL`));
160
116
  });
161
117
  if (images.length === 0)
162
- return yield* output.invalid("OpenAI Images returned no images");
118
+ return yield* output.invalid(`${NAME} returned no images`);
163
119
  return new ImageResponse({
164
120
  images,
165
121
  usage: decoded.usage === undefined
166
122
  ? undefined
167
- : new Usage({
168
- inputTokens: decoded.usage.input_tokens,
169
- outputTokens: decoded.usage.output_tokens,
170
- totalTokens: decoded.usage.total_tokens,
171
- providerMetadata: { openai: decoded.usage },
172
- }),
123
+ : {
124
+ type: "tokens",
125
+ input: decoded.usage.input_tokens,
126
+ output: decoded.usage.output_tokens,
127
+ total: decoded.usage.total_tokens,
128
+ details: { openai: decoded.usage },
129
+ },
173
130
  providerMetadata: { openai: { outputFormat: format } },
174
131
  });
175
132
  });
176
- const imageBlob = (data, mediaType) => {
177
- const buffer = new ArrayBuffer(data.byteLength);
178
- new Uint8Array(buffer).set(data);
179
- return new Blob([buffer], { type: mediaType });
180
- };
133
+ // ---------------------------------------------------------------------------
134
+ // 7. Protocol and route
135
+ // ---------------------------------------------------------------------------
136
+ export const protocol = MediaProtocol.inline({
137
+ id: ADAPTER,
138
+ name: NAME,
139
+ unsupported: ["aspectRatio", "seed"],
140
+ body: { from: fromRequest },
141
+ response: { decode: decodeResponse },
142
+ });
143
+ export const model = (input) => ImageModel.fromRoute({
144
+ id: ADAPTER,
145
+ provider: PROVIDER,
146
+ protocol,
147
+ baseURL: DEFAULT_BASE_URL,
148
+ path: ({ request }) => (isEdit(request) ? EDIT_PATH : PATH),
149
+ }, input);
181
150
  export const OpenAIImages = {
151
+ protocol,
182
152
  model,
183
153
  };
@@ -1,6 +1,7 @@
1
1
  import { Tool } from "@opencode/schema/tool";
2
2
  import { Effect, Schema, Stream } from "effect";
3
- import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
3
+ import { Headers, HttpClientRequest } from "effect/unstable/http";
4
+ import { Media } from "../media.js";
4
5
  import { AIError, LLMRequest, Message, ToolDefinition, type ContentPart, type MediaPart, type ProviderID, type ToolEntry, type ToolResultPart } from "../schema/index.js";
5
6
  import { isRecord } from "../utils/record.js";
6
7
  export { isRecord };
@@ -110,23 +111,26 @@ export declare const wrappedSystemUpdate: (route: string, message: Message) => E
110
111
  * routes: `Invalid JSON input for <route> tool call <name>`.
111
112
  */
112
113
  export declare const parseToolInput: (route: string, name: string, raw: string) => Effect.Effect<unknown, AIError, never>;
113
- export interface NormalizedMedia {
114
- readonly mime: string;
115
- readonly base64: string;
116
- readonly dataUrl: string;
117
- }
118
- export declare const normalizeMedia: (part: MediaPart) => NormalizedMedia;
119
- export declare const normalizeToolFile: (part: Tool.FileContent) => NormalizedMedia;
114
+ /** Inline view or a typed `InvalidRequest` for routes that cannot fetch URLs or dereference provider refs. */
115
+ export declare const requireInlineMedia: (route: string, asset: Media.Asset) => Effect.Effect<Media.Inline, AIError>;
116
+ export declare const inlineRequired: (route: string, asset: Media.Asset) => AIError;
117
+ /** The remote URL of a `url` asset, for protocols that accept `http(s)` references natively. */
118
+ export declare const mediaUrl: (asset: Media.Asset) => string | undefined;
119
+ /**
120
+ * Lift a tool-result file into a `MediaPart`. Tool files carry either a data URL, an `http(s)` URL, or raw base64 in
121
+ * `uri`; the declared `mime` wins over any data-URL prefix so tool authors control the type the model sees.
122
+ */
123
+ export declare const toolFileMedia: (item: Tool.FileContent) => MediaPart;
120
124
  export declare const trimBaseUrl: (value: string) => string;
121
125
  export declare const toolResultText: (part: ToolResultPart) => string;
122
126
  export declare const errorText: (error: unknown) => string;
123
127
  /**
124
128
  * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
125
- * decoder, optionally filters named events, and drops empty events. `[DONE]`
126
- * is dropped by default or retained for protocols that use it as their stream
127
- * boundary. Retry control events are ignored without interrupting the stream.
128
- * Decoder failures become provider output errors so the public error channel
129
- * stays `AIError`.
129
+ * decoder, optionally filters named events, and drops empty and bare `null`
130
+ * events. `[DONE]` is dropped by default or retained for protocols that use it
131
+ * as their stream boundary. Retry control events are ignored without
132
+ * interrupting the stream. Decoder failures become provider output errors so
133
+ * the public error channel stays `AIError`.
130
134
  */
131
135
  export declare const sseFraming: (bytes: Stream.Stream<Uint8Array, AIError>, events?: ReadonlySet<string>, includeDone?: boolean) => Stream.Stream<string, AIError>;
132
136
  /**
@@ -158,10 +162,6 @@ export declare const flattenToolRequest: (request: LLMRequest) => {
158
162
  readonly [x: string]: unknown;
159
163
  }, import("../route.js").CompactionOperations | undefined>>;
160
164
  };
161
- export declare const imageResponse: (route: string, name: string, response: HttpClientResponse.HttpClientResponse) => Effect.Effect<{
162
- body: string;
163
- invalid: (message: string, cause?: unknown) => AIError;
164
- }, AIError, never>;
165
165
  export declare const matchToolChoice: <Auto, None, Required, Tool>(route: string, toolChoice: NonNullable<LLMRequest["toolChoice"]>, cases: {
166
166
  readonly auto: () => Auto;
167
167
  readonly none: () => None;
@@ -1,9 +1,9 @@
1
- import { Buffer } from "node:buffer";
2
1
  import { Tool } from "@opencode/schema/tool";
3
2
  import { Effect, Option, Schema, Stream } from "effect";
4
3
  import * as Sse from "effect/unstable/encoding/Sse";
5
- import { Headers, HttpClientRequest, HttpClientResponse } from "effect/unstable/http";
6
- import { InvalidProviderOutputError, InvalidRequestError, UnsupportedOperationError, AIError, HttpContext, LLMRequest, Message, ToolDefinition, } from "../schema/index.js";
4
+ import { Headers, HttpClientRequest } from "effect/unstable/http";
5
+ import { Media } from "../media.js";
6
+ import { InvalidProviderOutputError, InvalidRequestError, UnsupportedOperationError, AIError, LLMRequest, Message, ToolDefinition, } from "../schema/index.js";
7
7
  import { isRecord } from "../utils/record.js";
8
8
  export { isRecord };
9
9
  export const Json = Schema.fromJsonString(Schema.Unknown);
@@ -126,17 +126,27 @@ export const wrappedSystemUpdate = Effect.fn("ProviderShared.wrappedSystemUpdate
126
126
  * routes: `Invalid JSON input for <route> tool call <name>`.
127
127
  */
128
128
  export const parseToolInput = (route, name, raw) => parseJson(route, raw || "{}", `Invalid JSON input for ${route} tool call ${name}`);
129
- export const normalizeMedia = (part) => {
130
- const mime = part.mediaType.toLowerCase();
131
- if (typeof part.data !== "string") {
132
- const base64 = Buffer.from(part.data).toString("base64");
133
- return { mime, base64, dataUrl: `data:${mime};base64,${base64}` };
134
- }
135
- if (!part.data.startsWith("data:"))
136
- return { mime, base64: part.data, dataUrl: `data:${mime};base64,${part.data}` };
137
- return { mime, base64: part.data.slice(part.data.indexOf(",") + 1), dataUrl: part.data };
129
+ /** Inline view or a typed `InvalidRequest` for routes that cannot fetch URLs or dereference provider refs. */
130
+ export const requireInlineMedia = (route, asset) => {
131
+ const inline = asset.inline();
132
+ return inline ? Effect.succeed(inline) : Effect.fail(inlineRequired(route, asset));
133
+ };
134
+ export const inlineRequired = (route, asset) => invalidRequest(`${route} requires inline media (bytes or base64); ${asset.source.type} sources must be materialized first`);
135
+ /** The remote URL of a `url` asset, for protocols that accept `http(s)` references natively. */
136
+ export const mediaUrl = (asset) => (asset.source.type === "url" ? asset.source.url : undefined);
137
+ /**
138
+ * Lift a tool-result file into a `MediaPart`. Tool files carry either a data URL, an `http(s)` URL, or raw base64 in
139
+ * `uri`; the declared `mime` wins over any data-URL prefix so tool authors control the type the model sees.
140
+ */
141
+ export const toolFileMedia = (item) => {
142
+ const parsed = Media.parseDataUrl(item.uri);
143
+ const asset = parsed
144
+ ? Media.from({ ...parsed.source, mediaType: item.mime })
145
+ : /^https?:\/\//.test(item.uri)
146
+ ? Media.url(item.uri, { mediaType: item.mime })
147
+ : Media.base64(item.uri, item.mime);
148
+ return Message.media(asset, { filename: item.name });
138
149
  };
139
- export const normalizeToolFile = (part) => normalizeMedia({ type: "media", mediaType: part.mime, data: part.uri, filename: part.name });
140
150
  export const trimBaseUrl = (value) => value.replace(/\/+$/, "");
141
151
  export const toolResultText = (part) => {
142
152
  if (part.result.type === "text")
@@ -164,11 +174,11 @@ export const errorText = (error) => {
164
174
  };
165
175
  /**
166
176
  * `framing` step for Server-Sent Events. Decodes UTF-8, runs the SSE channel
167
- * decoder, optionally filters named events, and drops empty events. `[DONE]`
168
- * is dropped by default or retained for protocols that use it as their stream
169
- * boundary. Retry control events are ignored without interrupting the stream.
170
- * Decoder failures become provider output errors so the public error channel
171
- * stays `AIError`.
177
+ * decoder, optionally filters named events, and drops empty and bare `null`
178
+ * events. `[DONE]` is dropped by default or retained for protocols that use it
179
+ * as their stream boundary. Retry control events are ignored without
180
+ * interrupting the stream. Decoder failures become provider output errors so
181
+ * the public error channel stays `AIError`.
172
182
  */
173
183
  export const sseFraming = (bytes, events, includeDone = false) => bytes.pipe(Stream.decodeText(), Stream.mapAccumEffect(() => {
174
184
  const output = [];
@@ -186,6 +196,10 @@ export const sseFraming = (bytes, events, includeDone = false) => bytes.pipe(Str
186
196
  return [state, state.output.splice(0)];
187
197
  })), Stream.filter((event) => (events === undefined || events.has(event.event)) &&
188
198
  event.data.length > 0 &&
199
+ // Some OpenAI-compatible proxies serialize an empty flush as a bare
200
+ // `data: null`, between events or after `[DONE]`. No protocol has a
201
+ // null event, so it carries nothing and must not abort the stream.
202
+ event.data !== "null" &&
189
203
  (event.data !== "[DONE]" || includeDone || (events !== undefined && event.event !== "message"))), Stream.map((event) => event.data));
190
204
  /**
191
205
  * Canonical invalid-request constructor shared by protocol lowering.
@@ -241,23 +255,6 @@ export const flattenToolRequest = (request) => {
241
255
  : LLMRequest.update(request, { messages }),
242
256
  };
243
257
  };
244
- export const imageResponse = Effect.fn("ProviderShared.imageResponse")(function* (route, name, response) {
245
- const http = new HttpContext({ url: response.request.url, status: response.status, headers: response.headers });
246
- const body = yield* response.text.pipe(Effect.mapError((cause) => new AIError({
247
- reason: new InvalidProviderOutputError({
248
- route,
249
- message: `Failed to read the ${name} response`,
250
- http,
251
- cause,
252
- }),
253
- })));
254
- return {
255
- body,
256
- invalid: (message, cause) => new AIError({
257
- reason: new InvalidProviderOutputError({ route, message, body, http, cause }),
258
- }),
259
- };
260
- });
261
258
  export const matchToolChoice = (route, toolChoice, cases) => Effect.gen(function* () {
262
259
  if (toolChoice.type === "auto")
263
260
  return cases.auto();
@@ -23,9 +23,8 @@ export declare const DocumentBlock: Schema.Struct<{
23
23
  }>;
24
24
  export type DocumentBlock = Schema.Schema.Type<typeof DocumentBlock>;
25
25
  export declare const lower: (part: {
26
- readonly data: string | Uint8Array<ArrayBufferLike>;
27
26
  readonly type: "media";
28
- readonly mediaType: string;
27
+ readonly media: import("../../media.js").Asset;
29
28
  readonly metadata?: {
30
29
  readonly [x: string]: unknown;
31
30
  } | undefined;
@@ -45,7 +44,7 @@ export declare const lower: (part: {
45
44
  };
46
45
  }[] | ({
47
46
  readonly document: {
48
- readonly format: "pdf" | "csv" | "doc" | "docx" | "xls" | "xlsx" | "html" | "txt" | "md";
47
+ readonly format: "pdf" | "txt" | "md" | "csv" | "doc" | "docx" | "xls" | "xlsx" | "html";
49
48
  readonly name: string;
50
49
  readonly source: {
51
50
  readonly bytes: string;
@@ -62,7 +62,7 @@ function documentName(filename, names) {
62
62
  return name;
63
63
  }
64
64
  const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part) {
65
- const media = ProviderShared.normalizeMedia(part);
65
+ const media = yield* ProviderShared.requireInlineMedia("Bedrock Converse", part.media);
66
66
  const bytes = yield* Effect.fromResult(Encoding.decodeBase64(media.base64)).pipe(Effect.mapError((cause) => ProviderShared.invalidRequest("Bedrock Converse media data must be valid base64", cause)));
67
67
  return Encoding.encodeBase64(bytes);
68
68
  });
@@ -72,13 +72,13 @@ const mediaBase64 = Effect.fn("BedrockMedia.mediaBase64")(function* (part) {
72
72
  // get an image-specific error so the caller knows it's a format-support issue,
73
73
  // not a kind-detection issue.
74
74
  export const lower = Effect.fn("BedrockMedia.lower")(function* (part, documentNames) {
75
- const mime = part.mediaType.toLowerCase();
75
+ const mime = part.media.mediaType.toLowerCase();
76
76
  const imageFormat = IMAGE_FORMATS[mime];
77
77
  if (imageFormat) {
78
78
  return [{ image: { format: imageFormat, source: { bytes: yield* mediaBase64(part) } } }];
79
79
  }
80
80
  if (mime.startsWith("image/"))
81
- return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.mediaType}`);
81
+ return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support image media type ${part.media.mediaType}`);
82
82
  const documentFormat = DOCUMENT_FORMATS[mime];
83
83
  if (documentFormat) {
84
84
  const name = documentName(part.filename, documentNames);
@@ -92,6 +92,6 @@ export const lower = Effect.fn("BedrockMedia.lower")(function* (part, documentNa
92
92
  ]
93
93
  : [block];
94
94
  }
95
- return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.mediaType}`);
95
+ return yield* ProviderShared.invalidRequest(`Bedrock Converse does not support media type ${part.media.mediaType}`);
96
96
  });
97
97
  export * as BedrockMedia from "./bedrock-media.js";
@@ -0,0 +1,10 @@
1
+ import { Effect } from "effect";
2
+ import { Media } from "../../media.js";
3
+ import type { AIError, ProviderID } from "../../schema/index.js";
4
+ /** Owned bytes for multipart uploads; decodes `base64` sources and rejects remote sources. */
5
+ export declare const inlineBytes: (route: string, asset: Media.Asset) => Effect.Effect<Uint8Array, AIError>;
6
+ /** Provider file handle when the ref belongs to this provider; refs from other providers are never forwarded. */
7
+ export declare const refID: (asset: Media.Asset, provider: ProviderID) => string | undefined;
8
+ /** Decode a provider's base64 output once into an owned `bytes` asset, sniffing the type when it is not declared. */
9
+ export declare const decodedAsset: (invalid: (message: string, cause?: unknown) => AIError, label: string, data: string, mediaType: string | undefined, options?: Media.AssetOptions) => Effect.Effect<Media.Asset, AIError, never>;
10
+ export * as MediaInput from "./media-input.js";
@@ -0,0 +1,17 @@
1
+ import { Effect, Encoding } from "effect";
2
+ import { Media } from "../../media.js";
3
+ import { ProviderShared } from "../shared.js";
4
+ /** Owned bytes for multipart uploads; decodes `base64` sources and rejects remote sources. */
5
+ export const inlineBytes = (route, asset) => {
6
+ if (asset.source.type === "bytes")
7
+ return Effect.succeed(asset.source.data);
8
+ const inline = asset.inline();
9
+ if (!inline)
10
+ return Effect.fail(ProviderShared.inlineRequired(route, asset));
11
+ return Effect.fromResult(Encoding.decodeBase64(inline.base64)).pipe(Effect.mapError((cause) => ProviderShared.invalidRequest(`${route} media contains invalid base64 data`, cause)));
12
+ };
13
+ /** Provider file handle when the ref belongs to this provider; refs from other providers are never forwarded. */
14
+ export const refID = (asset, provider) => asset.source.type === "ref" && asset.source.provider === provider ? asset.source.id : undefined;
15
+ /** Decode a provider's base64 output once into an owned `bytes` asset, sniffing the type when it is not declared. */
16
+ export const decodedAsset = (invalid, label, data, mediaType, options) => Effect.fromResult(Encoding.decodeBase64(data)).pipe(Effect.mapError((cause) => invalid(`${label} contains invalid base64 data`, cause)), Effect.map((bytes) => Media.bytes(bytes, mediaType, options)));
17
+ export * as MediaInput from "./media-input.js";
@@ -6,6 +6,7 @@ import { RequestExecutor } from "../../route/executor.js";
6
6
  import { HttpTransport } from "../../route/transport/index.js";
7
7
  import { OpenResponses } from "../open-responses.js";
8
8
  import { JsonObject, optionalNull, ProviderShared } from "../shared.js";
9
+ import { Media } from "../../media.js";
9
10
  const Body = Schema.Struct({
10
11
  model: Schema.String,
11
12
  input: Schema.Array(Schema.Unknown),
@@ -113,19 +114,19 @@ function toMessage(item, model) {
113
114
  if (part.type === "input_image")
114
115
  return {
115
116
  type: "media",
116
- data: part.image_url,
117
- mediaType: /^data:([^;,]+)/.exec(part.image_url)?.[1] ?? "image/*",
117
+ media: replayMedia(part.image_url, "image/*"),
118
118
  providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
119
119
  };
120
- const data = part.file_url === undefined ? part.file_data : part.file_url;
121
120
  return {
122
121
  type: "media",
123
- data,
122
+ media: replayMedia(part.file_url === undefined ? part.file_data : part.file_url, "application/octet-stream"),
124
123
  filename: part.filename,
125
- mediaType: /^data:([^;,]+)/.exec(data)?.[1] ?? "application/octet-stream",
126
124
  providerMetadata: part.detail === undefined ? undefined : { [key]: { detail: part.detail } },
127
125
  };
128
126
  }),
129
127
  });
130
128
  }
129
+ /** Replayed compaction items carry either a data URL or a remote URL; the data URL's own type wins when present. */
130
+ const replayMedia = (value, fallbackType) => Media.parseDataUrl(value) ??
131
+ (/^https?:\/\//.test(value) ? Media.url(value, { mediaType: fallbackType }) : Media.base64(value, fallbackType));
131
132
  export * as ResponsesCompaction from "./responses-compaction.js";