@linxiraos/pi-ai 1.2.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (148) hide show
  1. package/CHANGELOG.md +83 -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/error/classes.d.ts +1 -1
  18. package/dist/types/error/flags.d.ts +15 -0
  19. package/dist/types/error/index.d.ts +1 -0
  20. package/dist/types/error/network-cause.d.ts +40 -0
  21. package/dist/types/images/shared.d.ts +26 -2
  22. package/dist/types/index.d.ts +1 -0
  23. package/dist/types/judgment/native.d.ts +51 -3
  24. package/dist/types/judgment/systemone.d.ts +9 -2
  25. package/dist/types/judgment/text.d.ts +11 -5
  26. package/dist/types/judgment/types.d.ts +5 -1
  27. package/dist/types/judgment/wire.d.ts +16 -3
  28. package/dist/types/provider-session-state.d.ts +2 -2
  29. package/dist/types/providers/anthropic-images.d.ts +7 -0
  30. package/dist/types/providers/anthropic-signature.d.ts +12 -2
  31. package/dist/types/providers/anthropic-state.d.ts +7 -0
  32. package/dist/types/providers/anthropic.d.ts +21 -6
  33. package/dist/types/providers/connection-prewarm.d.ts +15 -0
  34. package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
  35. package/dist/types/providers/cursor-pi-args.d.ts +13 -0
  36. package/dist/types/providers/cursor.d.ts +7 -0
  37. package/dist/types/providers/openai-codex-responses.d.ts +6 -0
  38. package/dist/types/providers/openai-responses.d.ts +9 -0
  39. package/dist/types/providers/openai-shared.d.ts +30 -0
  40. package/dist/types/providers/systemone-server.d.ts +7 -3
  41. package/dist/types/providers/transform-messages.d.ts +26 -5
  42. package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
  43. package/dist/types/stream.d.ts +0 -2
  44. package/dist/types/types.d.ts +33 -9
  45. package/dist/types/utils/dsml-leak.d.ts +4 -2
  46. package/dist/types/utils/narration.d.ts +9 -0
  47. package/dist/types/utils/openai-http.d.ts +2 -0
  48. package/dist/types/utils/request-body.d.ts +11 -0
  49. package/dist/types/utils/request-compression.d.ts +21 -0
  50. package/dist/types/utils/tool-call-arguments.d.ts +2 -1
  51. package/dist/types/utils/transport-fetch.d.ts +8 -0
  52. package/dist/types/utils.d.ts +1 -1
  53. package/package.json +8 -8
  54. package/src/auth/cascade.ts +23 -13
  55. package/src/auth/pool.ts +3 -3
  56. package/src/auth/refresh.ts +39 -12
  57. package/src/auth/select.ts +8 -2
  58. package/src/auth/types.ts +16 -5
  59. package/src/auth/usage-cache.ts +23 -7
  60. package/src/auth/usage.ts +2 -4
  61. package/src/auth-broker/remote-store.ts +31 -6
  62. package/src/auth-gateway/clients.ts +318 -0
  63. package/src/auth-gateway/dispatch.ts +66 -35
  64. package/src/auth-gateway/http.ts +11 -5
  65. package/src/auth-gateway/index.ts +1 -0
  66. package/src/auth-gateway/routes/embeddings.ts +4 -10
  67. package/src/auth-gateway/routes/images.ts +6 -10
  68. package/src/auth-gateway/routes/rerank.ts +4 -10
  69. package/src/auth-gateway/routes/speech.ts +10 -10
  70. package/src/auth-gateway/routes/systemone.ts +22 -12
  71. package/src/auth-gateway/routes/transcriptions.ts +4 -2
  72. package/src/auth-gateway/routes/video.ts +12 -15
  73. package/src/auth-gateway/server.ts +136 -53
  74. package/src/auth-gateway/session-state.ts +14 -0
  75. package/src/auth-gateway/types.ts +16 -2
  76. package/src/dialect/coercion.ts +16 -0
  77. package/src/dialect/hermes.ts +7 -11
  78. package/src/dialect/qwen3.ts +3 -11
  79. package/src/error/classes.ts +3 -2
  80. package/src/error/flags.ts +24 -3
  81. package/src/error/index.ts +1 -0
  82. package/src/error/network-cause.ts +179 -0
  83. package/src/images/codex-format-directive.md +3 -0
  84. package/src/images/google-antigravity.ts +2 -1
  85. package/src/images/google-generative-ai.ts +4 -2
  86. package/src/images/openai-hosted.ts +25 -2
  87. package/src/images/openai-images.ts +11 -6
  88. package/src/images/shared.ts +77 -3
  89. package/src/index.ts +1 -0
  90. package/src/judgment/chat.ts +17 -1
  91. package/src/judgment/native.ts +182 -12
  92. package/src/judgment/openai-decisions.ts +27 -6
  93. package/src/judgment/systemone.ts +131 -10
  94. package/src/judgment/text.ts +20 -7
  95. package/src/judgment/types.ts +5 -1
  96. package/src/judgment/wire.ts +17 -3
  97. package/src/provider-session-state.ts +2 -2
  98. package/src/providers/amazon-bedrock.ts +4 -1
  99. package/src/providers/anthropic-client.ts +1 -1
  100. package/src/providers/anthropic-images.ts +263 -0
  101. package/src/providers/anthropic-messages-server.ts +84 -1
  102. package/src/providers/anthropic-signature.ts +35 -8
  103. package/src/providers/anthropic-state.ts +13 -0
  104. package/src/providers/anthropic.ts +479 -397
  105. package/src/providers/connection-prewarm.ts +78 -0
  106. package/src/providers/cowork-fetch.ts +5 -1
  107. package/src/providers/cursor/exec-modern.ts +1 -0
  108. package/src/providers/cursor-pi-args.ts +19 -0
  109. package/src/providers/cursor.ts +53 -13
  110. package/src/providers/devin.ts +10 -4
  111. package/src/providers/factory-droid/gemini.ts +9 -3
  112. package/src/providers/google-gemini-cli.ts +11 -1
  113. package/src/providers/google-shared.ts +2 -6
  114. package/src/providers/images-server.ts +6 -1
  115. package/src/providers/ollama.ts +15 -5
  116. package/src/providers/openai-anthropic-shim.ts +1 -0
  117. package/src/providers/openai-chat-server.ts +29 -0
  118. package/src/providers/openai-codex/access-programs.ts +2 -1
  119. package/src/providers/openai-codex-responses.ts +49 -82
  120. package/src/providers/openai-completions.ts +117 -19
  121. package/src/providers/openai-responses.ts +25 -2
  122. package/src/providers/openai-shared.ts +36 -5
  123. package/src/providers/pi-native-server.ts +1 -0
  124. package/src/providers/systemone-server.ts +72 -12
  125. package/src/providers/transform-messages.ts +95 -51
  126. package/src/registry/oauth/google-antigravity.ts +1 -1
  127. package/src/stream.ts +13 -73
  128. package/src/types.ts +33 -9
  129. package/src/usage/claude.ts +2 -9
  130. package/src/usage/github-copilot.ts +26 -4
  131. package/src/usage/openai-codex.ts +13 -7
  132. package/src/usage/opencode-go.ts +7 -8
  133. package/src/usage/xai-oauth.ts +2 -9
  134. package/src/utils/dsml-leak.ts +30 -14
  135. package/src/utils/leaked-thinking-stream.ts +10 -0
  136. package/src/utils/narration.ts +18 -0
  137. package/src/utils/openai-http.ts +27 -18
  138. package/src/utils/request-body.ts +27 -0
  139. package/src/utils/request-compression.ts +202 -0
  140. package/src/utils/request-debug.ts +23 -9
  141. package/src/utils/schema/dereference.ts +51 -18
  142. package/src/utils/schema/draft.ts +10 -2
  143. package/src/utils/schema/json-schema-validator.ts +4 -10
  144. package/src/utils/schema/normalize.ts +6 -9
  145. package/src/utils/tool-call-arguments.ts +2 -1
  146. package/src/utils/transport-fetch.ts +37 -0
  147. package/src/utils/validation.ts +26 -16
  148. 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,48 @@ 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
