@linxiraos/pi-ai 1.2.1 → 1.3.1

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 (153) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +4 -2
  3. package/THIRD-PARTY-NOTICES.txt +55 -5
  4. package/dist/types/auth/cascade.d.ts +5 -3
  5. package/dist/types/auth/pool.d.ts +3 -3
  6. package/dist/types/auth/select.d.ts +5 -1
  7. package/dist/types/auth/types.d.ts +15 -5
  8. package/dist/types/auth/usage-cache.d.ts +3 -1
  9. package/dist/types/auth-gateway/clients.d.ts +67 -0
  10. package/dist/types/auth-gateway/dispatch.d.ts +28 -10
  11. package/dist/types/auth-gateway/http.d.ts +2 -0
  12. package/dist/types/auth-gateway/index.d.ts +1 -0
  13. package/dist/types/auth-gateway/server.d.ts +9 -4
  14. package/dist/types/auth-gateway/session-state.d.ts +6 -0
  15. package/dist/types/auth-gateway/types.d.ts +16 -2
  16. package/dist/types/dialect/coercion.d.ts +6 -0
  17. package/dist/types/dialect/deepseek.d.ts +13 -0
  18. package/dist/types/error/classes.d.ts +1 -1
  19. package/dist/types/error/flags.d.ts +15 -0
  20. package/dist/types/error/index.d.ts +1 -0
  21. package/dist/types/error/network-cause.d.ts +40 -0
  22. package/dist/types/images/shared.d.ts +26 -2
  23. package/dist/types/index.d.ts +1 -0
  24. package/dist/types/judgment/native.d.ts +62 -3
  25. package/dist/types/judgment/systemone.d.ts +9 -2
  26. package/dist/types/judgment/text.d.ts +11 -5
  27. package/dist/types/judgment/types.d.ts +37 -6
  28. package/dist/types/judgment/wire.d.ts +18 -3
  29. package/dist/types/provider-session-state.d.ts +2 -2
  30. package/dist/types/providers/anthropic-images.d.ts +7 -0
  31. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  32. package/dist/types/providers/anthropic-state.d.ts +7 -0
  33. package/dist/types/providers/anthropic.d.ts +21 -6
  34. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  35. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  36. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  37. package/dist/types/providers/cursor.d.ts +7 -0
  38. package/dist/types/providers/ollama.d.ts +6 -1
  39. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  40. package/dist/types/providers/openai-responses.d.ts +9 -0
  41. package/dist/types/providers/openai-shared.d.ts +30 -0
  42. package/dist/types/providers/systemone-server.d.ts +7 -3
  43. package/dist/types/providers/transform-messages.d.ts +28 -5
  44. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  45. package/dist/types/stream.d.ts +0 -2
  46. package/dist/types/types.d.ts +33 -9
  47. package/dist/types/usage/muse-code.d.ts +1 -1
  48. package/dist/types/utils/dsml-leak.d.ts +17 -13
  49. package/dist/types/utils/narration.d.ts +9 -0
  50. package/dist/types/utils/openai-http.d.ts +2 -0
  51. package/dist/types/utils/request-body.d.ts +11 -0
  52. package/dist/types/utils/request-compression.d.ts +21 -0
  53. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  54. package/dist/types/utils/transport-fetch.d.ts +8 -0
  55. package/dist/types/utils.d.ts +1 -1
  56. package/package.json +7 -7
  57. package/src/auth/cascade.ts +23 -13
  58. package/src/auth/pool.ts +3 -3
  59. package/src/auth/refresh.ts +39 -12
  60. package/src/auth/select.ts +8 -2
  61. package/src/auth/types.ts +16 -5
  62. package/src/auth/usage-cache.ts +23 -7
  63. package/src/auth/usage.ts +2 -4
  64. package/src/auth-broker/remote-store.ts +31 -6
  65. package/src/auth-gateway/clients.ts +318 -0
  66. package/src/auth-gateway/dispatch.ts +66 -35
  67. package/src/auth-gateway/http.ts +11 -5
  68. package/src/auth-gateway/index.ts +1 -0
  69. package/src/auth-gateway/routes/embeddings.ts +4 -10
  70. package/src/auth-gateway/routes/images.ts +6 -10
  71. package/src/auth-gateway/routes/rerank.ts +4 -10
  72. package/src/auth-gateway/routes/speech.ts +10 -10
  73. package/src/auth-gateway/routes/systemone.ts +22 -12
  74. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  75. package/src/auth-gateway/routes/video.ts +12 -15
  76. package/src/auth-gateway/server.ts +136 -53
  77. package/src/auth-gateway/session-state.ts +14 -0
  78. package/src/auth-gateway/types.ts +16 -2
  79. package/src/dialect/coercion.ts +16 -0
  80. package/src/dialect/deepseek.ts +344 -81
  81. package/src/dialect/hermes.ts +7 -11
  82. package/src/dialect/qwen3.ts +3 -11
  83. package/src/error/classes.ts +3 -2
  84. package/src/error/flags.ts +24 -3
  85. package/src/error/index.ts +1 -0
  86. package/src/error/network-cause.ts +179 -0
  87. package/src/images/codex-format-directive.md +3 -0
  88. package/src/images/google-antigravity.ts +2 -1
  89. package/src/images/google-generative-ai.ts +4 -2
  90. package/src/images/openai-hosted.ts +25 -2
  91. package/src/images/openai-images.ts +11 -6
  92. package/src/images/shared.ts +77 -3
  93. package/src/index.ts +1 -0
  94. package/src/judgment/chat.ts +17 -1
  95. package/src/judgment/native.ts +238 -12
  96. package/src/judgment/openai-decisions.ts +30 -6
  97. package/src/judgment/systemone.ts +153 -11
  98. package/src/judgment/text.ts +26 -7
  99. package/src/judgment/types.ts +46 -6
  100. package/src/judgment/wire.ts +19 -3
  101. package/src/provider-session-state.ts +2 -2
  102. package/src/providers/amazon-bedrock.ts +4 -1
  103. package/src/providers/anthropic-client.ts +1 -1
  104. package/src/providers/anthropic-images.ts +263 -0
  105. package/src/providers/anthropic-messages-server.ts +84 -1
  106. package/src/providers/anthropic-signature.ts +35 -8
  107. package/src/providers/anthropic-state.ts +13 -0
  108. package/src/providers/anthropic.ts +479 -397
  109. package/src/providers/connection-prewarm.ts +78 -0
  110. package/src/providers/cowork-fetch.ts +5 -1
  111. package/src/providers/cursor/exec-modern.ts +1 -0
  112. package/src/providers/cursor-pi-args.ts +19 -0
  113. package/src/providers/cursor.ts +53 -13
  114. package/src/providers/devin.ts +10 -4
  115. package/src/providers/factory-droid/gemini.ts +9 -3
  116. package/src/providers/google-gemini-cli.ts +11 -1
  117. package/src/providers/google-shared.ts +2 -6
  118. package/src/providers/images-server.ts +6 -1
  119. package/src/providers/ollama.ts +23 -5
  120. package/src/providers/openai-anthropic-shim.ts +1 -0
  121. package/src/providers/openai-chat-server.ts +29 -0
  122. package/src/providers/openai-codex/access-programs.ts +2 -1
  123. package/src/providers/openai-codex-responses.ts +49 -82
  124. package/src/providers/openai-completions.ts +117 -19
  125. package/src/providers/openai-responses.ts +25 -2
  126. package/src/providers/openai-shared.ts +36 -5
  127. package/src/providers/pi-native-server.ts +1 -0
  128. package/src/providers/systemone-server.ts +72 -12
  129. package/src/providers/transform-messages.ts +104 -55
  130. package/src/registry/oauth/google-antigravity.ts +1 -1
  131. package/src/stream.ts +13 -73
  132. package/src/types.ts +33 -9
  133. package/src/usage/claude.ts +2 -9
  134. package/src/usage/github-copilot.ts +26 -4
  135. package/src/usage/muse-code.ts +115 -29
  136. package/src/usage/openai-codex.ts +13 -7
  137. package/src/usage/opencode-go.ts +7 -8
  138. package/src/usage/xai-oauth.ts +2 -9
  139. package/src/utils/dsml-leak.ts +88 -33
  140. package/src/utils/leaked-thinking-stream.ts +10 -0
  141. package/src/utils/narration.ts +18 -0
  142. package/src/utils/openai-http.ts +27 -18
  143. package/src/utils/request-body.ts +27 -0
  144. package/src/utils/request-compression.ts +202 -0
  145. package/src/utils/request-debug.ts +23 -9
  146. package/src/utils/schema/dereference.ts +51 -18
  147. package/src/utils/schema/draft.ts +10 -2
  148. package/src/utils/schema/json-schema-validator.ts +4 -10
  149. package/src/utils/schema/normalize.ts +6 -9
  150. package/src/utils/tool-call-arguments.ts +2 -1
  151. package/src/utils/transport-fetch.ts +37 -0
  152. package/src/utils/validation.ts +26 -16
  153. package/src/utils.ts +38 -23
