@opencode/ai 2.0.13 → 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,23 @@
1
- import { Effect, Encoding, Schema } from "effect";
2
- import { Headers, HttpClientRequest } from "effect/unstable/http";
3
- import { GeneratedImage, ImageModel, 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 { JsonObject, ProviderShared, optionalNull } from "./shared.js";
7
- import { ImageInputs } from "./utils/image-input.js";
8
+ import { MediaInput } from "./utils/media-input.js";
9
+ const ADAPTER = "meta-images";
10
+ const NAME = "Meta Images";
11
+ const PROVIDER = ProviderID.make("meta");
12
+ // ---------------------------------------------------------------------------
13
+ // 2. Request body and response schemas
14
+ // ---------------------------------------------------------------------------
8
15
  const Body = Schema.StructWithRest(Schema.Struct({
9
16
  model: Schema.String,
10
17
  prompt: Schema.String,
11
18
  images: Schema.optional(Schema.Array(JsonObject)),
12
19
  n: Schema.optional(Schema.Number),
20
+ /** Aspect ratio hint, not an exact output resolution. */
13
21
  size: Schema.optional(Schema.String),
14
22
  output_format: Schema.optional(Schema.String),
15
23
  response_format: Schema.optional(Schema.String),
@@ -25,65 +33,83 @@ const Response = Schema.Struct({
25
33
  total_tokens: Schema.optional(Schema.Number),
26
34
  })),
27
35
  });
28
- export const model = (input) => {
29
- const route = {
30
- id: "meta-images",
31
- generate: Effect.fn("MetaImages.generate")(function* (request, execute) {
32
- const http = mergeHttpOptions(request.model.http, request.http);
33
- const images = yield* Effect.forEach(request.images ?? [], (image) => {
34
- if (image.type === "bytes")
35
- return Effect.succeed({ image_url: ImageInputs.dataUrl(image) });
36
- if (image.type === "url")
37
- return Effect.succeed({ image_url: image.url });
38
- return ImageInputs.invalid("Meta Images accepts image bytes and URLs");
39
- });
40
- const { outputFormat, responseFormat, reasoningStrength, toolEnablement, ...native } = request.options ?? {};
41
- const payload = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(mergeJsonRecords({
42
- model: request.model.id,
43
- prompt: request.prompt,
44
- images: images.length === 0 ? undefined : images,
45
- output_format: outputFormat,
46
- response_format: responseFormat,
47
- reasoning_strength: reasoningStrength,
48
- tool_enablement: toolEnablement,
49
- }, native, http?.body));
50
- const body = ProviderShared.encodeJson(payload);
51
- const url = new URL(`${input.baseURL.replace(/\/$/, "")}/images/${images.length === 0 ? "generations" : "edits"}`);
52
- Object.entries(http?.query ?? {}).forEach(([key, value]) => url.searchParams.set(key, value));
53
- const headers = yield* Auth.toEffect(input.auth)({
54
- request,
55
- method: "POST",
56
- url: url.toString(),
57
- body,
58
- headers: Headers.fromInput({ ...input.headers, ...http?.headers }),
59
- });
60
- const response = yield* execute(HttpClientRequest.post(url.toString()).pipe(HttpClientRequest.setHeaders(headers), HttpClientRequest.bodyText(body, "application/json")));
61
- const output = yield* ProviderShared.imageResponse("meta-images", "Meta Images", response);
62
- const decoded = yield* Schema.decodeUnknownEffect(Schema.fromJsonString(Response))(output.body).pipe(Effect.mapError((cause) => output.invalid("Meta Images returned an invalid response", cause)));
63
- const format = decoded.output_format ?? payload.output_format ?? "webp";
64
- const generated = yield* Effect.forEach(decoded.data, (item, index) => {
65
- if (item.b64_json)
66
- return Effect.fromResult(Encoding.decodeBase64(item.b64_json)).pipe(Effect.mapError((cause) => output.invalid(`Meta Images result ${index} contains invalid base64`, cause)), Effect.map((data) => new GeneratedImage({ mediaType: `image/${format}`, data })));
67
- if (item.url)
68
- return Effect.succeed(new GeneratedImage({ mediaType: `image/${format}`, data: item.url }));
69
- return output.invalid(`Meta Images result ${index} has neither image data nor a URL`);
70
- });
71
- if (generated.length === 0)
72
- return yield* output.invalid("Meta Images returned no images");
73
- return new ImageResponse({
74
- images: generated,
75
- usage: decoded.usage === undefined
76
- ? undefined
77
- : new Usage({
78
- inputTokens: decoded.usage.input_tokens,
79
- outputTokens: decoded.usage.output_tokens,
80
- totalTokens: decoded.usage.total_tokens,
81
- providerMetadata: { meta: decoded.usage },
82
- }),
83
- providerMetadata: { meta: { outputFormat: format } },
84
- });
85
- }),
86
- };
87
- return ImageModel.make({ id: input.id, provider: "meta", route, http: input.http });
36
+ // ---------------------------------------------------------------------------
37
+ // 5. Request body construction
38
+ // ---------------------------------------------------------------------------
39
+ const isEdit = (request) => (request.images?.length ?? 0) > 0;
40
+ const reference = (asset) => {
41
+ const inline = asset.inline();
42
+ if (inline)
43
+ return Effect.succeed({ image_url: inline.dataUrl });
44
+ const url = ProviderShared.mediaUrl(asset);
45
+ if (url)
46
+ return Effect.succeed({ image_url: url });
47
+ return Effect.fail(ProviderShared.invalidRequest(`${NAME} accepts image bytes and URLs`));
88
48
  };
49
+ const fromRequest = Effect.fn("MetaImages.fromRequest")(function* (request) {
50
+ const images = yield* Effect.forEach(request.images ?? [], reference);
51
+ const { responseFormat, reasoningStrength, toolEnablement, ...native } = request.providerOptions ?? {};
52
+ const payload = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(Body))(mergeJsonRecords({
53
+ model: request.model.id,
54
+ prompt: request.prompt,
55
+ images: images.length === 0 ? undefined : images,
56
+ n: request.n,
57
+ size: request.size,
58
+ output_format: request.format,
59
+ response_format: responseFormat,
60
+ reasoning_strength: reasoningStrength,
61
+ tool_enablement: toolEnablement,
62
+ }, native, request.http?.body));
63
+ return MediaProtocol.json(payload);
64
+ });
65
+ // ---------------------------------------------------------------------------
66
+ // 6. Response decoding
67
+ // ---------------------------------------------------------------------------
68
+ const decodeResponse = Effect.fn("MetaImages.decodeResponse")(function* (response, context) {
69
+ const output = yield* MediaProtocol.decodeJson(ADAPTER, NAME, Response)(response);
70
+ const decoded = output.value;
71
+ const requested = context.body.type === "json" ? context.body.value.output_format : undefined;
72
+ const format = decoded.output_format ?? (typeof requested === "string" ? requested : "webp");
73
+ const mediaType = `image/${format}`;
74
+ const images = yield* Effect.forEach(decoded.data, (item, index) => {
75
+ if (item.b64_json)
76
+ return MediaInput.decodedAsset(output.invalid, `${NAME} result ${index}`, item.b64_json, mediaType, {
77
+ info: { format },
78
+ });
79
+ if (item.url)
80
+ return Effect.succeed(Media.url(item.url, { mediaType, info: { format } }));
81
+ return Effect.fail(output.invalid(`${NAME} result ${index} has neither image data nor a URL`));
82
+ });
83
+ if (images.length === 0)
84
+ return yield* output.invalid(`${NAME} returned no images`);
85
+ return new ImageResponse({
86
+ images,
87
+ usage: decoded.usage === undefined
88
+ ? undefined
89
+ : {
90
+ type: "tokens",
91
+ input: decoded.usage.input_tokens,
92
+ output: decoded.usage.output_tokens,
93
+ total: decoded.usage.total_tokens,
94
+ details: { meta: decoded.usage },
95
+ },
96
+ providerMetadata: { meta: { outputFormat: format } },
97
+ });
98
+ });
99
+ // ---------------------------------------------------------------------------
100
+ // 7. Protocol and route
101
+ // ---------------------------------------------------------------------------
102
+ export const protocol = MediaProtocol.inline({
103
+ id: ADAPTER,
104
+ name: NAME,
105
+ unsupported: ["mask", "aspectRatio", "seed"],
106
+ body: { from: fromRequest },
107
+ response: { decode: decodeResponse },
108
+ });
109
+ export const model = (input) => ImageModel.fromRoute({
110
+ id: ADAPTER,
111
+ provider: PROVIDER,
112
+ protocol,
113
+ path: ({ request }) => `/images/${isEdit(request) ? "edits" : "generations"}`,
114
+ }, input);
89
115
  export * as MetaImages from "./meta-images.js";
@@ -141,13 +141,14 @@ const MistralEvent = Schema.StructWithRest(Schema.Struct({
141
141
  }), [Schema.Record(Schema.String, Schema.Unknown)]);
142
142
  const MistralStreamEvent = Schema.Union([Schema.Literal(DONE), Protocol.jsonEvent(MistralEvent)]);
143
143
  const lowerMedia = Effect.fn("MistralChat.lowerMedia")(function* (part) {
144
- const media = ProviderShared.normalizeMedia(part);
145
- const url = typeof part.data === "string" && /^(?:https?:|data:)/.test(part.data) ? part.data : media.dataUrl;
146
- if (media.mime.startsWith("image/"))
144
+ const mime = part.media.mediaType.toLowerCase();
145
+ const url = ProviderShared.mediaUrl(part.media) ??
146
+ (yield* ProviderShared.requireInlineMedia("Mistral Chat", part.media)).dataUrl;
147
+ if (mime.startsWith("image/"))
147
148
  return { type: "image_url", image_url: url };
148
- if (media.mime === "application/pdf")
149
+ if (mime === "application/pdf")
149
150
  return { type: "document_url", document_url: url };
150
- return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.mediaType}`);
151
+ return yield* ProviderShared.invalidRequest(`Mistral Chat does not support media type ${part.media.mediaType}`);
151
152
  });
152
153
  const lowerUser = Effect.fn("MistralChat.lowerUser")(function* (message) {
153
154
  const content = [];
@@ -227,7 +228,7 @@ const lowerToolResults = Effect.fn("MistralChat.lowerToolResults")(function* (me
227
228
  content.push({ type: "text", text: item.text });
228
229
  continue;
229
230
  }
230
- content.push(yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }));
231
+ content.push(yield* lowerMedia(ProviderShared.toolFileMedia(item)));
231
232
  }
232
233
  output.push({
233
234
  role: "tool",
@@ -2,7 +2,7 @@ import { Effect, Schema } from "effect";
2
2
  import { HttpTransport } from "../route/transport/index.js";
3
3
  import { Protocol } from "../route/protocol.js";
4
4
  import { AIError, LLMEvent, Usage, type LLMRequest, type MediaPart, type ProviderMetadata, type ToolDefinition } from "../schema/index.js";
5
- import { ProviderShared } from "./shared.js";
5
+ import type { Media } from "../media.js";
6
6
  import { OpenResponsesOptions } from "./utils/open-responses-options.js";
7
7
  import { Lifecycle } from "./utils/lifecycle.js";
8
8
  import { ToolStream } from "./utils/tool-stream.js";
@@ -689,7 +689,7 @@ export interface ProviderAdapter {
689
689
  }, AIError>;
690
690
  readonly lowerMedia?: (input: {
691
691
  readonly part: MediaPart;
692
- readonly media: ProviderShared.NormalizedMedia;
692
+ readonly media: Media.Inline | undefined;
693
693
  readonly request: LLMRequest;
694
694
  }) => MediaInput | undefined;
695
695
  readonly restoreHostedToolItem?: (item: unknown) => HostedToolReplayItem | undefined;
@@ -1177,6 +1177,14 @@ export declare const step: (state: ParserState, event: NormalizedEvent) => AIErr
1177
1177
  };
1178
1178
  } | undefined;
1179
1179
  readonly namespace?: string | undefined;
1180
+ } | {
1181
+ readonly type: "media";
1182
+ readonly media: Media.Asset;
1183
+ readonly providerMetadata?: {
1184
+ readonly [x: string]: {
1185
+ readonly [x: string]: unknown;
1186
+ };
1187
+ } | undefined;
1180
1188
  } | {
1181
1189
  readonly type: "step-finish";
1182
1190
  readonly reason: {
@@ -1205,12 +1213,12 @@ export declare const step: (state: ParserState, event: NormalizedEvent) => AIErr
1205
1213
  } | {
1206
1214
  readonly type: "provider-error";
1207
1215
  readonly message: string;
1216
+ readonly classification?: "context-overflow" | "payload-too-large" | undefined;
1208
1217
  readonly providerMetadata?: {
1209
1218
  readonly [x: string]: {
1210
1219
  readonly [x: string]: unknown;
1211
1220
  };
1212
1221
  } | undefined;
1213
- readonly classification?: "context-overflow" | "payload-too-large" | undefined;
1214
1222
  })[]], AIError, never> | Effect.Effect<StepResult, never, never>;
1215
1223
  /**
1216
1224
  * The provider-neutral Open Responses protocol. Provider-specific Responses
@@ -335,27 +335,27 @@ const lowerReasoning = (part, providerMetadataKey) => {
335
335
  };
336
336
  };
337
337
  const lowerMedia = Effect.fn("OpenResponses.lowerMedia")(function* (part, request, adapter, target) {
338
- const media = ProviderShared.normalizeMedia(part);
338
+ const media = part.media.inline();
339
339
  const providerMedia = adapter.lowerMedia?.({ part, media, request });
340
340
  if (providerMedia)
341
341
  return providerMedia;
342
342
  const detail = yield* ProviderShared.validateWith(Schema.decodeUnknownEffect(OpenResponsesInputImage.fields.detail))(part.providerMetadata?.[metadataKey(request.model)]?.detail);
343
- const url = typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
344
- ? part.data
345
- : undefined;
346
- if (!media.mime.startsWith("image/")) {
347
- if (target === "tool-result" && media.mime.startsWith("video/"))
348
- return { type: "input_video", video_url: url ?? media.dataUrl };
343
+ const mime = part.media.mediaType.toLowerCase();
344
+ const url = ProviderShared.mediaUrl(part.media);
345
+ const location = url ?? (yield* ProviderShared.requireInlineMedia(adapter.name, part.media)).dataUrl;
346
+ if (part.media.kind !== "image") {
347
+ if (target === "tool-result" && part.media.kind === "video")
348
+ return { type: "input_video", video_url: location };
349
349
  return {
350
350
  type: "input_file",
351
- filename: part.filename ?? (media.mime === "application/pdf" ? "document.pdf" : "file"),
351
+ filename: part.filename ?? (mime === "application/pdf" ? "document.pdf" : "file"),
352
352
  detail,
353
- ...(url ? { file_url: url } : { file_data: media.dataUrl }),
353
+ ...(url ? { file_url: url } : { file_data: location }),
354
354
  };
355
355
  }
356
356
  return {
357
357
  type: "input_image",
358
- image_url: url ?? media.dataUrl,
358
+ image_url: location,
359
359
  detail,
360
360
  };
361
361
  });
@@ -377,12 +377,12 @@ const lowerMessageMedia = Effect.fnUntraced(function* (part, request, adapter) {
377
377
  const lowerToolResultContentItem = Effect.fnUntraced(function* (item, request, adapter) {
378
378
  if (item.type === "text")
379
379
  return { type: "input_text", text: item.text };
380
- return yield* lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }, request, adapter, "tool-result");
380
+ return yield* lowerMedia(ProviderShared.toolFileMedia(item), request, adapter, "tool-result");
381
381
  });
382
382
  const lowerHostedToolResultContentItem = Effect.fnUntraced(function* (item, request, adapter) {
383
383
  if (item.type === "text")
384
384
  return { type: "input_text", text: item.text };
385
- return yield* lowerMessageMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }, request, adapter);
385
+ return yield* lowerMessageMedia(ProviderShared.toolFileMedia(item), request, adapter);
386
386
  });
387
387
  const lowerToolResultOutput = Effect.fnUntraced(function* (part, request, adapter) {
388
388
  // Text/json/error results are encoded as a plain string for backward
@@ -513,11 +513,22 @@ const lowerMessages = Effect.fn("OpenResponses.lowerMessages")(function* (reques
513
513
  });
514
514
  continue;
515
515
  }
516
+ if (part.type === "media") {
517
+ flushText();
518
+ // Responses has no assistant-authored image item; replay generated media (e.g. from Gemini) as user input.
519
+ input.push({
520
+ type: "message",
521
+ role: "user",
522
+ content: [yield* lowerMessageMedia(part, request, adapter)],
523
+ });
524
+ continue;
525
+ }
516
526
  return yield* ProviderShared.unsupportedContent(adapter.name, "assistant", [
517
527
  "text",
518
528
  "reasoning",
519
529
  "tool-call",
520
530
  "tool-result",
531
+ "media",
521
532
  ]);
522
533
  }
523
534
  flushText();
@@ -12,6 +12,37 @@ declare const OpenAIChatCacheControl: Schema.Struct<{
12
12
  readonly type: Schema.Literal<"ephemeral">;
13
13
  readonly ttl: Schema.optional<Schema.String>;
14
14
  }>;
15
+ declare const ReasoningDetail: Schema.Union<readonly [Schema.StructWithRest<Schema.Struct<{
16
+ readonly id: Schema.optional<Schema.NullOr<Schema.String>>;
17
+ readonly format: Schema.optional<Schema.String>;
18
+ readonly index: Schema.optional<Schema.Number>;
19
+ readonly signature: Schema.optional<Schema.NullOr<Schema.String>>;
20
+ readonly type: Schema.Literal<"reasoning.text">;
21
+ readonly text: Schema.optional<Schema.String>;
22
+ }>, readonly [Schema.$Record<Schema.String, Schema.Unknown>]>, Schema.StructWithRest<Schema.Struct<{
23
+ readonly id: Schema.optional<Schema.NullOr<Schema.String>>;
24
+ readonly format: Schema.optional<Schema.String>;
25
+ readonly index: Schema.optional<Schema.Number>;
26
+ readonly signature: Schema.optional<Schema.NullOr<Schema.String>>;
27
+ readonly type: Schema.Literal<"reasoning.summary">;
28
+ readonly summary: Schema.optional<Schema.String>;
29
+ }>, readonly [Schema.$Record<Schema.String, Schema.Unknown>]>, Schema.StructWithRest<Schema.Struct<{
30
+ readonly id: Schema.optional<Schema.NullOr<Schema.String>>;
31
+ readonly format: Schema.optional<Schema.String>;
32
+ readonly index: Schema.optional<Schema.Number>;
33
+ readonly signature: Schema.optional<Schema.NullOr<Schema.String>>;
34
+ readonly type: Schema.Literal<"reasoning.encrypted">;
35
+ readonly data: Schema.String;
36
+ }>, readonly [Schema.$Record<Schema.String, Schema.Unknown>]>, Schema.Struct<{
37
+ readonly type: Schema.Literal<"summary">;
38
+ readonly summary: Schema.String;
39
+ readonly index: Schema.optional<Schema.Number>;
40
+ }>, Schema.Struct<{
41
+ readonly type: Schema.Literal<"encrypted">;
42
+ readonly encrypted: Schema.String;
43
+ readonly index: Schema.optional<Schema.Number>;
44
+ }>]>;
45
+ type ReasoningDetail = Schema.Schema.Type<typeof ReasoningDetail>;
15
46
  export declare const bodyFields: {
16
47
  model: Schema.String;
17
48
  messages: Schema.$Array<Schema.toTaggedUnion<"role", readonly [Schema.Struct<{
@@ -278,7 +309,9 @@ export interface ParserState {
278
309
  readonly finishReason?: FinishReasonDetails;
279
310
  readonly lifecycle: Lifecycle.State;
280
311
  readonly reasoningField?: string;
281
- readonly reasoningDetails: Array<unknown>;
312
+ /** A scalar reasoning field (`reasoning_content`, ...) has carried text in this stream. */
313
+ readonly reasoningTextObserved: boolean;
314
+ readonly reasoningDetails: Array<ReasoningDetail>;
282
315
  readonly reasoningDetailsObserved: boolean;
283
316
  readonly reasoningEmitted: boolean;
284
317
  readonly latestToolIndex?: number;
@@ -1,4 +1,4 @@
1
- import { Effect, Schema } from "effect";
1
+ import { Effect, Option, Schema } from "effect";
2
2
  import { Tool } from "@opencode/schema/tool";
3
3
  import { Route } from "../route/client.js";
4
4
  import { Auth } from "../route/auth.js";
@@ -50,6 +50,32 @@ const OpenAIChatAssistantToolCall = Schema.Struct({
50
50
  arguments: Schema.String,
51
51
  }),
52
52
  });
53
+ // `reasoning_details` carries two dialects. OpenRouter's `reasoning.*` entries
54
+ // must be replayed unmodified (`index` included), so they keep every field they
55
+ // arrived with. Kimi's OpenAI-compatible surface streams preserved thinking as
56
+ // bare `summary` / `encrypted` entries keyed by a stream-only `index`; Kimi does
57
+ // not document this publicly, so the handling follows Kimi Code (Kimi's own
58
+ // client): merge summary deltas by `index`, replay without `index`, and always
59
+ // send `reasoning_content` alongside. Anything else is dropped at the boundary.
60
+ const OpenRouterDetailFields = {
61
+ id: Schema.optional(Schema.NullOr(Schema.String)),
62
+ format: Schema.optional(Schema.String),
63
+ index: Schema.optional(Schema.Number),
64
+ signature: Schema.optional(Schema.NullOr(Schema.String)),
65
+ };
66
+ const ReasoningDetail = Schema.Union([
67
+ Schema.StructWithRest(Schema.Struct({ type: Schema.Literal("reasoning.text"), text: Schema.optional(Schema.String), ...OpenRouterDetailFields }), [Schema.Record(Schema.String, Schema.Unknown)]),
68
+ Schema.StructWithRest(Schema.Struct({
69
+ type: Schema.Literal("reasoning.summary"),
70
+ summary: Schema.optional(Schema.String),
71
+ ...OpenRouterDetailFields,
72
+ }), [Schema.Record(Schema.String, Schema.Unknown)]),
73
+ Schema.StructWithRest(Schema.Struct({ type: Schema.Literal("reasoning.encrypted"), data: Schema.String, ...OpenRouterDetailFields }), [Schema.Record(Schema.String, Schema.Unknown)]),
74
+ Schema.Struct({ type: Schema.Literal("summary"), summary: Schema.String, index: Schema.optional(Schema.Number) }),
75
+ Schema.Struct({ type: Schema.Literal("encrypted"), encrypted: Schema.String, index: Schema.optional(Schema.Number) }),
76
+ ]);
77
+ const decodeReasoningDetail = Schema.decodeUnknownOption(ReasoningDetail);
78
+ const knownReasoningDetails = (details) => details.flatMap((detail) => Option.toArray(decodeReasoningDetail(detail)));
53
79
  // Intentionally omit Gemini's provider-specific `extra_content.google.thought_signature`
54
80
  // extension until direct Google OpenAI-compatible routing is supported here:
55
81
  // https://github.com/vercel/ai/issues/11590
@@ -204,12 +230,9 @@ const lowerToolCall = (part, options) => ({
204
230
  },
205
231
  });
206
232
  const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part) {
207
- const media = ProviderShared.normalizeMedia(part);
208
- if (!media.mime.startsWith("image/"))
209
- return yield* ProviderShared.invalidRequest(`OpenAI Chat does not support media type ${part.mediaType}`);
210
- const url = typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
211
- ? part.data
212
- : media.dataUrl;
233
+ if (part.media.kind !== "image")
234
+ return yield* ProviderShared.invalidRequest(`OpenAI Chat does not support media type ${part.media.mediaType}`);
235
+ const url = ProviderShared.mediaUrl(part.media) ?? (yield* ProviderShared.requireInlineMedia("OpenAI Chat", part.media)).dataUrl;
213
236
  return { type: "image_url", image_url: { url } };
214
237
  });
215
238
  const openAICompatibleReasoningContent = (native) => isRecord(native) && typeof native.reasoning_content === "string" ? native.reasoning_content : undefined;
@@ -223,10 +246,20 @@ const reasoningDetails = (parts, native, providerMetadataKey) => {
223
246
  return Array.isArray(details) ? details : [];
224
247
  });
225
248
  if (parts.some((part) => Array.isArray(part.providerMetadata?.[providerMetadataKey]?.reasoningDetails)))
226
- return observed;
249
+ return knownReasoningDetails(observed).map(lowerReasoningDetail);
227
250
  if (isRecord(native) && Array.isArray(native.reasoning_details))
228
- return native.reasoning_details;
251
+ return knownReasoningDetails(native.reasoning_details).map(lowerReasoningDetail);
252
+ };
253
+ // Kimi rejects its stream-only `index` on requests
254
+ // ("the reasoning_details ... must not contain streaming index").
255
+ const lowerReasoningDetail = (detail) => {
256
+ if (detail.type === "summary")
257
+ return { type: detail.type, summary: detail.summary };
258
+ if (detail.type === "encrypted")
259
+ return { type: detail.type, encrypted: detail.encrypted };
260
+ return detail;
229
261
  };
262
+ const isKimiDetail = (detail) => detail.type === "summary" || detail.type === "encrypted";
230
263
  const lowerUserMessage = Effect.fn("OpenAIChat.lowerUserMessage")(function* (message, options) {
231
264
  const content = [];
232
265
  for (const part of message.content) {
@@ -285,6 +318,10 @@ const lowerAssistantMessage = Effect.fn("OpenAIChat.lowerAssistantMessage")(func
285
318
  return "reasoning_content";
286
319
  if (!fullyStructured || requireReasoning)
287
320
  return "reasoning_content";
321
+ // Kimi always expects `reasoning_content` on replayed assistant messages,
322
+ // even when thinking arrived only through structured details.
323
+ if (details?.some(isKimiDetail))
324
+ return "reasoning_content";
288
325
  })();
289
326
  const reasoningText = (() => {
290
327
  if (configuredField !== undefined)
@@ -330,7 +367,7 @@ const lowerToolMessages = Effect.fn("OpenAIChat.lowerToolMessages")(function* (m
330
367
  cache_control: options.cacheControl?.(part.cache),
331
368
  });
332
369
  const files = content.filter((item) => item.type === "file");
333
- images.push(...(yield* Effect.forEach(files, (item) => lowerMedia({ type: "media", mediaType: item.mime, data: item.uri, filename: item.name }))));
370
+ images.push(...(yield* Effect.forEach(files, (item) => lowerMedia(ProviderShared.toolFileMedia(item)))));
334
371
  }
335
372
  return { messages, images };
336
373
  });
@@ -708,14 +745,16 @@ const reasoningDelta = (delta, configuredField) => {
708
745
  }
709
746
  return undefined;
710
747
  };
711
- const detailText = (details) => {
748
+ const detailText = (details, hideKimiSummary) => {
712
749
  const text = details.flatMap((detail) => {
713
- if (!isRecord(detail))
714
- return [];
715
- if (detail.type === "reasoning.text" && typeof detail.text === "string" && detail.text)
716
- return [detail.text];
717
- if (detail.type === "reasoning.summary" && typeof detail.summary === "string" && detail.summary)
718
- return [detail.summary];
750
+ if (detail.type === "reasoning.text")
751
+ return detail.text ? [detail.text] : [];
752
+ if (detail.type === "reasoning.summary")
753
+ return detail.summary ? [detail.summary] : [];
754
+ // Kimi streams the full thinking through `reasoning_content` and a separate
755
+ // summary through details; show the summary only when nothing else does.
756
+ if (detail.type === "summary")
757
+ return detail.summary && !hideKimiSummary ? [detail.summary] : [];
719
758
  return [];
720
759
  });
721
760
  if (text.length > 0)
@@ -724,25 +763,48 @@ const detailText = (details) => {
724
763
  const appendReasoningDetails = (result, details) => {
725
764
  for (const detail of details) {
726
765
  const previous = result.at(-1);
727
- if (!isRecord(previous) ||
728
- previous.type !== "reasoning.text" ||
729
- !isRecord(detail) ||
730
- detail.type !== "reasoning.text" ||
731
- conflictingReasoningTextDetails(previous, detail)) {
766
+ const merged = previous === undefined ? undefined : mergeReasoningDetails(previous, detail);
767
+ if (merged === undefined) {
732
768
  result.push(detail);
733
769
  continue;
734
770
  }
735
- result[result.length - 1] = {
771
+ result[result.length - 1] = merged;
772
+ }
773
+ };
774
+ // Consecutive text or summary deltas of the same kind accumulate into one
775
+ // entry; encrypted entries are opaque and never merge.
776
+ const mergeReasoningDetails = (previous, detail) => {
777
+ if (conflictingReasoningDetails(previous, detail))
778
+ return undefined;
779
+ if (previous.type === "reasoning.text" && detail.type === "reasoning.text")
780
+ return {
736
781
  ...previous,
737
- ...Object.fromEntries(Object.entries(detail).filter((entry) => entry[1] !== undefined)),
738
- text: `${typeof previous.text === "string" ? previous.text : ""}${typeof detail.text === "string" ? detail.text : ""}`,
739
- signature: mergeDetailValue(previous.signature, detail.signature),
740
- format: mergeDetailValue(previous.format, detail.format),
782
+ ...detail,
783
+ text: `${previous.text ?? ""}${detail.text ?? ""}`,
784
+ ...mergeDetailIdentity(previous, detail),
741
785
  };
742
- }
786
+ if (previous.type === "reasoning.summary" && detail.type === "reasoning.summary")
787
+ return {
788
+ ...previous,
789
+ ...detail,
790
+ summary: `${previous.summary ?? ""}${detail.summary ?? ""}`,
791
+ ...mergeDetailIdentity(previous, detail),
792
+ };
793
+ if (previous.type === "summary" && detail.type === "summary")
794
+ return { ...previous, ...detail, summary: previous.summary + detail.summary };
795
+ };
796
+ // The first non-empty signature and format win; a later delta may carry the
797
+ // signature for text that streamed earlier.
798
+ const mergeDetailIdentity = (previous, current) => {
799
+ const signature = mergeDetailValue(previous.signature, current.signature);
800
+ const format = mergeDetailValue(previous.format, current.format);
801
+ return {
802
+ ...(signature === undefined ? {} : { signature }),
803
+ ...(format === undefined ? {} : { format }),
804
+ };
743
805
  };
744
806
  const mergeDetailValue = (previous, current) => previous || current || (previous !== undefined ? previous : current);
745
- const conflictingReasoningTextDetails = (previous, current) => conflictingDetailValue(previous.id, current.id) ||
807
+ const conflictingReasoningDetails = (previous, current) => conflictingDetailValue(previous.id, current.id) ||
746
808
  conflictingDetailValue(previous.index, current.index) ||
747
809
  conflictingDetailValue(previous.format, current.format) ||
748
810
  (Boolean(previous.signature) && Boolean(current.signature) && previous.signature !== current.signature);
@@ -798,12 +860,17 @@ const step = (state, event) => Effect.gen(function* () {
798
860
  return [{ ...state, usage }, events];
799
861
  }
800
862
  const reasoningField = state.reasoningField ?? reasoning?.field;
801
- const detailDelta = Array.isArray(delta?.reasoning_details) ? delta.reasoning_details : undefined;
863
+ const reasoningTextObserved = state.reasoningTextObserved || reasoning !== undefined;
864
+ const detailDelta = Array.isArray(delta?.reasoning_details)
865
+ ? knownReasoningDetails(delta.reasoning_details)
866
+ : undefined;
802
867
  if (detailDelta !== undefined)
803
868
  appendReasoningDetails(state.reasoningDetails, detailDelta);
804
869
  const reasoningDetailsObserved = state.reasoningDetailsObserved || detailDelta !== undefined;
805
870
  const deltaMetadata = reasoningMetadata(state.providerMetadataKey, reasoningField);
806
- const text = detailDelta?.length ? (detailText(detailDelta) ?? reasoning?.text) : reasoning?.text;
871
+ const text = detailDelta?.length
872
+ ? (detailText(detailDelta, reasoningTextObserved) ?? reasoning?.text)
873
+ : reasoning?.text;
807
874
  if (text !== undefined)
808
875
  lifecycle = Lifecycle.reasoningDelta(lifecycle, events, "reasoning-0", text, deltaMetadata);
809
876
  else if (reasoningDetailsObserved &&
@@ -881,6 +948,7 @@ const step = (state, event) => Effect.gen(function* () {
881
948
  finishReason,
882
949
  lifecycle,
883
950
  reasoningField,
951
+ reasoningTextObserved,
884
952
  reasoningDetails: state.reasoningDetails,
885
953
  reasoningDetailsObserved,
886
954
  reasoningEmitted,
@@ -947,6 +1015,7 @@ export const protocol = Protocol.make({
947
1015
  toolCallEvents: [],
948
1016
  lifecycle: Lifecycle.initial(),
949
1017
  reasoningField: request.model.compatibility?.reasoningField,
1018
+ reasoningTextObserved: false,
950
1019
  reasoningDetails: [],
951
1020
  reasoningDetailsObserved: false,
952
1021
  reasoningEmitted: false,
@@ -1,32 +1,21 @@
1
- import { ImageModel, type ImageInput } from "../image.js";
2
- import { type Definition as AuthDefinition } from "../route/auth.js";
3
- import { type HttpOptions } from "../schema/index.js";
1
+ import { ImageModel, ImageResponse, type ImageRequestFor } from "../image.js";
2
+ import { MediaProtocol } from "../route/media-protocol.js";
3
+ import { MediaRoute } from "../route/media.js";
4
4
  export declare const DEFAULT_BASE_URL = "https://api.openai.com/v1";
5
5
  export declare const PATH = "/images/generations";
6
6
  export declare const EDIT_PATH = "/images/edits";
7
7
  export type OpenAIImageString<Known extends string> = Known | (string & {});
8
+ /** Provider-native options. Common fields (`n`, `size`, `format`, `images`, `mask`) live on the request. */
8
9
  export type OpenAIImageOptions = {
9
- readonly mask?: ImageInput;
10
- readonly n?: number;
11
- readonly size?: OpenAIImageString<"auto" | "256x256" | "512x512" | "1024x1024" | "1536x1024" | "1024x1536" | "1792x1024" | "1024x1792">;
12
10
  readonly quality?: OpenAIImageString<"auto" | "low" | "medium" | "high" | "standard" | "hd">;
13
11
  readonly background?: OpenAIImageString<"auto" | "opaque" | "transparent">;
14
12
  readonly moderation?: OpenAIImageString<"auto" | "low">;
15
- readonly outputFormat?: OpenAIImageString<"png" | "jpeg" | "webp">;
16
13
  readonly outputCompression?: number;
17
14
  } & Record<string, unknown>;
18
- export type OpenAIImageBody = Record<string, unknown> & {
19
- readonly model: string;
20
- readonly prompt: string;
21
- };
22
- export interface ModelInput {
23
- readonly id: string;
24
- readonly auth: AuthDefinition;
25
- readonly baseURL?: string;
26
- readonly headers?: Record<string, string>;
27
- readonly http?: HttpOptions;
28
- }
29
- export declare const model: (input: ModelInput) => ImageModel<OpenAIImageOptions>;
15
+ export type Request = ImageRequestFor<OpenAIImageOptions>;
16
+ export declare const protocol: MediaProtocol.Inline<Request, ImageResponse>;
17
+ export declare const model: (input: MediaRoute.ModelInput) => ImageModel<OpenAIImageOptions>;
30
18
  export declare const OpenAIImages: {
31
- readonly model: (input: ModelInput) => ImageModel<OpenAIImageOptions>;
19
+ readonly protocol: MediaProtocol.Inline<Request, ImageResponse>;
20
+ readonly model: (input: MediaRoute.ModelInput) => ImageModel<OpenAIImageOptions>;
32
21
  };