+ /**
70
+ * Whether `model`'s native judgment API takes these exact images: image support plus every documented
71
+ * transport and catalog bound. A judge chain skips a candidate that would refuse them instead of failing on it.
72
+ */
73
+ export function nativeJudgmentAcceptsImages(model: Model, images: readonly ImageContent[]): boolean {
74
+ if (!isJudgmentApi(model.api) || !model.input.includes("image")) return false;
75
+ const wireLimits = JUDGMENT_WIRES[model.api].images;
76
+ if (wireLimits === undefined || imageLimitViolation(images, wireLimits) !== undefined) return false;
77
+ const modelLimits = resolveJudgmentImageInput(model);
78
+ return modelLimits === undefined || imageLimitViolation(images, modelLimits) === undefined;
79
+ }
80
+
81
+ /**
82
+ * Reject images that `model`'s native judgment API cannot take — no image support, an unsupported or
83
+ * unreferenced asset, or a transport or catalog bound exceeded — before any credential or upstream work.
84
+ * @throws {AIError.ValidationError} naming the reason.
85
+ */
86
+ export function checkNativeJudgmentImages(
87
+ model: Model,
88
+ request: JudgmentRequest,
89
+ imageAssets?: JudgmentImageAssets,
90
+ ): void {
91
+ const label = `${model.provider}/${model.id}`;
92
+ if (isJudgmentApi(model.api)) {
93
+ checkJudgmentImages(
94
+ label,
95
+ JUDGMENT_WIRES[model.api],
96
+ model.input.includes("image"),
97
+ request,
98
+ imageAssets,
99
+ resolveJudgmentImageInput(model),
100
+ );
101
+ } else if ((request.images?.length ?? 0) > 0 || Object.keys(imageAssets ?? {}).length > 0) {
102
+ throw new AIError.ValidationError(`Native judgment backend ${label} does not support image inputs`);
103
+ }
104
+ }
105
+
61
106
  /** `TYPESAFE_BASE_URL` when set, else the public API root; trailing slashes stripped. */
