@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.
- package/CHANGELOG.md +83 -0
- package/README.md +4 -2
- package/THIRD-PARTY-NOTICES.txt +55 -5
- package/dist/types/auth/cascade.d.ts +5 -3
- package/dist/types/auth/pool.d.ts +3 -3
- package/dist/types/auth/select.d.ts +5 -1
- package/dist/types/auth/types.d.ts +15 -5
- package/dist/types/auth/usage-cache.d.ts +3 -1
- package/dist/types/auth-gateway/clients.d.ts +67 -0
- package/dist/types/auth-gateway/dispatch.d.ts +28 -10
- package/dist/types/auth-gateway/http.d.ts +2 -0
- package/dist/types/auth-gateway/index.d.ts +1 -0
- package/dist/types/auth-gateway/server.d.ts +9 -4
- package/dist/types/auth-gateway/session-state.d.ts +6 -0
- package/dist/types/auth-gateway/types.d.ts +16 -2
- package/dist/types/dialect/coercion.d.ts +6 -0
- package/dist/types/error/classes.d.ts +1 -1
- package/dist/types/error/flags.d.ts +15 -0
- package/dist/types/error/index.d.ts +1 -0
- package/dist/types/error/network-cause.d.ts +40 -0
- package/dist/types/images/shared.d.ts +26 -2
- package/dist/types/index.d.ts +1 -0
- package/dist/types/judgment/native.d.ts +51 -3
- package/dist/types/judgment/systemone.d.ts +9 -2
- package/dist/types/judgment/text.d.ts +11 -5
- package/dist/types/judgment/types.d.ts +5 -1
- package/dist/types/judgment/wire.d.ts +16 -3
- package/dist/types/provider-session-state.d.ts +2 -2
- package/dist/types/providers/anthropic-images.d.ts +7 -0
- package/dist/types/providers/anthropic-signature.d.ts +12 -2
- package/dist/types/providers/anthropic-state.d.ts +7 -0
- package/dist/types/providers/anthropic.d.ts +21 -6
- package/dist/types/providers/connection-prewarm.d.ts +15 -0
- package/dist/types/providers/cursor/exec-modern.d.ts +1 -1
- package/dist/types/providers/cursor-pi-args.d.ts +13 -0
- package/dist/types/providers/cursor.d.ts +7 -0
- package/dist/types/providers/openai-codex-responses.d.ts +6 -0
- package/dist/types/providers/openai-responses.d.ts +9 -0
- package/dist/types/providers/openai-shared.d.ts +30 -0
- package/dist/types/providers/systemone-server.d.ts +7 -3
- package/dist/types/providers/transform-messages.d.ts +26 -5
- package/dist/types/registry/oauth/google-antigravity.d.ts +1 -1
- package/dist/types/stream.d.ts +0 -2
- package/dist/types/types.d.ts +33 -9
- package/dist/types/utils/dsml-leak.d.ts +4 -2
- package/dist/types/utils/narration.d.ts +9 -0
- package/dist/types/utils/openai-http.d.ts +2 -0
- package/dist/types/utils/request-body.d.ts +11 -0
- package/dist/types/utils/request-compression.d.ts +21 -0
- package/dist/types/utils/tool-call-arguments.d.ts +2 -1
- package/dist/types/utils/transport-fetch.d.ts +8 -0
- package/dist/types/utils.d.ts +1 -1
- package/package.json +8 -8
- package/src/auth/cascade.ts +23 -13
- package/src/auth/pool.ts +3 -3
- package/src/auth/refresh.ts +39 -12
- package/src/auth/select.ts +8 -2
- package/src/auth/types.ts +16 -5
- package/src/auth/usage-cache.ts +23 -7
- package/src/auth/usage.ts +2 -4
- package/src/auth-broker/remote-store.ts +31 -6
- package/src/auth-gateway/clients.ts +318 -0
- package/src/auth-gateway/dispatch.ts +66 -35
- package/src/auth-gateway/http.ts +11 -5
- package/src/auth-gateway/index.ts +1 -0
- package/src/auth-gateway/routes/embeddings.ts +4 -10
- package/src/auth-gateway/routes/images.ts +6 -10
- package/src/auth-gateway/routes/rerank.ts +4 -10
- package/src/auth-gateway/routes/speech.ts +10 -10
- package/src/auth-gateway/routes/systemone.ts +22 -12
- package/src/auth-gateway/routes/transcriptions.ts +4 -2
- package/src/auth-gateway/routes/video.ts +12 -15
- package/src/auth-gateway/server.ts +136 -53
- package/src/auth-gateway/session-state.ts +14 -0
- package/src/auth-gateway/types.ts +16 -2
- package/src/dialect/coercion.ts +16 -0
- package/src/dialect/hermes.ts +7 -11
- package/src/dialect/qwen3.ts +3 -11
- package/src/error/classes.ts +3 -2
- package/src/error/flags.ts +24 -3
- package/src/error/index.ts +1 -0
- package/src/error/network-cause.ts +179 -0
- package/src/images/codex-format-directive.md +3 -0
- package/src/images/google-antigravity.ts +2 -1
- package/src/images/google-generative-ai.ts +4 -2
- package/src/images/openai-hosted.ts +25 -2
- package/src/images/openai-images.ts +11 -6
- package/src/images/shared.ts +77 -3
- package/src/index.ts +1 -0
- package/src/judgment/chat.ts +17 -1
- package/src/judgment/native.ts +182 -12
- package/src/judgment/openai-decisions.ts +27 -6
- package/src/judgment/systemone.ts +131 -10
- package/src/judgment/text.ts +20 -7
- package/src/judgment/types.ts +5 -1
- package/src/judgment/wire.ts +17 -3
- package/src/provider-session-state.ts +2 -2
- package/src/providers/amazon-bedrock.ts +4 -1
- package/src/providers/anthropic-client.ts +1 -1
- package/src/providers/anthropic-images.ts +263 -0
- package/src/providers/anthropic-messages-server.ts +84 -1
- package/src/providers/anthropic-signature.ts +35 -8
- package/src/providers/anthropic-state.ts +13 -0
- package/src/providers/anthropic.ts +479 -397
- package/src/providers/connection-prewarm.ts +78 -0
- package/src/providers/cowork-fetch.ts +5 -1
- package/src/providers/cursor/exec-modern.ts +1 -0
- package/src/providers/cursor-pi-args.ts +19 -0
- package/src/providers/cursor.ts +53 -13
- package/src/providers/devin.ts +10 -4
- package/src/providers/factory-droid/gemini.ts +9 -3
- package/src/providers/google-gemini-cli.ts +11 -1
- package/src/providers/google-shared.ts +2 -6
- package/src/providers/images-server.ts +6 -1
- package/src/providers/ollama.ts +15 -5
- package/src/providers/openai-anthropic-shim.ts +1 -0
- package/src/providers/openai-chat-server.ts +29 -0
- package/src/providers/openai-codex/access-programs.ts +2 -1
- package/src/providers/openai-codex-responses.ts +49 -82
- package/src/providers/openai-completions.ts +117 -19
- package/src/providers/openai-responses.ts +25 -2
- package/src/providers/openai-shared.ts +36 -5
- package/src/providers/pi-native-server.ts +1 -0
- package/src/providers/systemone-server.ts +72 -12
- package/src/providers/transform-messages.ts +95 -51
- package/src/registry/oauth/google-antigravity.ts +1 -1
- package/src/stream.ts +13 -73
- package/src/types.ts +33 -9
- package/src/usage/claude.ts +2 -9
- package/src/usage/github-copilot.ts +26 -4
- package/src/usage/openai-codex.ts +13 -7
- package/src/usage/opencode-go.ts +7 -8
- package/src/usage/xai-oauth.ts +2 -9
- package/src/utils/dsml-leak.ts +30 -14
- package/src/utils/leaked-thinking-stream.ts +10 -0
- package/src/utils/narration.ts +18 -0
- package/src/utils/openai-http.ts +27 -18
- package/src/utils/request-body.ts +27 -0
- package/src/utils/request-compression.ts +202 -0
- package/src/utils/request-debug.ts +23 -9
- package/src/utils/schema/dereference.ts +51 -18
- package/src/utils/schema/draft.ts +10 -2
- package/src/utils/schema/json-schema-validator.ts +4 -10
- package/src/utils/schema/normalize.ts +6 -9
- package/src/utils/tool-call-arguments.ts +2 -1
- package/src/utils/transport-fetch.ts +37 -0
- package/src/utils/validation.ts +26 -16
- package/src/utils.ts +38 -23
package/src/judgment/native.ts
CHANGED
|
@@ -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
|
|
8
|
-
*
|
|
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
|
|
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
|
|
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({
|
|
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:
|
|
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(
|
|
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`
|
|
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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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
|
|
3
|
-
*
|
|
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`),
|
|
12
|
-
*
|
|
13
|
-
*
|
|
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 {
|
|
19
|
-
import
|
|
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 =
|
|
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
|
-
|
|
45
|
-
|
|
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
|
};
|
package/src/judgment/text.ts
CHANGED
|
@@ -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
|
|
6
|
-
* so the question part stays byte-identical
|
|
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
|
|
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
|
|
111
|
-
* carries XML-field state followed by the answer-format cue.
|
|
112
|
-
* on a bare request (answer it,
|
|
113
|
-
* explicit tags and the trailing
|
|
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);
|
package/src/judgment/types.ts
CHANGED
|
@@ -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>>;
|