@@ -4,8 +4,8 @@
4
4
  *
5
5
  * Each judgment API is one wire dialect: TypeSafe's System One route
6
6
  * (`POST /v1/systemone`), OpenRouter Decisions (`POST /api/alpha/decisions`,
7
- * same body), Cloudflare Workers AI decision models (`POST /ai/run/{model}`,
8
- * same body in Cloudflare's envelope; `systemone.ts`), and OpenAI Decisions
7
+ * same body), Cloudflare Workers AI (`POST /ai/run/{model}`, same body plus
8
+ * top-level data URL images; `systemone.ts`), and OpenAI Decisions
9
9
  * (`POST /v1/decisions`, translated both ways; `openai-decisions.ts`).
10
10
  * Credentials flow through {@link withAuth}, so a stored key rotates on
11
11
  * 401/403 exactly like chat providers; transient 429/5xx responses retry with
@@ -19,16 +19,18 @@
19
19
  * (`rules/auth/typesafe.kdl`), `TYPESAFE_BASE_URL` overrides the API root,
20
20
  * `TYPESAFE_DEFAULT_MODEL` the model.
21
21
  */
22
+ import { resolveJudgmentImageInput, type JudgmentImageInput } from "@linxiraos/pi-catalog/compat/judgment-images";
22
23
  import { resolveJudgmentQuestionsMax } from "@linxiraos/pi-catalog/compat/judgment-questions";
23
24
  import { TYPESAFE_DEFAULT_BASE_URL } from "@linxiraos/pi-catalog/discovery";
24
25
  import type { Api, FetchImpl } from "@linxiraos/pi-catalog/types";
25
- import { $env, isRecord } from "@linxiraos/pi-utils";
26
+ import { $env, isRecord, parseImageMetadata } from "@linxiraos/pi-utils";
26
27
  import { type ApiKey, withAuth } from "../auth-retry";
27
28
  import * as AIError from "../error";
28
- import type { Model, Usage } from "../types";
29
+ import { validateImageInput } from "../images/shared";
30
+ import type { ImageContent, Model, Usage } from "../types";
29
31
  import { getRetryAfterMsFromHeaders } from "../utils/retry-after";