62
107
  export function typesafeBaseUrl(): string {
63
108
  return ($env.TYPESAFE_BASE_URL?.trim() || TYPESAFE_DEFAULT_BASE_URL).replace(/\/+$/, "");
@@ -92,10 +137,19 @@ export interface NativeJudgeOptions extends NativeJudgeConnection {
92
137
  * requests are split. Unset: no cap.
93
138
  */
94
139
  maxQuestions?: number;
140
+ /**
141
+ * Whether images are enabled for this judge. `false` disables image input; `true` enables
142
+ * dialects that require explicit opt-in but cannot enable a wire without image support.
143
+ */
144
+ supportsImages?: boolean;
145
+ /** Catalog-selected image bounds, applied in addition to the wire's transport constraints. */
146
+ imageInput?: JudgmentImageInput;
95
147
  }
96
148
 
97
149
  /** Per-call options for {@link NativeJudge.judge}. */
98
150
  export interface NativeJudgeCallOptions extends JudgeOptions {
151
+ /** Assets keyed by their existing references; wires without asset transport reject them. */
152
+ imageAssets?: JudgmentImageAssets;
99
153
  /**
100
154
  * Called before a split judgment rejects, with the summed usage of the chunks that had already
101
155
  * succeeded: billed upstream, but their answers are discarded with the failure. Not called when
@@ -128,8 +182,94 @@ function isAnswerOf(value: unknown, type: Answer["type"]): value is Answer {
128
182
  return isRecord(value) && value.type === type;
129
183
  }
130
184
 
185
+ /** The first documented cap `images` exceed, phrased to follow the backend label; `undefined` when within every cap. */
186
+ function imageLimitViolation(images: readonly ImageContent[], limits: JudgmentImageInput): string | undefined {
187
+ if (limits.maxCount !== undefined && images.length > limits.maxCount) {
188
+ return `accepts at most ${limits.maxCount} images, got ${images.length}`;
189
+ }
190
+ let totalBytes = 0;
191
+ const { minPixels, maxPixels, minSide, maxSide } = limits;
192
+ const checkDimensions =
193
+ minPixels !== undefined || maxPixels !== undefined || minSide !== undefined || maxSide !== undefined;
194
+ const needsBytes = limits.maxBytes !== undefined || limits.maxTotalBytes !== undefined;
195
+ for (const [index, image] of images.entries()) {
196
+ const mimeType = image.mimeType.toLowerCase();
197
+ if (limits.mimeTypes && !limits.mimeTypes.includes(mimeType)) {
198
+ return `does not accept ${mimeType} images (images[${index}]); use ${limits.mimeTypes.join(", ")}`;
199
+ }
200
+ if (!needsBytes && !checkDimensions) continue;
201
+ const bytes = Buffer.from(image.data, "base64");
202
+ totalBytes += bytes.length;
203
+ if (limits.maxBytes !== undefined && bytes.length > limits.maxBytes) {
204
+ return `accepts images up to ${limits.maxBytes} bytes; images[${index}] is ${bytes.length} bytes`;
205
+ }
206
+ if (!checkDimensions) continue;
207
+ const metadata = parseImageMetadata(bytes);
208
+ if (!metadata?.width || !metadata.height) continue;
209
+ const { width, height } = metadata;
210
+ const size = `images[${index}] is ${width}x${height}`;
211
+ if (maxPixels !== undefined && width * height > maxPixels)
212
+ return `accepts images up to ${maxPixels} pixels; ${size}`;
213
+ if (minPixels !== undefined && width * height < minPixels) {
214
+ return `accepts images of at least ${minPixels} pixels; ${size}`;
215
+ }
216
+ if (maxSide !== undefined && Math.max(width, height) > maxSide) {
217
+ return `accepts images up to ${maxSide} pixels per side; ${size}`;
218
+ }
219
+ if (minSide !== undefined && Math.min(width, height) < minSide) {
220
+ return `accepts images of at least ${minSide} pixels per side; ${size}`;
221
+ }
222
+ }
223
+ if (limits.maxTotalBytes !== undefined && totalBytes > limits.maxTotalBytes) {
224
+ return `accepts at most ${limits.maxTotalBytes} image bytes per request, got ${totalBytes}`;
225
+ }
226
+ return undefined;
227
+ }
228
+
229
+ /**
230
+ * Reject images `wire` would refuse: assets on a wire without asset transport, images when they are
231
+ * disabled, an asset `request` never references (TypeSafe refuses those), malformed data, or a
232
+ * documented cap exceeded.
233
+ * @throws {AIError.ValidationError} naming the first problem.
234
+ */
235
+ function checkJudgmentImages(
236
+ label: string,
237
+ wire: JudgmentWire,
238
+ imagesEnabled: boolean,
239
+ request: JudgmentRequest,
240
+ imageAssets: JudgmentImageAssets | undefined,
241
+ modelImageInput?: JudgmentImageInput,
242
+ ): void {
243
+ const assets = Object.values(imageAssets ?? {});
244
+ if (assets.length > 0 && wire.supportsImageAssets !== true) {
245
+ throw new AIError.ValidationError(`Native judgment backend ${label} does not support image assets`);
246
+ }
247
+ const images = [...(request.images ?? []), ...assets];
248
+ if (images.length === 0) return;
249
+ if (!imagesEnabled || wire.images === undefined) {
250
+ throw new AIError.ValidationError(`Native judgment backend ${label} does not support image inputs`);
251
+ }
252
+ if (imageAssets !== undefined && assets.length > 0) {
253
+ const referenced = referencedImageAssets(request, imageAssets);
254
+ for (const reference in imageAssets) {
255
+ if (Object.hasOwn(referenced, reference)) continue;
256
+ throw new AIError.ValidationError(
257
+ `Native judgment backend ${label}: image asset ${JSON.stringify(reference)} is not referenced in state, instructions, or criteria`,
258
+ );
259
+ }
260
+ }
261
+ for (const image of images) validateImageInput(image);
262
+ for (const limits of [wire.images, modelImageInput]) {
263
+ if (limits === undefined) continue;
264
+ const violation = imageLimitViolation(images, limits);
265
+ if (violation !== undefined) throw new AIError.ValidationError(`Native judgment backend ${label} ${violation}`);
266
+ }
267
+ }
268
+
131
269
  export class NativeJudge implements Judge {
132
270
  readonly label: string;
271
+ /** True when the selected native wire and model or direct-call option permit image input. */
272
+ readonly supportsImages?: boolean;
133
273
  readonly api: JudgmentApi;
134
274
  readonly provider: string;
135
275
  /** Wire model id sent with every request. */
@@ -141,6 +281,7 @@ export class NativeJudge implements Judge {
141
281
  readonly #fetch: FetchImpl;
142
282
  readonly #timeoutMs: number;
143
283
  readonly #maxQuestions: number | undefined;
284
+ readonly #imageInput: JudgmentImageInput | undefined;
144
285
 
145
286
  constructor(options: NativeJudgeOptions) {
146
287
  this.#apiKey = options.apiKey;
@@ -156,6 +297,9 @@ export class NativeJudge implements Judge {
156
297
  throw new RangeError(`NativeJudge maxQuestions must be a positive integer, got ${options.maxQuestions}`);
157
298
  }
158
299
  this.#maxQuestions = options.maxQuestions;
300
+ this.#imageInput = options.imageInput;
301
+ const optedIntoImages = options.supportsImages ?? this.#wire.imagesRequireOptIn !== true;
302
+ if (this.#wire.images !== undefined && optedIntoImages) this.supportsImages = true;
159
303
  this.label = `${this.provider}/${this.model}`;
160
304
  }
161
305
 
@@ -163,12 +307,25 @@ export class NativeJudge implements Judge {
163
307
  request: JudgmentRequest<Q>,
164
308
  options?: NativeJudgeCallOptions,
165
309
  ): Promise<JudgmentResult<Q>> {
310
+ checkJudgmentImages(
311
+ this.label,
312
+ this.#wire,
313
+ this.supportsImages === true,
314
+ request,
315
+ options?.imageAssets,
316
+ this.#imageInput,
317
+ );
166
318
  const ids = Object.keys(request.questions);
167
319
  const cap = this.#maxQuestions;
168
320
  if (cap !== undefined && ids.length > cap) return this.#judgeChunked(request, ids, cap, options);
169
321
  return this.#judgeOnce(request, options);
170
322
  }
171
323
 
324
+ /** Effective image detail for cache identity after applying this wire's forwarding behavior. */
325
+ effectiveImageDetail(detail: ImageContent["detail"]): ImageContent["detail"] {
326
+ return this.#wire.ignoresImageDetail ? undefined : detail;
327
+ }
328
+
172
329
  /**
173
330
  * Answer `request` in requests of at most `cap` questions each, {@link CHUNK_PARALLEL} at a time;
174
331
  * answers merge and usage sums. The first failing chunk aborts its in-flight siblings and stops
@@ -189,14 +346,17 @@ export class NativeJudge implements Judge {
189
346
  }
190
347
  const failed = new AbortController();
191
348
  const signal = options?.signal ? AbortSignal.any([options.signal, failed.signal]) : failed.signal;
192
- const chunkOptions = { signal };
349
+ const imageAssets = options?.imageAssets;
193
350
  const results: JudgmentResult[] = [];
194
351
  let next = 0;
195
352
  const drain = async (): Promise<void> => {
196
353
  while (next < chunks.length && !signal.aborted) {
197
354
  const index = next++;
355
+ const chunk = { ...request, questions: chunks[index] };
356
+ // TypeSafe refuses unreferenced assets: a chunk carries only those its own questions or the state use.
357
+ const chunkAssets = imageAssets === undefined ? undefined : referencedImageAssets(chunk, imageAssets);
198
358
  try {
199
- results[index] = await this.#judgeOnce({ state: request.state, questions: chunks[index] }, chunkOptions);
359
+ results[index] = await this.#judgeOnce(chunk, { signal, imageAssets: chunkAssets });
200
360
  } catch (error) {
201
361
  failed.abort(error);
202
362
  throw error;
@@ -234,11 +394,13 @@ export class NativeJudge implements Judge {
234
394
 
235
395
  async #judgeOnce<Q extends Questions>(
236
396
  request: JudgmentRequest<Q>,
237
- options: JudgeOptions | undefined,
397
+ options: NativeJudgeCallOptions | undefined,
238
398
  ): Promise<JudgmentResult<Q>> {
239
- const body = JSON.stringify(this.#wire.encode(request, this.model));
399
+ const body = JSON.stringify(this.#wire.encode(request, this.model, options?.imageAssets));
400
+ const hasImages = (request.images?.length ?? 0) > 0 || Object.keys(options?.imageAssets ?? {}).length > 0;
401
+ const requestHeaders = hasImages ? this.#wire.imageHeaders : undefined;
240
402
  const signal = options?.signal;
241
- const payload = await withAuth(this.#apiKey, key => this.#attempt(body, key, signal), { signal });
403
+ const payload = await withAuth(this.#apiKey, key => this.#attempt(body, key, signal, requestHeaders), { signal });
242
404
  const decoded = this.#wire.decode(payload);
243
405
  if (!decoded) {
244
406
  throw new AIError.ProviderResponseError(`${this.label} returned a malformed judgment response`, {
@@ -274,10 +436,16 @@ export class NativeJudge implements Judge {
274
436
  };
275
437
  }
276
438
 
277
- async #attempt(body: string, key: string, signal: AbortSignal | undefined): Promise<unknown> {
439
+ async #attempt(
440
+ body: string,
441
+ key: string,
442
+ signal: AbortSignal | undefined,
443
+ requestHeaders: Readonly<Record<string, string>> | undefined,
444
+ ): Promise<unknown> {
278
445
  const target = this.#wire.target(this.baseUrl, this.model, key);
279
446
  const headers: Record<string, string> = {
280
447
  ...this.#headers,
448
+ ...requestHeaders,
281
449
  Authorization: `Bearer ${target.token}`,
282
450
  Accept: "application/json",
283
451
  "Content-Type": "application/json",
@@ -329,5 +497,7 @@ export function createNativeJudge(model: Model, connection: NativeJudgeConnectio
329
497
  model: model.requestModelId ?? model.id,
330
498
  baseUrl: model.baseUrl,
331
499
  maxQuestions: resolveJudgmentQuestionsMax(model),
500
+ supportsImages: nativeJudgmentSupportsImages(model),
501
+ imageInput: resolveJudgmentImageInput(model),
332
502
  });
333
503
  }
@@ -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,7 @@
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 { embeddedImageDataUrl } from "../images/shared";
22
24
  import predicateTemplate from "./openai-decisions-predicate.md" with { type: "text" };
23
25
  import { type Answer, type Question, tokenUsage } from "./types";
24
26
  import { finiteNumber, type JudgmentWire } from "./wire";
@@ -99,12 +101,31 @@ function decodeAnswer(entry: Record<string, unknown>): Answer | undefined {
99
101
 
100
102
  /** OpenAI's `/v1/decisions` route. */
101
103
  export const openaiDecisionsWire: JudgmentWire = {
104
+ // https://developers.openai.com/api/reference/resources/decisions/methods/create/
105
+ images: { maxCount: 128 },
102
106
  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
- }),
107
+ encode: (request, model) => {
108
+ const state = typeof request.state === "string" ? request.state : JSON.stringify(request.state);
109
+ return {
110
+ model,
111
+ input: request.images?.length
112
+ ? [
113
+ {
114
+ role: "user",
115
+ content: [
116
+ { type: "input_text", text: state },
117
+ ...request.images.map(image => ({
118
+ type: "input_image",
119
+ image_url: embeddedImageDataUrl(image),
120
+ ...(image.detail !== undefined ? { detail: image.detail } : {}),
121
+ })),
122
+ ],
123
+ },
124
+ ]
125
+ : state,
126
+ questions: Object.entries(request.questions).map(([name, question]) => encodeQuestion(name, question)),
127
+ };
128
+ },
108
129
  decode(payload) {
109
130
  if (!isRecord(payload) || !Array.isArray(payload.answers)) return undefined;
110
131
  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,18 @@
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 { type JsonValue, type JudgmentRequest, tokenUsage } from "./types";
24
+ import { type DecodedJudgment, finiteNumber, type JudgmentImageAssets, type JudgmentWire } from "./wire";
20
25
 
21
26
  function decodeSystemOne(payload: unknown): DecodedJudgment | undefined {
22
27
  if (!isRecord(payload) || !isRecord(payload.answers)) return undefined;
@@ -29,6 +34,63 @@ function decodeSystemOne(payload: unknown): DecodedJudgment | undefined {
29
34
  };
30
35
  }
31
36
 
37
+ /**
38
+ * `state` with `items` added and every original value kept: arrays gain them at the end, objects
39
+ * under the first free `__omp_judgment_images[_N]` key, and a string root becomes `[state, ...items]`.
40
+ */
41
+ function stateWithImages(state: JudgmentRequest["state"], items: readonly JsonValue[]): JudgmentRequest["state"] {
42
+ if (Array.isArray(state)) return [...state, ...items];
43
+ if (!isRecord(state)) return [state, ...items];
44
+ const baseKey = "__omp_judgment_images";
45
+ let key = baseKey;
46
+ for (let suffix = 1; Object.hasOwn(state, key); suffix++) key = `${baseKey}_${suffix}`;
47
+ return { ...state, [key]: items };
48
+ }
49
+
50
+ function* jsonStrings(value: unknown): Generator<string> {
51
+ if (typeof value === "string") yield value;
52
+ else if (Array.isArray(value)) for (const item of value) yield* jsonStrings(item);
53
+ else if (isRecord(value)) for (const key in value) yield* jsonStrings(value[key]);
54
+ }
55
+
56
+ /**
57
+ * The subset of `assets` that `request` references. TypeSafe substitutes a reference wherever it
58
+ * occurs as text in a string of `state`, a question's `instructions`, or its `criteria` (never in
59
+ * object keys) and rejects a request carrying an unreferenced asset.
60
+ */
61
+ export function referencedImageAssets(request: JudgmentRequest, assets: JudgmentImageAssets): JudgmentImageAssets {
62
+ const pending = new Set(Object.keys(assets));
63
+ const visit = (value: unknown): void => {
64
+ for (const text of jsonStrings(value)) {
65
+ if (pending.size === 0) return;
66
+ for (const reference of pending) if (text.includes(reference)) pending.delete(reference);
67
+ }
68
+ };
69
+ visit(request.state);
70
+ for (const id in request.questions) {
71
+ const question = request.questions[id];
72
+ visit(question.instructions);
73
+ visit(question.criteria);
74
+ }
75
+ // `fromEntries` defines own properties, so a `__proto__` reference stays an ordinary key.
76
+ return Object.fromEntries(Object.entries(assets).filter(([reference]) => !pending.has(reference)));
77
+ }
78
+
79
+ interface SystemOneImageAsset {
80
+ type: "image";
81
+ format: "png" | "jpeg";
82
+ data_base64: string;
83
+ }
84
+
85
+ function encodeSystemOneImage(image: ImageContent): SystemOneImageAsset {
86
+ const mimeType = image.mimeType.toLowerCase();
87
+ return {
88
+ type: "image",
89
+ format: mimeType === "image/png" ? "png" : "jpeg",
90
+ data_base64: Buffer.from(image.data, "base64").toString("base64"),
91
+ };
92
+ }
93
+
32
94
  /** A System One dialect posting the verbatim request to `path` under the base URL with the key as bearer. */
33
95
  function systemOneWire(path: string): JudgmentWire {
34
96
  return {
@@ -39,13 +101,71 @@ function systemOneWire(path: string): JudgmentWire {
39
101
  }
40
102
 
41
103
  /** TypeSafe's own route, shared by every System One server. */
42
- export const typesafeWire = systemOneWire("/v1/systemone");
104
+ export const typesafeWire: JudgmentWire = {
105
+ ...systemOneWire("/v1/systemone"),
106
+ images: { mimeTypes: ["image/png", "image/jpeg"] },
107
+ supportsImageAssets: true,
108
+ imagesRequireOptIn: true,
109
+ imageHeaders: { "TypeSafe-Danger-Zone-Images": "true" },
110
+ ignoresImageDetail: true,
111
+ encode(request, model, imageAssets) {
112
+ const suppliedAssets = Object.entries(imageAssets ?? {});
113
+ const images = request.images ?? [];
114
+ if (suppliedAssets.length === 0 && images.length === 0) {
115
+ return { state: request.state, model, questions: request.questions };
116
+ }
43
117
 
44
- /** OpenRouter Decisions: the System One body under `/api/alpha`. */
45
- export const openrouterDecisionsWire = systemOneWire("/decisions");
118
+ const assets: Record<string, SystemOneImageAsset> = Object.fromEntries(
119
+ suppliedAssets.map(([reference, image]) => [reference, encodeSystemOneImage(image)]),
120
+ );
121
+ const references: string[] = [];
122
+ for (const image of images) {
123
+ let reference = crypto.randomUUID();
124
+ while (Object.hasOwn(assets, reference)) reference = crypto.randomUUID();
125
+ assets[reference] = encodeSystemOneImage(image);
126
+ references.push(reference);
127
+ }
128
+
129
+ return {
130
+ state: references.length ? stateWithImages(request.state, references) : request.state,
131
+ model,
132
+ questions: request.questions,
133
+ assets,
134
+ };
135
+ },
136
+ };
137
+
138
+ /** OpenRouter Decisions: System One body under `/api/alpha`, with image parts preserved in state. */
139
+ export const openrouterDecisionsWire: JudgmentWire = {
140
+ ...systemOneWire("/decisions"),
141
+ images: {},
142
+ imagesRequireOptIn: true,
143
+ encode(request, model) {
144
+ const imageParts: JsonValue[] = (request.images ?? []).map(image => ({
145
+ type: "image_url",
146
+ image_url: {
147
+ url: embeddedImageDataUrl(image),
148
+ ...(image.detail !== undefined ? { detail: image.detail === "original" ? "auto" : image.detail } : {}),
149
+ },
150
+ }));
151
+ return {
152
+ state: imageParts.length ? stateWithImages(request.state, imageParts) : request.state,
153
+ model,
154
+ questions: request.questions,
155
+ };
156
+ },
157
+ };
46
158
 
47
159
  /** Cloudflare Workers AI decision models (Clef) through the account-scoped `ai/run` route. */
48
160
  export const workersAiWire: JudgmentWire = {
161
+ // Clef's `images` extension: https://developers.cloudflare.com/workers-ai/models/clef/
162
+ images: {
163
+ mimeTypes: ["image/png", "image/jpeg", "image/webp"],
164
+ maxCount: 4,
165
+ maxBytes: 4 * 1024 * 1024,
166
+ maxTotalBytes: 8 * 1024 * 1024,
167
+ maxPixels: 16_000_000,
168
+ },
49
169
  target(baseUrl, model, key) {
50
170
  const credential = parseCloudflareCredential(key);
51
171
  const accountId = credential?.accountId ?? $env.CLOUDFLARE_ACCOUNT_ID?.trim();
@@ -60,6 +180,7 @@ export const workersAiWire: JudgmentWire = {
60
180
  state: request.state,
61
181
  model: model.slice(model.lastIndexOf("/") + 1),
62
182
  questions: request.questions,
183
+ ...(request.images?.length ? { images: request.images.map(embeddedImageDataUrl) } : {}),
63
184
  }),
64
185
  decode: payload => (isRecord(payload) ? decodeSystemOne(payload.result) : undefined),
65
186
  };
@@ -2,8 +2,8 @@
2
2
  * Text bridge: answers {@link Questions} with any model that completes text.
3
3
  *
4
4
  * Questions render into one system prompt asking for keyword answers (an
5
- * option label, `yes`/`no`, or a level number); the state is the user message,
6
- * so the question part stays byte-identical across calls and prompt caches
5
+ * option label, `yes`/`no`, or a level number); the state is the user text block,
6
+ * with separate image content blocks beside it, so the question part stays byte-identical
7
7
  * hit. Several questions batch into a single completion answered one line per
8
8
  * question id. Answers are one-hot: a parsed keyword yields probability 1 and
9
9
  * confidence 1, since a text completion carries no distribution.
@@ -14,7 +14,8 @@
14
14
  */
15
15
  import { escapeXmlAttribute, escapeXmlText, prompt } from "@linxiraos/pi-utils";
16
16
  import { YAML } from "bun";
17
- import type { Usage } from "../types";
17
+ import * as AIError from "../error";
18
+ import type { ImageContent, Usage } from "../types";
18
19
  import textJudgeRetryTemplate from "./text-judge-retry.md" with { type: "text" };
19
20
  import textJudgeStateTemplate from "./text-judge-state.md" with { type: "text" };
20
21
  import textJudgeTemplate from "./text-judge.md" with { type: "text" };
@@ -35,6 +36,8 @@ import {
35
36
  export interface TextPrompt {
36
37
  system: string;
37
38
  user: string;
39
+ /** Images are kept as distinct content blocks, never rendered into JSON text. */
40
+ images?: readonly ImageContent[];
38
41
  /** Format-correction attempt; chat backends may enforce tool suppression on the wire. */
39
42
  retry?: boolean;
40
43
  }
@@ -50,6 +53,8 @@ export interface TextBackend {
50
53
  readonly api: string;
51
54
  readonly provider: string;
52
55
  readonly model: string;
56
+ /** Whether this backend can send image content on the wire. */
57
+ readonly supportsImages?: boolean;
53
58
  /** State guard for agent-tuned chat models; on-device classifier workers may disable it. */
54
59
  readonly guardState?: boolean;
55
60
  /** Format-correction retries after a completion cannot be parsed. */
@@ -107,10 +112,11 @@ function renderStateField(key: string, value: JsonValue): string {
107
112
 
108
113
  /**
109
114
  * Render a request: the system prompt carries the preamble and question
110
- * definitions (constant across states, so prompt caches hit); the user message
111
- * carries XML-field state followed by the answer-format cue. Small models act
112
- * on a bare request (answer it, emit tool calls) instead of classifying it —
113
- * explicit tags and the trailing cue keep them on task.
115
+ * definitions (constant across states, so prompt caches hit); the user text
116
+ * carries XML-field state followed by the answer-format cue. Images remain
117
+ * separate content blocks. Small models act on a bare request (answer it,
118
+ * emit tool calls) instead of classifying it — explicit tags and the trailing
119
+ * cue keep them on task.
114
120
  */
115
121
  export function renderJudgmentPrompt(request: JudgmentRequest, options: { guardState?: boolean } = {}): TextPrompt {
116
122
  const questions: RenderedQuestion[] = [];
@@ -145,6 +151,7 @@ export function renderJudgmentPrompt(request: JudgmentRequest, options: { guardS
145
151
  multi,
146
152
  state: renderJudgmentState(request.state),
147
153
  }),
154
+ ...(request.images ? { images: request.images } : {}),
148
155
  };
149
156
  }
150
157
 
@@ -262,11 +269,13 @@ export function parseAnswer(id: string, question: Question, reply: string): Answ
262
269
  }
263
270
 
264
271
  export class TextJudge implements Judge {
272
+ readonly supportsImages?: boolean;
265
273
  readonly label: string;
266
274
  readonly #backend: TextBackend;
267
275
 
268
276
  constructor(backend: TextBackend) {
269
277
  this.#backend = backend;
278
+ if (backend.supportsImages === true) this.supportsImages = true;
270
279
  this.label = `${backend.provider}/${backend.model}`;
271
280
  }
272
281
 
@@ -276,6 +285,9 @@ export class TextJudge implements Judge {
276
285
  ): Promise<JudgmentResult<Q>> {
277
286
  const ids = Object.keys(request.questions);
278
287
  if (ids.length === 0) throw new Error("judgment request has no questions");
288
+ if (request.images?.length && this.#backend.supportsImages !== true) {
289
+ throw new AIError.ValidationError(`Text judgment backend ${this.label} does not support image inputs`);
290
+ }
279
291
  const rendered = renderJudgmentPrompt(request, { guardState: this.#backend.guardState });
280
292
  const retries = this.#backend.parseRetries ?? 0;
281
293
  for (let attempt = 0; ; attempt++) {
@@ -288,6 +300,7 @@ export class TextJudge implements Judge {
288
300
  multi: ids.length > 1,
289
301
  }),
290
302
  user: rendered.user,
303
+ ...(rendered.images ? { images: rendered.images } : {}),
291
304
  retry: true,
292
305
  };
293
306
  const completion = await this.#backend.complete(textPrompt, options);
@@ -11,7 +11,7 @@
11
11
  * bridge ({@link TextJudge}) renders the same questions into keyword prompts
12
12
  * for an ordinary chat model.
13
13
  */
14
- import type { Usage } from "../types";
14
+ import type { ImageContent, Usage } from "../types";
15
15
 
16
16
  /** JSON-compatible value; readonly containers are accepted so `as const` state passes through. */
17
17
  export type JsonValue = string | number | boolean | null | readonly JsonValue[] | { readonly [key: string]: JsonValue };
@@ -80,6 +80,8 @@ export type AnswerFor<Q extends Question> =
80
80
  export interface JudgmentRequest<Q extends Questions = Questions> {
81
81
  state: JudgmentState;
82
82
  questions: Q;
83
+ /** Images evaluated alongside the state; never part of the JSON state itself. */
84
+ images?: readonly ImageContent[];
83
85
  }
84
86
 
85
87
  export interface JudgmentResult<Q extends Questions = Questions> {
@@ -97,6 +99,8 @@ export interface JudgeOptions {
97
99
 
98
100
  /** Answers typed questions about a state. Implementations are stateless and safe to share. */
99
101
  export interface Judge {
102
+ /** Whether this backend can consume separate image content blocks. */
103
+ readonly supportsImages?: boolean;
100
104
  /** Backend description for logs, e.g. `typesafe/jev-latest`. */
101
105
  readonly label: string;
102
106
  judge<Q extends Questions>(request: JudgmentRequest<Q>, options?: JudgeOptions): Promise<JudgmentResult<Q>>;