@oh-my-pi/pi-ai 18.9.1 → 18.9.2

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 (37) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/dist/types/auth-gateway/dispatch.d.ts +5 -3
  3. package/dist/types/judgment/index.d.ts +1 -1
  4. package/dist/types/judgment/native.d.ts +80 -0
  5. package/dist/types/judgment/openai-decisions.d.ts +3 -0
  6. package/dist/types/judgment/systemone.d.ts +7 -0
  7. package/dist/types/judgment/types.d.ts +2 -2
  8. package/dist/types/judgment/wire.d.ts +36 -0
  9. package/dist/types/providers/transform-messages.d.ts +14 -0
  10. package/dist/types/registry/oauth/cloudflare-workers-ai.d.ts +3 -0
  11. package/dist/types/utils/retry-wait.d.ts +9 -0
  12. package/package.json +6 -6
  13. package/src/auth/sqlite-credential-store.ts +4 -4
  14. package/src/auth-gateway/dispatch.ts +7 -5
  15. package/src/auth-gateway/routes/systemone.ts +16 -12
  16. package/src/judgment/index.ts +1 -1
  17. package/src/judgment/native.ts +333 -0
  18. package/src/judgment/openai-decisions-predicate.md +9 -0
  19. package/src/judgment/openai-decisions.ts +129 -0
  20. package/src/judgment/systemone.ts +65 -0
  21. package/src/judgment/types.ts +2 -2
  22. package/src/judgment/wire.ts +42 -0
  23. package/src/providers/anthropic.ts +82 -70
  24. package/src/providers/cursor.ts +2 -6
  25. package/src/providers/devin.ts +20 -2
  26. package/src/providers/openai-responses.ts +7 -8
  27. package/src/providers/systemone-server.ts +1 -1
  28. package/src/providers/transform-messages.ts +146 -68
  29. package/src/registry/cloudflare-ai-gateway.ts +2 -2
  30. package/src/registry/hooks/api-key.ts +1 -0
  31. package/src/registry/oauth/cloudflare-ai-gateway.ts +2 -2
  32. package/src/registry/oauth/cloudflare-workers-ai.ts +32 -0
  33. package/src/usage/claude.ts +2 -6
  34. package/src/utils/empty-completion-retry.ts +2 -3
  35. package/src/utils/retry-wait.ts +19 -0
  36. package/dist/types/judgment/typesafe.d.ts +0 -49
  37. package/src/judgment/typesafe.ts +0 -183