30
32
  import { openaiDecisionsWire } from "./openai-decisions";
31
- import { openrouterDecisionsWire, typesafeWire, workersAiWire } from "./systemone";
33
+ import { openrouterDecisionsWire, referencedImageAssets, typesafeWire, workersAiWire } from "./systemone";
32
34
  import {
33
35
  type Answer,
34
36
  type Judge,
@@ -38,7 +40,8 @@ import {
38
40
  type Questions,
39
41
  tokenUsage,
40
42
  } from "./types";
41
- import type { JudgmentWire } from "./wire";
43
+ import type { JudgmentImageAssets, JudgmentWire } from "./wire";
44
+ export type { JudgmentImageAssets } from "./wire";
42
45
 
43
46
  export const TYPESAFE_PROVIDER = "typesafe";
44
47
  export const TYPESAFE_DEFAULT_MODEL = "jev-latest";
@@ -58,6 +61,97 @@ export function isJudgmentApi(api: Api): api is JudgmentApi {
58
61
  return Object.hasOwn(JUDGMENT_WIRES, api);
59
62
  }
60
63
 
64
+ /** Whether both the native protocol and catalog model support image input. */
65
+ export function nativeJudgmentSupportsImages(model: Model): boolean {
66
+ return isJudgmentApi(model.api) && JUDGMENT_WIRES[model.api].images !== undefined && model.input.includes("image");
67
+ }
68
+
69
+ /** Whether `model`'s native judgment takes images and its wire forwards box questions. The model itself may still refuse them (Box needs Jev 1.14+). */
70
+ export function nativeJudgmentSupportsBoxes(model: Model): boolean {
71
+ return nativeJudgmentSupportsImages(model) && isJudgmentApi(model.api) && JUDGMENT_WIRES[model.api].boxes === true;
72
+ }
73
+
74
+ /**
75
+ * Every image bound `model`'s native judgment enforces: its wire's transport limits tightened by the
76
+ * catalog's model bounds. `undefined` when it takes no images. Native APIs reject images outside these
77
+ * rather than resizing them, so callers size images to them first.
78
+ */
79
+ export function nativeJudgmentImageBounds(model: Model): JudgmentImageInput | undefined {
80
+ if (!isJudgmentApi(model.api) || !model.input.includes("image")) return undefined;
81
+ const wire = JUDGMENT_WIRES[model.api].images;
82
+ if (wire === undefined) return undefined;
83
+ const catalog = resolveJudgmentImageInput(model);
84
+ return catalog === undefined ? wire : intersectImageInput(wire, catalog);
85
+ }
86
+
87
+ /** Whether `model`'s native judgment API takes these exact images: image support within every {@link nativeJudgmentImageBounds}. */
88
+ export function nativeJudgmentAcceptsImages(model: Model, images: readonly ImageContent[]): boolean {
89
+ const bounds = nativeJudgmentImageBounds(model);
90
+ return bounds !== undefined && imageLimitViolation(images, bounds) === undefined;
91
+ }
92
+
93
+ /** The bounds both `a` and `b` allow: shared MIME types, the lower of each cap, the higher of each floor. */
94
+ function intersectImageInput(a: JudgmentImageInput, b: JudgmentImageInput): JudgmentImageInput {
95
+ const bounds: JudgmentImageInput = {};
96
+ const mimeTypes =
97
+ a.mimeTypes && b.mimeTypes
98
+ ? a.mimeTypes.filter(type => b.mimeTypes?.includes(type))
99
+ : (a.mimeTypes ?? b.mimeTypes);
100
+ if (mimeTypes !== undefined) bounds.mimeTypes = mimeTypes;
101
+ for (const cap of ["maxCount", "maxBytes", "maxTotalBytes", "maxPixels", "maxSide"] as const) {
102
+ const limit = a[cap] === undefined ? b[cap] : b[cap] === undefined ? a[cap] : Math.min(a[cap], b[cap]);
103
+ if (limit !== undefined) bounds[cap] = limit;
104
+ }
105
+ for (const floor of ["minPixels", "minSide"] as const) {
106
+ const limit =
107
+ a[floor] === undefined ? b[floor] : b[floor] === undefined ? a[floor] : Math.max(a[floor], b[floor]);
108
+ if (limit !== undefined) bounds[floor] = limit;
109
+ }
110
+ return bounds;
111
+ }
112
+
113
+ /**
114
+ * Reject images that `model`'s native judgment API cannot take — no image support, an unsupported or
115
+ * unreferenced asset, or a transport or catalog bound exceeded — before any credential or upstream work.
116
+ * @throws {AIError.ValidationError} naming the reason.
117
+ */
118
+ export function checkNativeJudgmentImages(
119
+ model: Model,
120
+ request: JudgmentRequest,
121
+ imageAssets?: JudgmentImageAssets,
122
+ ): void {
123
+ const label = `${model.provider}/${model.id}`;
124
+ if (isJudgmentApi(model.api)) {
125
+ checkJudgmentImages(
126
+ label,
127
+ JUDGMENT_WIRES[model.api],
128
+ model.input.includes("image"),
129
+ request,
130
+ imageAssets,
131
+ resolveJudgmentImageInput(model),
132
+ );
133
+ } else if ((request.images?.length ?? 0) > 0 || Object.keys(imageAssets ?? {}).length > 0) {
134
+ throw new AIError.ValidationError(`Native judgment backend ${label} does not support image inputs`);
135
+ }
136
+ }
137
+
138
+ /**
139
+ * Reject box questions whose `image` is not an index into `request.images`: nothing would say which
140
+ * image their edges measure.
141
+ * @throws {AIError.ValidationError} naming the question.
142
+ */
143
+ export function checkBoxQuestions(request: JudgmentRequest): void {
144
+ const count = request.images?.length ?? 0;
145
+ for (const id in request.questions) {
146
+ const question = request.questions[id];
147
+ if (question.type !== "box") continue;
148
+ if (Number.isInteger(question.image) && question.image >= 0 && question.image < count) continue;
149
+ throw new AIError.ValidationError(
150
+ `judgment: box question "${id}" names image ${question.image}, but the request has ${count} images`,
151
+ );
152
+ }
153
+ }
154
+
61
155
  /** `TYPESAFE_BASE_URL` when set, else the public API root; trailing slashes stripped. */
62
156
  export function typesafeBaseUrl(): string {
63
157
  return ($env.TYPESAFE_BASE_URL?.trim() || TYPESAFE_DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -92,10 +186,19 @@ export interface NativeJudgeOptions extends NativeJudgeConnection {
92
186
  * requests are split. Unset: no cap.
93
187
  */
94
188
  maxQuestions?: number;
189
+ /**
190
+ * Whether images are enabled for this judge. `false` disables image input; `true` enables
191
+ * dialects that require explicit opt-in but cannot enable a wire without image support.
192
+ */
193
+ supportsImages?: boolean;
194
+ /** Catalog-selected image bounds, applied in addition to the wire's transport constraints. */
195
+ imageInput?: JudgmentImageInput;
95
196
  }
96
197
 
97
198
  /** Per-call options for {@link NativeJudge.judge}. */
98
199
  export interface NativeJudgeCallOptions extends JudgeOptions {
200
+ /** Assets keyed by their existing references; wires without asset transport reject them. */
201
+ imageAssets?: JudgmentImageAssets;
99
202
  /**
100
203
  * Called before a split judgment rejects, with the summed usage of the chunks that had already
101
204
  * succeeded: billed upstream, but their answers are discarded with the failure. Not called when
@@ -128,8 +231,94 @@ function isAnswerOf(value: unknown, type: Answer["type"]): value is Answer {
128
231
  return isRecord(value) && value.type === type;
129
232
  }
130
233
 
234
+ /** The first documented cap `images` exceed, phrased to follow the backend label; `undefined` when within every cap. */
235
+ function imageLimitViolation(images: readonly ImageContent[], limits: JudgmentImageInput): string | undefined {
236
+ if (limits.maxCount !== undefined && images.length > limits.maxCount) {
237
+ return `accepts at most ${limits.maxCount} images, got ${images.length}`;
238
+ }
239
+ let totalBytes = 0;
240
+ const { minPixels, maxPixels, minSide, maxSide } = limits;
241
+ const checkDimensions =
242
+ minPixels !== undefined || maxPixels !== undefined || minSide !== undefined || maxSide !== undefined;
243
+ const needsBytes = limits.maxBytes !== undefined || limits.maxTotalBytes !== undefined;
244
+ for (const [index, image] of images.entries()) {
245
+ const mimeType = image.mimeType.toLowerCase();
246
+ if (limits.mimeTypes && !limits.mimeTypes.includes(mimeType)) {
247
+ return `does not accept ${mimeType} images (images[${index}]); use ${limits.mimeTypes.join(", ")}`;
248
+ }
249
+ if (!needsBytes && !checkDimensions) continue;
250
+ const bytes = Buffer.from(image.data, "base64");
251
+ totalBytes += bytes.length;
252
+ if (limits.maxBytes !== undefined && bytes.length > limits.maxBytes) {
253
+ return `accepts images up to ${limits.maxBytes} bytes; images[${index}] is ${bytes.length} bytes`;
254
+ }
255
+ if (!checkDimensions) continue;
256
+ const metadata = parseImageMetadata(bytes);
257
+ if (!metadata?.width || !metadata.height) continue;
258
+ const { width, height } = metadata;
259
+ const size = `images[${index}] is ${width}x${height}`;
260
+ if (maxPixels !== undefined && width * height > maxPixels)
261
+ return `accepts images up to ${maxPixels} pixels; ${size}`;
262
+ if (minPixels !== undefined && width * height < minPixels) {
263
+ return `accepts images of at least ${minPixels} pixels; ${size}`;
264
+ }
265
+ if (maxSide !== undefined && Math.max(width, height) > maxSide) {
266
+ return `accepts images up to ${maxSide} pixels per side; ${size}`;
267
+ }
268
+ if (minSide !== undefined && Math.min(width, height) < minSide) {
269
+ return `accepts images of at least ${minSide} pixels per side; ${size}`;
270
+ }
271
+ }
272
+ if (limits.maxTotalBytes !== undefined && totalBytes > limits.maxTotalBytes) {
273
+ return `accepts at most ${limits.maxTotalBytes} image bytes per request, got ${totalBytes}`;
274
+ }
275
+ return undefined;
276
+ }
277
+
278
+ /**
279
+ * Reject images `wire` would refuse: assets on a wire without asset transport, images when they are
280
+ * disabled, an asset `request` never references (TypeSafe refuses those), malformed data, or a
281
+ * documented cap exceeded.
282
+ * @throws {AIError.ValidationError} naming the first problem.
283
+ */
284
+ function checkJudgmentImages(
285
+ label: string,
286
+ wire: JudgmentWire,
287
+ imagesEnabled: boolean,
288
+ request: JudgmentRequest,
289
+ imageAssets: JudgmentImageAssets | undefined,
290
+ modelImageInput?: JudgmentImageInput,
291
+ ): void {
292
+ const assets = Object.values(imageAssets ?? {});
293
+ if (assets.length > 0 && wire.supportsImageAssets !== true) {
294
+ throw new AIError.ValidationError(`Native judgment backend ${label} does not support image assets`);
295
+ }
296
+ const images = [...(request.images ?? []), ...assets];
297
+ if (images.length === 0) return;
298
+ if (!imagesEnabled || wire.images === undefined) {
299
+ throw new AIError.ValidationError(`Native judgment backend ${label} does not support image inputs`);
300
+ }
301
+ if (imageAssets !== undefined && assets.length > 0) {
302
+ const referenced = referencedImageAssets(request, imageAssets);
303
+ for (const reference in imageAssets) {
304
+ if (Object.hasOwn(referenced, reference)) continue;
305
+ throw new AIError.ValidationError(
306
+ `Native judgment backend ${label}: image asset ${JSON.stringify(reference)} is not referenced in state, instructions, or criteria`,
307
+ );
308
+ }
309
+ }
310
+ for (const image of images) validateImageInput(image);
311
+ for (const limits of [wire.images, modelImageInput]) {
312
+ if (limits === undefined) continue;
313
+ const violation = imageLimitViolation(images, limits);
314
+ if (violation !== undefined) throw new AIError.ValidationError(`Native judgment backend ${label} ${violation}`);
315
+ }
316
+ }
317
+
131
318
  export class NativeJudge implements Judge {
132
319
  readonly label: string;
320
+ /** True when the selected native wire and model or direct-call option permit image input. */
321
+ readonly supportsImages?: boolean;
133
322
  readonly api: JudgmentApi;
134
323
  readonly provider: string;
135
324
  /** Wire model id sent with every request. */
@@ -141,6 +330,7 @@ export class NativeJudge implements Judge {
141
330
  readonly #fetch: FetchImpl;
142
331
  readonly #timeoutMs: number;
143
332
  readonly #maxQuestions: number | undefined;
333
+ readonly #imageInput: JudgmentImageInput | undefined;
144
334
 
145
335
  constructor(options: NativeJudgeOptions) {
146
336
  this.#apiKey = options.apiKey;
@@ -156,6 +346,9 @@ export class NativeJudge implements Judge {
156
346
  throw new RangeError(`NativeJudge maxQuestions must be a positive integer, got ${options.maxQuestions}`);
157
347
  }
158
348
  this.#maxQuestions = options.maxQuestions;
349
+ this.#imageInput = options.imageInput;
350
+ const optedIntoImages = options.supportsImages ?? this.#wire.imagesRequireOptIn !== true;
351
+ if (this.#wire.images !== undefined && optedIntoImages) this.supportsImages = true;
159
352
  this.label = `${this.provider}/${this.model}`;
160
353
  }
161
354
 
@@ -163,12 +356,32 @@ export class NativeJudge implements Judge {
163
356
  request: JudgmentRequest<Q>,
164
357
  options?: NativeJudgeCallOptions,
165
358
  ): Promise<JudgmentResult<Q>> {
359
+ checkJudgmentImages(
360
+ this.label,
361
+ this.#wire,
362
+ this.supportsImages === true,
363
+ request,
364
+ options?.imageAssets,
365
+ this.#imageInput,
366
+ );
367
+ checkBoxQuestions(request);
368
+ if (this.#wire.boxes !== true) {
369
+ for (const id in request.questions) {
370
+ if (request.questions[id].type !== "box") continue;
371
+ throw new AIError.ValidationError(`Native judgment backend ${this.label} does not answer box questions`);
372
+ }
373
+ }
166
374
  const ids = Object.keys(request.questions);
167
375
  const cap = this.#maxQuestions;
168
376
  if (cap !== undefined && ids.length > cap) return this.#judgeChunked(request, ids, cap, options);
169
377
  return this.#judgeOnce(request, options);
170
378
  }
171
379
 
380
+ /** Effective image detail for cache identity after applying this wire's forwarding behavior. */
381
+ effectiveImageDetail(detail: ImageContent["detail"]): ImageContent["detail"] {
382
+ return this.#wire.ignoresImageDetail ? undefined : detail;
383
+ }
384
+
172
385
  /**
173
386
  * Answer `request` in requests of at most `cap` questions each, {@link CHUNK_PARALLEL} at a time;
174
387
  * answers merge and usage sums. The first failing chunk aborts its in-flight siblings and stops
@@ -189,14 +402,17 @@ export class NativeJudge implements Judge {
189
402
  }
190
403
  const failed = new AbortController();
191
404
  const signal = options?.signal ? AbortSignal.any([options.signal, failed.signal]) : failed.signal;
192
- const chunkOptions = { signal };
405
+ const imageAssets = options?.imageAssets;
193
406
  const results: JudgmentResult[] = [];
194
407
  let next = 0;
195
408
  const drain = async (): Promise<void> => {
196
409
  while (next < chunks.length && !signal.aborted) {
197
410
  const index = next++;
411
+ const chunk = { ...request, questions: chunks[index] };
412
+ // TypeSafe refuses unreferenced assets: a chunk carries only those its own questions or the state use.
413
+ const chunkAssets = imageAssets === undefined ? undefined : referencedImageAssets(chunk, imageAssets);
198
414
  try {
199
- results[index] = await this.#judgeOnce({ state: request.state, questions: chunks[index] }, chunkOptions);
415
+ results[index] = await this.#judgeOnce(chunk, { signal, imageAssets: chunkAssets });
200
416
  } catch (error) {
201
417
  failed.abort(error);
202
418
  throw error;
@@ -234,11 +450,13 @@ export class NativeJudge implements Judge {
234
450
 
235
451
  async #judgeOnce<Q extends Questions>(
236
452
  request: JudgmentRequest<Q>,
237
- options: JudgeOptions | undefined,
453
+ options: NativeJudgeCallOptions | undefined,
238
454
  ): Promise<JudgmentResult<Q>> {
239
- const body = JSON.stringify(this.#wire.encode(request, this.model));
455
+ const body = JSON.stringify(this.#wire.encode(request, this.model, options?.imageAssets));
456
+ const hasImages = (request.images?.length ?? 0) > 0 || Object.keys(options?.imageAssets ?? {}).length > 0;
457
+ const requestHeaders = hasImages ? this.#wire.imageHeaders : undefined;
240
458
  const signal = options?.signal;
241
- const payload = await withAuth(this.#apiKey, key => this.#attempt(body, key, signal), { signal });
459
+ const payload = await withAuth(this.#apiKey, key => this.#attempt(body, key, signal, requestHeaders), { signal });
242
460
  const decoded = this.#wire.decode(payload);
243
461
  if (!decoded) {
244
462
  throw new AIError.ProviderResponseError(`${this.label} returned a malformed judgment response`, {
@@ -274,10 +492,16 @@ export class NativeJudge implements Judge {
274
492
  };
275
493
  }
276
494
 
277
- async #attempt(body: string, key: string, signal: AbortSignal | undefined): Promise<unknown> {
495
+ async #attempt(
496
+ body: string,
497
+ key: string,
498
+ signal: AbortSignal | undefined,
499
+ requestHeaders: Readonly<Record<string, string>> | undefined,
500
+ ): Promise<unknown> {
278
501
  const target = this.#wire.target(this.baseUrl, this.model, key);
279
502
  const headers: Record<string, string> = {
280
503
  ...this.#headers,
504
+ ...requestHeaders,
281
505
  Authorization: `Bearer ${target.token}`,
282
506
  Accept: "application/json",
283
507
  "Content-Type": "application/json",
@@ -329,5 +553,7 @@ export function createNativeJudge(model: Model, connection: NativeJudgeConnectio
329
553
  model: model.requestModelId ?? model.id,
330
554
  baseUrl: model.baseUrl,
331
555
  maxQuestions: resolveJudgmentQuestionsMax(model),
556
+ supportsImages: nativeJudgmentSupportsImages(model),
557
+ imageInput: resolveJudgmentImageInput(model),
332
558
  });
333
559
  }
@@ -7,7 +7,8 @@
7
7
  *
8
8
  * | System One | Decisions |
9
9
  * | ------------------------------------------- | --------------------------------------------- |
10
- * | `state` (text or JSON) | `input` (text; JSON state is serialized) |
10
+ * | `state` (text or JSON) | `input` text (JSON serialized), or `input_text` for images |
11
+ * | separate images | ordered `input_image` data URLs in user input |
11
12
  * | question id | `name`, echoed on its answer |
12
13
  * | `noul` + optional yes/no `criteria` | `predicate`; criteria fold into instructions |
13
14
  * | `choice` `criteria` option → rubric | `choices: [{ value, description? }]` |
@@ -19,6 +20,8 @@
19
20
  * tokens only, so cost is priced from the catalog model.
20
21
  */
21
22
  import { isRecord, prompt } from "@linxiraos/pi-utils";
23
+ import * as AIError from "../error";
24
+ import { embeddedImageDataUrl } from "../images/shared";
22
25
  import predicateTemplate from "./openai-decisions-predicate.md" with { type: "text" };
23
26
  import { type Answer, type Question, tokenUsage } from "./types";
24
27
  import { finiteNumber, type JudgmentWire } from "./wire";
@@ -58,6 +61,8 @@ function encodeQuestion(name: string, question: Question): DecisionQuestion {
58
61
  instructions: question.instructions,
59
62
  levels: question.criteria.map(label => ({ label })),
60
63
  };
64
+ case "box":
65
+ throw new AIError.ValidationError(`OpenAI Decisions has no box question type (question "${name}")`);
61
66
  }
62
67
  }
63
68
 
@@ -99,12 +104,31 @@ function decodeAnswer(entry: Record<string, unknown>): Answer | undefined {
99
104
 
100
105
  /** OpenAI's `/v1/decisions` route. */
101
106
  export const openaiDecisionsWire: JudgmentWire = {
107
+ // https://developers.openai.com/api/reference/resources/decisions/methods/create/
108
+ images: { maxCount: 128 },
102
109
  target: (baseUrl, _model, key) => ({ url: `${baseUrl}/decisions`, token: key }),
103
- encode: (request, model) => ({
104
- model,
105
- input: typeof request.state === "string" ? request.state : JSON.stringify(request.state),
106
- questions: Object.entries(request.questions).map(([name, question]) => encodeQuestion(name, question)),
107
- }),
110
+ encode: (request, model) => {
111
+ const state = typeof request.state === "string" ? request.state : JSON.stringify(request.state);
112
+ return {
113
+ model,
114
+ input: request.images?.length
115
+ ? [
116
+ {
117
+ role: "user",
118
+ content: [
119
+ { type: "input_text", text: state },
120
+ ...request.images.map(image => ({
121
+ type: "input_image",
122
+ image_url: embeddedImageDataUrl(image),
123
+ ...(image.detail !== undefined ? { detail: image.detail } : {}),
124
+ })),
125
+ ],
126
+ },
127
+ ]
128
+ : state,
129
+ questions: Object.entries(request.questions).map(([name, question]) => encodeQuestion(name, question)),
130
+ };
131
+ },
108
132
  decode(payload) {
109
133
  if (!isRecord(payload) || !Array.isArray(payload.answers)) return undefined;
110
134
  const answers: Record<string, Answer> = {};
@@ -1,6 +1,8 @@
1
1
  /**
2
- * TypeSafe System One dialects. The request body (`{ model, state, questions }`)
3
- * and answer map are TypeSafe's own, forwarded verbatim:
2
+ * TypeSafe and OpenRouter use the `{ model, state, questions }` body. Images
3
+ * join `state` without replacing it (see {@link stateWithImages}): TypeSafe as
4
+ * references to a top-level `assets` map, OpenRouter as `image_url` data URL
5
+ * parts. Cloudflare optionally accepts image data URLs in its envelope.
4
6
  *
5
7
  * - `typesafe`: `POST {baseUrl}/v1/systemone` — TypeSafe and every System One
6
8
  * server (OpenJEV, Kev, self-hosted Clef, OpenCode Zen, Command Code).
@@ -8,15 +10,25 @@
8
10
  * `/api/alpha`; usage adds the billed `cost` in USD.
9
11
  * - `workers-ai-systemone`: Cloudflare Workers AI's `POST {baseUrl}/{model}`
10
12
  * (`/client/v4/accounts/<account>/ai/run/@cf/cloudflare/clef`). The body
11
- * names the model by its last path segment (`clef`), the response arrives in
12
- * Cloudflare's `{ result, success, errors }` envelope, and the credential is
13
- * a Cloudflare API token plus the account id the URL needs.
13
+ * names the model by its last path segment (`clef`), adds a top-level array
14
+ * of base64 data URLs when images are supplied, and returns Cloudflare's
15
+ * `{ result, success, errors }` envelope. The credential is a Cloudflare
16
+ * API token plus the account id the URL needs.
14
17
  */
15
18
  import { parseCloudflareCredential } from "@linxiraos/pi-catalog/wire/cloudflare";
16
19
  import { $env, isRecord } from "@linxiraos/pi-utils";
17
20
  import * as AIError from "../error";
18
- import { tokenUsage } from "./types";
19
- import { type DecodedJudgment, finiteNumber, type JudgmentWire } from "./wire";
21
+ import { embeddedImageDataUrl } from "../images/shared";
22
+ import type { ImageContent } from "../types";
23
+ import {
24
+ type BoxQuestion,
25
+ type JsonValue,
26
+ type JudgmentRequest,
27
+ type Question,
28
+ type Questions,
29
+ tokenUsage,
30
+ } from "./types";
31
+ import { type DecodedJudgment, finiteNumber, type JudgmentImageAssets, type JudgmentWire } from "./wire";
20
32
 
21
33
  function decodeSystemOne(payload: unknown): DecodedJudgment | undefined {
22
34
  if (!isRecord(payload) || !isRecord(payload.answers)) return undefined;
@@ -29,23 +41,152 @@ function decodeSystemOne(payload: unknown): DecodedJudgment | undefined {
29
41
  };
30
42
  }
31
43
 
44
+ /**
45
+ * `state` with `items` added and every original value kept: arrays gain them at the end, objects
46
+ * under the first free `__omp_judgment_images[_N]` key, and a string root becomes `[state, ...items]`.
47
+ */
48
+ function stateWithImages(state: JudgmentRequest["state"], items: readonly JsonValue[]): JudgmentRequest["state"] {
49
+ if (Array.isArray(state)) return [...state, ...items];
50
+ if (!isRecord(state)) return [state, ...items];
51
+ const baseKey = "__omp_judgment_images";
52
+ let key = baseKey;
53
+ for (let suffix = 1; Object.hasOwn(state, key); suffix++) key = `${baseKey}_${suffix}`;
54
+ return { ...state, [key]: items };
55
+ }
56
+
57
+ function* jsonStrings(value: unknown): Generator<string> {
58
+ if (typeof value === "string") yield value;
59
+ else if (Array.isArray(value)) for (const item of value) yield* jsonStrings(item);
60
+ else if (isRecord(value)) for (const key in value) yield* jsonStrings(value[key]);
61
+ }
62
+
63
+ /**
64
+ * The subset of `assets` that `request` references. TypeSafe substitutes a reference wherever it
65
+ * occurs as text in a string of `state`, a question's `instructions`, or its `criteria` (never in
66
+ * object keys) and rejects a request carrying an unreferenced asset.
67
+ */
68
+ export function referencedImageAssets(request: JudgmentRequest, assets: JudgmentImageAssets): JudgmentImageAssets {
69
+ const pending = new Set(Object.keys(assets));
70
+ const visit = (value: unknown): void => {
71
+ for (const text of jsonStrings(value)) {
72
+ if (pending.size === 0) return;
73
+ for (const reference of pending) if (text.includes(reference)) pending.delete(reference);
74
+ }
75
+ };
76
+ visit(request.state);
77
+ for (const id in request.questions) {
78
+ const question = request.questions[id];
79
+ visit(question.instructions);
80
+ if (question.type !== "box") visit(question.criteria);
81
+ }
82
+ // `fromEntries` defines own properties, so a `__proto__` reference stays an ordinary key.
83
+ return Object.fromEntries(Object.entries(assets).filter(([reference]) => !pending.has(reference)));
84
+ }
85
+
86
+ interface SystemOneImageAsset {
87
+ type: "image";
88
+ format: "png" | "jpeg";
89
+ data_base64: string;
90
+ }
91
+
92
+ function encodeSystemOneImage(image: ImageContent): SystemOneImageAsset {
93
+ const mimeType = image.mimeType.toLowerCase();
94
+ return {
95
+ type: "image",
96
+ format: mimeType === "image/png" ? "png" : "jpeg",
97
+ data_base64: Buffer.from(image.data, "base64").toString("base64"),
98
+ };
99
+ }
100
+
101
+ type WireQuestion = Exclude<Question, BoxQuestion> | Omit<BoxQuestion, "image">;
102
+
103
+ /** `questions` as System One takes them: a box question's `image` only names the image its edges measure locally. */
104
+ function wireQuestions(questions: Questions): Record<string, WireQuestion> {
105
+ const wire: Record<string, WireQuestion> = {};
106
+ for (const id in questions) {
107
+ const question = questions[id];
108
+ wire[id] = question.type === "box" ? { type: "box", instructions: question.instructions } : question;
109
+ }
110
+ return wire;
111
+ }
112
+
32
113
  /** A System One dialect posting the verbatim request to `path` under the base URL with the key as bearer. */
33
114
  function systemOneWire(path: string): JudgmentWire {
34
115
  return {
35
116
  target: (baseUrl, _model, key) => ({ url: `${baseUrl}${path}`, token: key }),
36
- encode: (request, model) => ({ state: request.state, model, questions: request.questions }),
117
+ encode: (request, model) => ({ state: request.state, model, questions: wireQuestions(request.questions) }),
37
118
  decode: decodeSystemOne,
38
119
  };
39
120
  }
40
121
 
41
122
  /** TypeSafe's own route, shared by every System One server. */
42
- export const typesafeWire = systemOneWire("/v1/systemone");
123
+ export const typesafeWire: JudgmentWire = {
124
+ ...systemOneWire("/v1/systemone"),
125
+ images: { mimeTypes: ["image/png", "image/jpeg"] },
126
+ supportsImageAssets: true,
127
+ imagesRequireOptIn: true,
128
+ imageHeaders: { "TypeSafe-Danger-Zone-Images": "true" },
129
+ ignoresImageDetail: true,
130
+ boxes: true,
131
+ encode(request, model, imageAssets) {
132
+ const suppliedAssets = Object.entries(imageAssets ?? {});
133
+ const images = request.images ?? [];
134
+ if (suppliedAssets.length === 0 && images.length === 0) {
135
+ return { state: request.state, model, questions: wireQuestions(request.questions) };
136
+ }
43
137
 
44
- /** OpenRouter Decisions: the System One body under `/api/alpha`. */
45
- export const openrouterDecisionsWire = systemOneWire("/decisions");
138
+ const assets: Record<string, SystemOneImageAsset> = Object.fromEntries(
139
+ suppliedAssets.map(([reference, image]) => [reference, encodeSystemOneImage(image)]),
140
+ );
141
+ const references: string[] = [];
142
+ for (const image of images) {
143
+ let reference = crypto.randomUUID();
144
+ while (Object.hasOwn(assets, reference)) reference = crypto.randomUUID();
145
+ assets[reference] = encodeSystemOneImage(image);
146
+ references.push(reference);
147
+ }
148
+
149
+ return {
150
+ state: references.length ? stateWithImages(request.state, references) : request.state,
151
+ model,
152
+ questions: wireQuestions(request.questions),
153
+ assets,
154
+ };
155
+ },
156
+ };
157
+
158
+ /** OpenRouter Decisions: System One body under `/api/alpha`, with image parts preserved in state. */
159
+ export const openrouterDecisionsWire: JudgmentWire = {
160
+ ...systemOneWire("/decisions"),
161
+ images: {},
162
+ imagesRequireOptIn: true,
163
+ boxes: true,
164
+ encode(request, model) {
165
+ const imageParts: JsonValue[] = (request.images ?? []).map(image => ({
166
+ type: "image_url",
167
+ image_url: {
168
+ url: embeddedImageDataUrl(image),
169
+ ...(image.detail !== undefined ? { detail: image.detail === "original" ? "auto" : image.detail } : {}),
170
+ },
171
+ }));
172
+ return {
173
+ state: imageParts.length ? stateWithImages(request.state, imageParts) : request.state,
174
+ model,
175
+ questions: wireQuestions(request.questions),
176
+ };
177
+ },
178
+ };
46
179
 
47
180
  /** Cloudflare Workers AI decision models (Clef) through the account-scoped `ai/run` route. */
48
181
  export const workersAiWire: JudgmentWire = {
182
+ // Clef's `images` extension: https://developers.cloudflare.com/workers-ai/models/clef/
183
+ images: {
184
+ mimeTypes: ["image/png", "image/jpeg", "image/webp"],
185
+ maxCount: 4,
186
+ maxBytes: 4 * 1024 * 1024,
187
+ maxTotalBytes: 8 * 1024 * 1024,
188
+ maxPixels: 16_000_000,
189
+ },
49
190
  target(baseUrl, model, key) {
50
191
  const credential = parseCloudflareCredential(key);
51
192
  const accountId = credential?.accountId ?? $env.CLOUDFLARE_ACCOUNT_ID?.trim();
@@ -60,6 +201,7 @@ export const workersAiWire: JudgmentWire = {
60
201
  state: request.state,
61
202
  model: model.slice(model.lastIndexOf("/") + 1),
62
203
  questions: request.questions,
204
+ ...(request.images?.length ? { images: request.images.map(embeddedImageDataUrl) } : {}),
63
205
  }),
64
206
  decode: payload => (isRecord(payload) ? decodeSystemOne(payload.result) : undefined),
65
207
  };