@@ -0,0 +1,333 @@
1
+ /**
2
+ * Native judgment client: the {@link Judge} backend for catalog models whose
3
+ * API answers typed questions directly ({@link isJudgmentApi}).
4
+ *
5
+ * Each judgment API is one wire dialect: TypeSafe's System One route
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
9
+ * (`POST /v1/decisions`, translated both ways; `openai-decisions.ts`).
10
+ * Credentials flow through {@link withAuth}, so a stored key rotates on
11
+ * 401/403 exactly like chat providers; transient 429/5xx responses retry with
12
+ * bounded, `retry-after`-aware backoff. Endpoints that cap the questions per
13
+ * request (the catalog's `judgment-questions-max`, e.g. OpenJEV at 16) get
14
+ * oversized requests split into chunks under `maxQuestions`, at most
15
+ * {@link CHUNK_PARALLEL} in flight at once.
16
+ *
17
+ * Environment (mirrors the official TypeSafe SDK, for judges built without an
18
+ * explicit model): `TYPESAFE_API_KEY` is resolved by the auth registry
19
+ * (`rules/auth/typesafe.kdl`), `TYPESAFE_BASE_URL` overrides the API root,
20
+ * `TYPESAFE_DEFAULT_MODEL` the model.
21
+ */
22
+ import { resolveJudgmentQuestionsMax } from "@oh-my-pi/pi-catalog/compat/judgment-questions";
23
+ import { TYPESAFE_DEFAULT_BASE_URL } from "@oh-my-pi/pi-catalog/discovery";
24
+ import type { Api, FetchImpl } from "@oh-my-pi/pi-catalog/types";
25
+ import { $env, isRecord } from "@oh-my-pi/pi-utils";
26
+ import { type ApiKey, withAuth } from "../auth-retry";
27
+ import * as AIError from "../error";
28
+ import type { Model, Usage } from "../types";
29
+ import { getRetryAfterMsFromHeaders } from "../utils/retry-after";
30
+ import { openaiDecisionsWire } from "./openai-decisions";
31
+ import { openrouterDecisionsWire, typesafeWire, workersAiWire } from "./systemone";
32
+ import {
33
+ type Answer,
34
+ type Judge,
35
+ type JudgeOptions,
36
+ type JudgmentRequest,
37
+ type JudgmentResult,
38
+ type Questions,
39
+ tokenUsage,
40
+ } from "./types";
41
+ import type { JudgmentWire } from "./wire";
42
+
43
+ export const TYPESAFE_PROVIDER = "typesafe";
44
+ export const TYPESAFE_DEFAULT_MODEL = "jev-latest";
45
+
46
+ const JUDGMENT_WIRES = {
47
+ typesafe: typesafeWire,
48
+ "openrouter-decisions": openrouterDecisionsWire,
49
+ "workers-ai-systemone": workersAiWire,
50
+ "openai-decisions": openaiDecisionsWire,
51
+ } as const satisfies Partial<Record<Api, JudgmentWire>>;
52
+
53
+ /** APIs {@link NativeJudge} can serve. */
54
+ export type JudgmentApi = keyof typeof JUDGMENT_WIRES;
55
+
56
+ /** Whether a catalog API answers judgments natively. */
57
+ export function isJudgmentApi(api: Api): api is JudgmentApi {
58
+ return Object.hasOwn(JUDGMENT_WIRES, api);
59
+ }
60
+
61
+ /** `TYPESAFE_BASE_URL` when set, else the public API root; trailing slashes stripped. */
62
+ export function typesafeBaseUrl(): string {
63
+ return ($env.TYPESAFE_BASE_URL?.trim() || TYPESAFE_DEFAULT_BASE_URL).replace(/\/+$/, "");
64
+ }
65
+
66
+ /** `TYPESAFE_DEFAULT_MODEL` when set, else {@link TYPESAFE_DEFAULT_MODEL}. */
67
+ export function typesafeModel(): string {
68
+ return $env.TYPESAFE_DEFAULT_MODEL?.trim() || TYPESAFE_DEFAULT_MODEL;
69
+ }
70
+
71
+ /** Credential and transport overrides for a judge; see {@link createNativeJudge}. */
72
+ export interface NativeJudgeConnection {
73
+ apiKey: ApiKey;
74
+ /** Static headers attached to judgment requests (e.g. proxy routing, gateway auth). */
75
+ headers?: Record<string, string>;
76
+ fetch?: FetchImpl;
77
+ }
78
+
79
+ export interface NativeJudgeOptions extends NativeJudgeConnection {
80
+ /** Wire dialect; defaults to TypeSafe's own API. */
81
+ api?: JudgmentApi;
82
+ /** Catalog provider reported on results; defaults to {@link TYPESAFE_PROVIDER}. */
83
+ provider?: string;
84
+ /** Defaults to {@link typesafeBaseUrl}. */
85
+ baseUrl?: string;
86
+ /** Wire model id; defaults to {@link typesafeModel}. */
87
+ model?: string;
88
+ /** Per-attempt timeout; defaults to {@link DEFAULT_TIMEOUT_MS}. */
89
+ timeoutMs?: number;
90
+ /**
91
+ * Most questions per request the endpoint accepts, a positive integer; larger
92
+ * requests are split. Unset: no cap.
93
+ */
94
+ maxQuestions?: number;
95
+ }
96
+
97
+ /** Per-call options for {@link NativeJudge.judge}. */
98
+ export interface NativeJudgeCallOptions extends JudgeOptions {
99
+ /**
100
+ * Called before a split judgment rejects, with the summed usage of the chunks that had already
101
+ * succeeded: billed upstream, but their answers are discarded with the failure. Not called when
102
+ * no chunk succeeded or the request was not split.
103
+ */
104
+ onPartialUsage?: (usage: Usage) => void;
105
+ }
106
+
107
+ /** Non-2xx response from a native judgment API. */
108
+ export class JudgmentApiError extends AIError.ProviderHttpError {
109
+ override readonly name = "JudgmentApiError";
110
+ }
111
+
112
+ const DEFAULT_TIMEOUT_MS = 10_000;
113
+ const MAX_ATTEMPTS = 3;
114
+ const BACKOFF_BASE_MS = 500;
115
+ const BACKOFF_MAX_MS = 5_000;
116
+ /** Chunks of one split judgment in flight at once, so a split never bursts the endpoint unboundedly. */
117
+ const CHUNK_PARALLEL = 4;
118
+
119
+ /** Server hint wins (capped); otherwise exponential backoff from {@link BACKOFF_BASE_MS}. */
120
+ function backoffMs(attempt: number, headers: Headers | undefined): number {
121
+ const hinted = headers === undefined ? undefined : getRetryAfterMsFromHeaders(headers);
122
+ if (hinted !== undefined) return Math.min(hinted, BACKOFF_MAX_MS);
123
+ return Math.min(BACKOFF_BASE_MS * 2 ** attempt, BACKOFF_MAX_MS);
124
+ }
125
+
126
+ /** Whether `value` is a wire answer of `type`. Answers are trusted past their discriminant, as forwarded verbatim. */
127
+ function isAnswerOf(value: unknown, type: Answer["type"]): value is Answer {
128
+ return isRecord(value) && value.type === type;
129
+ }
130
+
131
+ export class NativeJudge implements Judge {
132
+ readonly label: string;
133
+ readonly api: JudgmentApi;
134
+ readonly provider: string;
135
+ /** Wire model id sent with every request. */
136
+ readonly model: string;
137
+ readonly baseUrl: string;
138
+ readonly #wire: JudgmentWire;
139
+ readonly #apiKey: ApiKey;
140
+ readonly #headers: Record<string, string> | undefined;
141
+ readonly #fetch: FetchImpl;
142
+ readonly #timeoutMs: number;
143
+ readonly #maxQuestions: number | undefined;
144
+
145
+ constructor(options: NativeJudgeOptions) {
146
+ this.#apiKey = options.apiKey;
147
+ this.api = options.api ?? TYPESAFE_PROVIDER;
148
+ this.#wire = JUDGMENT_WIRES[this.api];
149
+ this.provider = options.provider ?? TYPESAFE_PROVIDER;
150
+ this.baseUrl = (options.baseUrl ?? typesafeBaseUrl()).replace(/\/+$/, "");
151
+ this.model = options.model ?? typesafeModel();
152
+ this.#headers = options.headers;
153
+ this.#fetch = options.fetch ?? fetch;
154
+ this.#timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
155
+ if (options.maxQuestions !== undefined && !(Number.isInteger(options.maxQuestions) && options.maxQuestions > 0)) {
156
+ throw new RangeError(`NativeJudge maxQuestions must be a positive integer, got ${options.maxQuestions}`);
157
+ }
158
+ this.#maxQuestions = options.maxQuestions;
159
+ this.label = `${this.provider}/${this.model}`;
160
+ }
161
+
162
+ async judge<Q extends Questions>(
163
+ request: JudgmentRequest<Q>,
164
+ options?: NativeJudgeCallOptions,
165
+ ): Promise<JudgmentResult<Q>> {
166
+ const ids = Object.keys(request.questions);
167
+ const cap = this.#maxQuestions;
168
+ if (cap !== undefined && ids.length > cap) return this.#judgeChunked(request, ids, cap, options);
169
+ return this.#judgeOnce(request, options);
170
+ }
171
+
172
+ /**
173
+ * Answer `request` in requests of at most `cap` questions each, {@link CHUNK_PARALLEL} at a time;
174
+ * answers merge and usage sums. The first failing chunk aborts its in-flight siblings and stops
175
+ * dispatch, so a doomed judgment stops spending; once every chunk settles, the usage of those that
176
+ * succeeded goes to `onPartialUsage` before the judgment rejects.
177
+ */
178
+ async #judgeChunked<Q extends Questions>(
179
+ request: JudgmentRequest<Q>,
180
+ ids: string[],
181
+ cap: number,
182
+ options: NativeJudgeCallOptions | undefined,
183
+ ): Promise<JudgmentResult<Q>> {
184
+ const chunks: Questions[] = [];
185
+ for (let start = 0; start < ids.length; start += cap) {
186
+ const questions: Questions = {};
187
+ for (const id of ids.slice(start, start + cap)) questions[id] = request.questions[id];
188
+ chunks.push(questions);
189
+ }
190
+ const failed = new AbortController();
191
+ const signal = options?.signal ? AbortSignal.any([options.signal, failed.signal]) : failed.signal;
192
+ const chunkOptions = { signal };
193
+ const results: JudgmentResult[] = [];
194
+ let next = 0;
195
+ const drain = async (): Promise<void> => {
196
+ while (next < chunks.length && !signal.aborted) {
197
+ const index = next++;
198
+ try {
199
+ results[index] = await this.#judgeOnce({ state: request.state, questions: chunks[index] }, chunkOptions);
200
+ } catch (error) {
201
+ failed.abort(error);
202
+ throw error;
203
+ }
204
+ }
205
+ };
206
+ // Settle every worker, not just the first failure, so no sibling's billed result goes uncounted.
207
+ await Promise.allSettled(Array.from({ length: Math.min(CHUNK_PARALLEL, chunks.length) }, drain));
208
+ const answers: Record<string, Answer> = {};
209
+ let settled = 0;
210
+ let input = 0;
211
+ let output = 0;
212
+ let cost = 0;
213
+ for (const result of results) {
214
+ if (result === undefined) continue;
215
+ settled++;
216
+ Object.assign(answers, result.answers);
217
+ input += result.usage.input;
218
+ output += result.usage.output;
219
+ cost += result.usage.cost.total;
220
+ }
221
+ // Every chunk failure aborts `failed`, so `signal` carries the first failure or the caller's abort.
222
+ if (signal.aborted) {
223
+ if (settled > 0) options?.onPartialUsage?.(tokenUsage(input, output, cost));
224
+ throw signal.reason;
225
+ }
226
+ return {
227
+ api: this.api,
228
+ provider: this.provider,
229
+ model: results[0].model,
230
+ answers: answers as JudgmentResult<Q>["answers"],
231
+ usage: tokenUsage(input, output, cost),
232
+ };
233
+ }
234
+
235
+ async #judgeOnce<Q extends Questions>(
236
+ request: JudgmentRequest<Q>,
237
+ options: JudgeOptions | undefined,
238
+ ): Promise<JudgmentResult<Q>> {
239
+ const body = JSON.stringify(this.#wire.encode(request, this.model));
240
+ const signal = options?.signal;
241
+ const payload = await withAuth(this.#apiKey, key => this.#attempt(body, key, signal), { signal });
242
+ const decoded = this.#wire.decode(payload);
243
+ if (!decoded) {
244
+ throw new AIError.ProviderResponseError(`${this.label} returned a malformed judgment response`, {
245
+ provider: this.provider,
246
+ kind: "envelope",
247
+ });
248
+ }
249
+ const answers: Record<string, Answer> = {};
250
+ for (const id in request.questions) {
251
+ const type = request.questions[id].type;
252
+ if (decoded.refused.includes(id)) {
253
+ throw new AIError.ProviderResponseError(`${this.label} refused to answer question "${id}"`, {
254
+ provider: this.provider,
255
+ kind: "content-blocked",
256
+ });
257
+ }
258
+ const answer = decoded.answers[id];
259
+ if (!isAnswerOf(answer, type)) {
260
+ throw new AIError.ProviderResponseError(
261
+ `${this.label} response is missing a "${type}" answer for question "${id}"`,
262
+ { provider: this.provider, kind: "envelope" },
263
+ );
264
+ }
265
+ answers[id] = answer;
266
+ }
267
+ return {
268
+ api: this.api,
269
+ provider: this.provider,
270
+ model: decoded.model ?? this.model,
271
+ // Each id was checked against its question's type above.
272
+ answers: answers as JudgmentResult<Q>["answers"],
273
+ usage: decoded.usage,
274
+ };
275
+ }
276
+
277
+ async #attempt(body: string, key: string, signal: AbortSignal | undefined): Promise<unknown> {
278
+ const target = this.#wire.target(this.baseUrl, this.model, key);
279
+ const headers: Record<string, string> = {
280
+ ...this.#headers,
281
+ Authorization: `Bearer ${target.token}`,
282
+ Accept: "application/json",
283
+ "Content-Type": "application/json",
284
+ };
285
+ for (let attempt = 0; ; attempt++) {
286
+ signal?.throwIfAborted();
287
+ const timeout = AbortSignal.timeout(this.#timeoutMs);
288
+ let response: Response;
289
+ try {
290
+ response = await this.#fetch(target.url, {
291
+ method: "POST",
292
+ headers,
293
+ body,
294
+ signal: signal ? AbortSignal.any([signal, timeout]) : timeout,
295
+ });
296
+ } catch (error) {
297
+ if (signal?.aborted || attempt + 1 >= MAX_ATTEMPTS) throw error;
298
+ await Bun.sleep(backoffMs(attempt, undefined));
299
+ continue;
300
+ }
301
+ if (response.ok) return await response.json();
302
+ const text = await response.text();
303
+ const error = new JudgmentApiError(`${this.label} API error (${response.status}): ${text}`, response.status, {
304
+ headers: response.headers,
305
+ });
306
+ const transient = response.status === 408 || response.status === 429 || response.status >= 500;
307
+ if (!transient || attempt + 1 >= MAX_ATTEMPTS) throw error;
308
+ await Bun.sleep(backoffMs(attempt, response.headers));
309
+ }
310
+ }
311
+ }
312
+
313
+ /**
314
+ * The {@link NativeJudge} for a catalog judge model: its API's dialect, wire id
315
+ * (`requestModelId ?? id`), base URL, and catalog question cap
316
+ * (`judgment-questions-max`). Shared by the coding agent's judge chain and the
317
+ * auth-gateway System One route so both address a model the same way.
318
+ *
319
+ * @throws ConfigurationError when the model's API is not a judgment API.
320
+ */
321
+ export function createNativeJudge(model: Model, connection: NativeJudgeConnection): NativeJudge {
322
+ if (!isJudgmentApi(model.api)) {
323
+ throw new AIError.ConfigurationError(`${model.provider}/${model.id} does not answer native judgments`);
324
+ }
325
+ return new NativeJudge({
326
+ ...connection,
327
+ api: model.api,
328
+ provider: model.provider,
329
+ model: model.requestModelId ?? model.id,
330
+ baseUrl: model.baseUrl,
331
+ maxQuestions: resolveJudgmentQuestionsMax(model),
332
+ });
333
+ }
@@ -0,0 +1,9 @@
1
+ {{{instructions}}}
2
+ {{#if yes}}
3
+
4
+ True means: {{{yes}}}
5
+ {{/if}}
6
+ {{#if no}}
7
+
8
+ False means: {{{no}}}
9
+ {{/if}}
@@ -0,0 +1,129 @@
1
+ /**
2
+ * OpenAI Decisions dialect (`POST {baseUrl}/decisions`, `gpt-6-luna`).
3
+ *
4
+ * The API asks the same three question kinds as System One under other
5
+ * names, as an ordered list instead of an id map, so requests and answers are
6
+ * translated both ways:
7
+ *
8
+ * | System One | Decisions |
9
+ * | ------------------------------------------- | --------------------------------------------- |
10
+ * | `state` (text or JSON) | `input` (text; JSON state is serialized) |
11
+ * | question id | `name`, echoed on its answer |
12
+ * | `noul` + optional yes/no `criteria` | `predicate`; criteria fold into instructions |
13
+ * | `choice` `criteria` option → rubric | `choices: [{ value, description? }]` |
14
+ * | `score` `criteria` level descriptions | `levels: [{ label }]`, lowest first |
15
+ * | `noul` answer `noul` | `predicate` answer `probability` |
16
+ * | `probabilities` map by option / level index | `probabilities: [{ value, probability }]` |
17
+ *
18
+ * A `refusal` answer marks a question the model declined. Billing is input
19
+ * tokens only, so cost is priced from the catalog model.
20
+ */
21
+ import { isRecord, prompt } from "@oh-my-pi/pi-utils";
22
+ import predicateTemplate from "./openai-decisions-predicate.md" with { type: "text" };
23
+ import { type Answer, type Question, tokenUsage } from "./types";
24
+ import { finiteNumber, type JudgmentWire } from "./wire";
25
+
26
+ type DecisionQuestion =
27
+ | { type: "predicate"; name: string; instructions: string }
28
+ | { type: "choice"; name: string; instructions: string; choices: { value: string; description?: string }[] }
29
+ | { type: "score"; name: string; instructions: string; levels: { label: string }[] };
30
+
31
+ function encodeQuestion(name: string, question: Question): DecisionQuestion {
32
+ switch (question.type) {
33
+ case "noul":
34
+ return {
35
+ type: "predicate",
36
+ name,
37
+ instructions: question.criteria
38
+ ? prompt.render(predicateTemplate, {
39
+ instructions: question.instructions,
40
+ yes: question.criteria.true,
41
+ no: question.criteria.false,
42
+ })
43
+ : question.instructions,
44
+ };
45
+ case "choice":
46
+ return {
47
+ type: "choice",
48
+ name,
49
+ instructions: question.instructions,
50
+ choices: Object.entries(question.criteria).map(([value, description]) =>
51
+ description === null ? { value } : { value, description },
52
+ ),
53
+ };
54
+ case "score":
55
+ return {
56
+ type: "score",
57
+ name,
58
+ instructions: question.instructions,
59
+ levels: question.criteria.map(label => ({ label })),
60
+ };
61
+ }
62
+ }
63
+
64
+ /** `[{ value, probability }]` as a probability map keyed by the stringified value; `undefined` when malformed. */
65
+ function probabilityMap(value: unknown): Record<string, number> | undefined {
66
+ if (!Array.isArray(value)) return undefined;
67
+ const map: Record<string, number> = {};
68
+ for (const entry of value) {
69
+ if (!isRecord(entry) || typeof entry.probability !== "number") return undefined;
70
+ if (typeof entry.value !== "string" && typeof entry.value !== "boolean" && typeof entry.value !== "number") {
71
+ return undefined;
72
+ }
73
+ map[String(entry.value)] = entry.probability;
74
+ }
75
+ return map;
76
+ }
77
+
78
+ /** The System One answer for one Decisions answer; `undefined` for refusals and malformed entries. */
79
+ function decodeAnswer(entry: Record<string, unknown>): Answer | undefined {
80
+ switch (entry.type) {
81
+ case "predicate":
82
+ return typeof entry.probability === "number" ? { type: "noul", noul: entry.probability } : undefined;
83
+ case "choice": {
84
+ const probabilities = probabilityMap(entry.probabilities);
85
+ if (!probabilities || typeof entry.confidence !== "number") return undefined;
86
+ if (typeof entry.choice !== "string" && typeof entry.choice !== "boolean") return undefined;
87
+ return { type: "choice", choice: String(entry.choice), probabilities, confidence: entry.confidence };
88
+ }
89
+ case "score": {
90
+ const probabilities = probabilityMap(entry.probabilities);
91
+ if (!probabilities || typeof entry.score !== "number" || typeof entry.confidence !== "number")
92
+ return undefined;
93
+ return { type: "score", score: entry.score, probabilities, confidence: entry.confidence };
94
+ }
95
+ default:
96
+ return undefined;
97
+ }
98
+ }
99
+
100
+ /** OpenAI's `/v1/decisions` route. */
101
+ export const openaiDecisionsWire: JudgmentWire = {
102
+ 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
+ }),
108
+ decode(payload) {
109
+ if (!isRecord(payload) || !Array.isArray(payload.answers)) return undefined;
110
+ const answers: Record<string, Answer> = {};
111
+ const refused: string[] = [];
112
+ for (const entry of payload.answers) {
113
+ if (!isRecord(entry) || typeof entry.name !== "string") continue;
114
+ if (entry.type === "refusal") {
115
+ refused.push(entry.name);
116
+ continue;
117
+ }
118
+ const answer = decodeAnswer(entry);
119
+ if (answer) answers[entry.name] = answer;
120
+ }
121
+ const usage = isRecord(payload.usage) ? payload.usage : {};
122
+ return {
123
+ model: typeof payload.model === "string" ? payload.model : undefined,
124
+ answers,
125
+ refused,
126
+ usage: tokenUsage(finiteNumber(usage.input_tokens), finiteNumber(usage.output_tokens)),
127
+ };
128
+ },
129
+ };
@@ -0,0 +1,65 @@
1
+ /**
2
+ * TypeSafe System One dialects. The request body (`{ model, state, questions }`)
3
+ * and answer map are TypeSafe's own, forwarded verbatim:
4
+ *
5
+ * - `typesafe`: `POST {baseUrl}/v1/systemone` — TypeSafe and every System One
6
+ * server (OpenJEV, Kev, self-hosted Clef, OpenCode Zen, Command Code).
7
+ * - `openrouter-decisions`: `POST {baseUrl}/decisions` under OpenRouter's
8
+ * `/api/alpha`; usage adds the billed `cost` in USD.
9
+ * - `workers-ai-systemone`: Cloudflare Workers AI's `POST {baseUrl}/{model}`
10
+ * (`/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.
14
+ */
15
+ import { parseCloudflareCredential } from "@oh-my-pi/pi-catalog/wire/cloudflare";
16
+ import { $env, isRecord } from "@oh-my-pi/pi-utils";
17
+ import * as AIError from "../error";
18
+ import { tokenUsage } from "./types";
19
+ import { type DecodedJudgment, finiteNumber, type JudgmentWire } from "./wire";
20
+
21
+ function decodeSystemOne(payload: unknown): DecodedJudgment | undefined {
22
+ if (!isRecord(payload) || !isRecord(payload.answers)) return undefined;
23
+ const usage = isRecord(payload.usage) ? payload.usage : {};
24
+ return {
25
+ model: typeof payload.model === "string" ? payload.model : undefined,
26
+ answers: payload.answers,
27
+ refused: [],
28
+ usage: tokenUsage(finiteNumber(usage.input_tokens), finiteNumber(usage.output_tokens), finiteNumber(usage.cost)),
29
+ };
30
+ }
31
+
32
+ /** A System One dialect posting the verbatim request to `path` under the base URL with the key as bearer. */
33
+ function systemOneWire(path: string): JudgmentWire {
34
+ return {
35
+ target: (baseUrl, _model, key) => ({ url: `${baseUrl}${path}`, token: key }),
36
+ encode: (request, model) => ({ state: request.state, model, questions: request.questions }),
37
+ decode: decodeSystemOne,
38
+ };
39
+ }
40
+
41
+ /** TypeSafe's own route, shared by every System One server. */
42
+ export const typesafeWire = systemOneWire("/v1/systemone");
43
+
44
+ /** OpenRouter Decisions: the System One body under `/api/alpha`. */
45
+ export const openrouterDecisionsWire = systemOneWire("/decisions");
46
+
47
+ /** Cloudflare Workers AI decision models (Clef) through the account-scoped `ai/run` route. */
48
+ export const workersAiWire: JudgmentWire = {
49
+ target(baseUrl, model, key) {
50
+ const credential = parseCloudflareCredential(key);
51
+ const accountId = credential?.accountId ?? $env.CLOUDFLARE_ACCOUNT_ID?.trim();
52
+ if (!accountId) {
53
+ throw new AIError.ConfigurationError(
54
+ "Cloudflare account ID is required: log in to Cloudflare Workers AI again or set CLOUDFLARE_ACCOUNT_ID",
55
+ );
56
+ }
57
+ return { url: `${baseUrl.replace("<account>", accountId)}/${model}`, token: credential?.token ?? key };
58
+ },
59
+ encode: (request, model) => ({
60
+ state: request.state,
61
+ model: model.slice(model.lastIndexOf("/") + 1),
62
+ questions: request.questions,
63
+ }),
64
+ decode: payload => (isRecord(payload) ? decodeSystemOne(payload.result) : undefined),
65
+ };
@@ -7,7 +7,7 @@
7
7
  * — about one {@link JudgmentState}. Every question in a request sees the same
8
8
  * state and is answered independently, so callers batch independent questions
9
9
  * into one call. The shape mirrors TypeSafe's System One API so the native
10
- * backend ({@link TypeSafeJudge}) forwards requests verbatim, while the text
10
+ * backend ({@link NativeJudge}) forwards requests verbatim, while the text
11
11
  * bridge ({@link TextJudge}) renders the same questions into keyword prompts
12
12
  * for an ordinary chat model.
13
13
  */
@@ -83,7 +83,7 @@ export interface JudgmentRequest<Q extends Questions = Questions> {
83
83
  }
84
84
 
85
85
  export interface JudgmentResult<Q extends Questions = Questions> {
86
- /** Transport that produced the answers (`typesafe`, or the chat model's api). */
86
+ /** Transport that produced the answers (a judgment api such as `typesafe`, or the chat model's api). */
87
87
  api: string;
88
88
  provider: string;
89
89
  model: string;
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Wire dialects of the native judgment APIs: how {@link NativeJudge} addresses,
3
+ * encodes, and decodes one request for each API it serves. Internal to the
4
+ * judgment module; `native.ts` maps every judgment API to its dialect.
5
+ */
6
+ import type { Usage } from "../types";
7
+ import type { JudgmentRequest } from "./types";
8
+
9
+ /** Where one attempt posts and which bearer token it sends. */
10
+ export interface JudgmentTarget {
11
+ url: string;
12
+ token: string;
13
+ }
14
+
15
+ /** One decoded response. */
16
+ export interface DecodedJudgment {
17
+ /** Model that answered, when the response names one. */
18
+ model: string | undefined;
19
+ /** Wire answers keyed by question id; {@link NativeJudge} checks each against its question's type. */
20
+ answers: Readonly<Record<string, unknown>>;
21
+ /** Question ids the model declined to answer. */
22
+ refused: readonly string[];
23
+ usage: Usage;
24
+ }
25
+
26
+ /** How one judgment API addresses, encodes, and decodes a request. */
27
+ export interface JudgmentWire {
28
+ /**
29
+ * Target of one attempt made with credential `key` against `model`.
30
+ * Throws when the credential cannot address the endpoint (e.g. no account id).
31
+ */
32
+ target(baseUrl: string, model: string, key: string): JudgmentTarget;
33
+ /** Request body for `model`. */
34
+ encode(request: JudgmentRequest, model: string): unknown;
35
+ /** The response's answers and usage; `undefined` when the payload is not this API's response shape. */
36
+ decode(payload: unknown): DecodedJudgment | undefined;
37
+ }
38
+
39
+ /** `value` when it is a finite number. */
40
+ export function finiteNumber(value: unknown): number | undefined {
41
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
42
+ }