toolnexus 0.25.0 → 0.27.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/README.md +35 -0
- package/dist/acp.d.ts +10 -2
- package/dist/acp.js +36 -5
- package/dist/classifier.d.ts +199 -31
- package/dist/classifier.js +639 -31
- package/dist/client.d.ts +6 -0
- package/dist/judge.d.ts +6 -3
- package/dist/judge.js +13 -1
- package/dist/mcp.js +16 -6
- package/dist/mcp2026/source.d.ts +5 -0
- package/dist/mcp2026/source.js +11 -2
- package/dist/mcp2026/stdio.d.ts +43 -0
- package/dist/mcp2026/stdio.js +144 -20
- package/dist/mcphandler.js +2 -4
- package/dist/mcpserve.js +2 -1
- package/dist/serve.js +12 -1
- package/dist/skill.d.ts +14 -0
- package/dist/skill.js +33 -2
- package/package.json +2 -2
- package/src/acp.ts +59 -7
- package/src/classifier.ts +723 -47
- package/src/client.ts +6 -0
- package/src/judge.ts +19 -4
- package/src/mcp.ts +18 -6
- package/src/mcp2026/source.ts +14 -4
- package/src/mcp2026/stdio.ts +148 -23
- package/src/mcphandler.ts +2 -3
- package/src/mcpserve.ts +2 -1
- package/src/serve.ts +13 -2
- package/src/skill.ts +51 -2
package/src/classifier.ts
CHANGED
|
@@ -9,9 +9,11 @@
|
|
|
9
9
|
* the declared schema. Numeric limits, permission checks and allowlists stay in code. Nothing
|
|
10
10
|
* here is a security control.
|
|
11
11
|
*/
|
|
12
|
+
import { createHash } from "node:crypto"
|
|
12
13
|
import type { Client } from "./client.js"
|
|
13
14
|
import { type ErrorInfo, type ErrorTier, type MetricEvent, redactErrorBody, capErrorBody } from "./client.js"
|
|
14
15
|
import { retryAfterMs, isRetryableStatus } from "./retry.js"
|
|
16
|
+
import { isContentPart, type ContentPart } from "./content.js"
|
|
15
17
|
|
|
16
18
|
// ---------------------------------------------------------------- constants
|
|
17
19
|
|
|
@@ -29,14 +31,121 @@ export const DEFAULT_BATCH_CONCURRENCY = 16
|
|
|
29
31
|
/**
|
|
30
32
|
* A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
|
|
31
33
|
* a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
|
|
32
|
-
* `typesafe/jev-1.
|
|
34
|
+
* `typesafe/jev-1.14`, and mixing the two is issue #91. `custom` is a self-hosted System One
|
|
35
|
+
* endpoint and requires `baseUrl` + `supports` (SPEC §8B "Backends, images, cache and validation").
|
|
33
36
|
*/
|
|
34
|
-
export type ClassifierBackend = "typesafe" | "openrouter"
|
|
37
|
+
export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "custom"
|
|
35
38
|
|
|
36
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
/** What a backend+model can take. DATA: a fail-fast for what the table says, never a claim about
|
|
40
|
+
* the vendor. Override per classifier with `ClassifierOptions.supports`. */
|
|
41
|
+
export interface ClassifierSupports {
|
|
42
|
+
images: boolean
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** One row of the backend table. */
|
|
46
|
+
export interface ClassifierBackendPreset {
|
|
47
|
+
/** `""` on `custom`, which requires `baseUrl`. May carry `${NAME}`, expanded at call time. */
|
|
48
|
+
baseUrl: string
|
|
49
|
+
model: string
|
|
50
|
+
apiKeyEnv: string
|
|
51
|
+
/** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope. */
|
|
52
|
+
wire: "systemone" | "cloudflare"
|
|
53
|
+
/** Send `TypeSafe-Danger-Zone-Images: true` when a request carries images. */
|
|
54
|
+
imagesHeader: boolean
|
|
55
|
+
/** The default support for a model on this backend; absent on `custom`. */
|
|
56
|
+
supports?: (model: string) => ClassifierSupports
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** TypeSafe's required opt-in header for image ("danger zone") input. */
|
|
60
|
+
export const CLASSIFIER_IMAGES_HEADER = "TypeSafe-Danger-Zone-Images"
|
|
61
|
+
/** Image limits (docs.typesafe.ai/images): checked client-side, never resized. */
|
|
62
|
+
export const CLASSIFIER_IMAGE_MIN_SIDE = 280
|
|
63
|
+
export const CLASSIFIER_IMAGE_MAX_SIDE = 1568
|
|
64
|
+
export const CLASSIFIER_IMAGE_MAX_BYTES = 16 * 1024 * 1024
|
|
65
|
+
export const CLASSIFIER_REQUEST_IMAGE_BYTES = 32 * 1024 * 1024
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* The backend table (PROPOSED defaults, owner to confirm — design.md PD-1..PD-4). Vercel and
|
|
69
|
+
* Cloudflare-Jev images stay false until a live probe proves them.
|
|
70
|
+
*/
|
|
71
|
+
export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, Readonly<ClassifierBackendPreset>>> = Object.freeze({
|
|
72
|
+
typesafe: {
|
|
73
|
+
baseUrl: DEFAULT_CLASSIFIER_BASE_URL,
|
|
74
|
+
model: DEFAULT_CLASSIFIER_MODEL,
|
|
75
|
+
apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV,
|
|
76
|
+
wire: "systemone",
|
|
77
|
+
imagesHeader: true,
|
|
78
|
+
supports: () => ({ images: true }),
|
|
79
|
+
},
|
|
80
|
+
openrouter: {
|
|
81
|
+
baseUrl: "https://openrouter.ai/api/v1",
|
|
82
|
+
model: "typesafe/jev-1.14",
|
|
83
|
+
apiKeyEnv: "OPENROUTER_API_KEY",
|
|
84
|
+
wire: "systemone",
|
|
85
|
+
imagesHeader: true,
|
|
86
|
+
supports: () => ({ images: true }),
|
|
87
|
+
},
|
|
88
|
+
cloudflare: {
|
|
89
|
+
baseUrl: "https://api.cloudflare.com/client/v4/accounts/${CLOUDFLARE_ACCOUNT_ID}/ai/run",
|
|
90
|
+
model: "typesafe/jev",
|
|
91
|
+
apiKeyEnv: "CLOUDFLARE_JEV_API_KEY",
|
|
92
|
+
wire: "cloudflare",
|
|
93
|
+
imagesHeader: false,
|
|
94
|
+
supports: (model: string) => ({ images: model.includes("clef") }),
|
|
95
|
+
},
|
|
96
|
+
vercel: {
|
|
97
|
+
baseUrl: "https://ai-gateway.vercel.sh/typesafe/v1",
|
|
98
|
+
model: "typesafe-ai/jev",
|
|
99
|
+
apiKeyEnv: "AI_GATEWAY_API_KEY",
|
|
100
|
+
wire: "systemone",
|
|
101
|
+
imagesHeader: true,
|
|
102
|
+
supports: () => ({ images: false }),
|
|
103
|
+
},
|
|
104
|
+
custom: {
|
|
105
|
+
baseUrl: "",
|
|
106
|
+
model: DEFAULT_CLASSIFIER_MODEL,
|
|
107
|
+
apiKeyEnv: "",
|
|
108
|
+
wire: "systemone",
|
|
109
|
+
imagesHeader: true,
|
|
110
|
+
},
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
|
|
115
|
+
* variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
|
|
116
|
+
* subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
|
|
117
|
+
* host the caller did not name (#155, SPEC §8B).
|
|
118
|
+
*/
|
|
119
|
+
export function classifierKeyEnv(backend: ClassifierBackend, baseUrl: string): string {
|
|
120
|
+
const p = Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, backend) ? CLASSIFIER_BACKENDS[backend] : undefined
|
|
121
|
+
if (!p || !p.apiKeyEnv || !p.baseUrl) return ""
|
|
122
|
+
const hostOf = (u: string): string => {
|
|
123
|
+
try {
|
|
124
|
+
const url = new URL(u)
|
|
125
|
+
return url.protocol === "https:" ? url.hostname.toLowerCase() : ""
|
|
126
|
+
} catch {
|
|
127
|
+
return ""
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
const h = hostOf(baseUrl)
|
|
131
|
+
const own = hostOf(p.baseUrl)
|
|
132
|
+
if (!h || !own) return ""
|
|
133
|
+
return h === own || (backend === "openrouter" && h.endsWith(".openrouter.ai")) ? p.apiKeyEnv : ""
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Raised BEFORE any network call when the backend table says the backend cannot take what the
|
|
138
|
+
* request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
|
|
139
|
+
*/
|
|
140
|
+
export class ClassifierUnsupported extends Error {
|
|
141
|
+
constructor(
|
|
142
|
+
readonly backend: string,
|
|
143
|
+
readonly model: string,
|
|
144
|
+
readonly feature: string,
|
|
145
|
+
) {
|
|
146
|
+
super(`classifier: ${backend} (${model}) does not support ${feature}`)
|
|
147
|
+
this.name = "ClassifierUnsupported"
|
|
148
|
+
}
|
|
40
149
|
}
|
|
41
150
|
|
|
42
151
|
/** Client-side cap on a choice's named options (§8B). */
|
|
@@ -97,9 +206,36 @@ export interface ScoreQuestion {
|
|
|
97
206
|
criteria: string[]
|
|
98
207
|
}
|
|
99
208
|
|
|
100
|
-
/** The
|
|
209
|
+
/** The modelled question types, discriminated by `type`. */
|
|
101
210
|
export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion
|
|
102
211
|
|
|
212
|
+
/**
|
|
213
|
+
* The OPEN end of the question union (§8B): a System One type the library does not model (e.g.
|
|
214
|
+
* Jev 1.14 `box`), sent as written with no client-side limits. `type` must be non-empty.
|
|
215
|
+
*/
|
|
216
|
+
export interface RawQuestion {
|
|
217
|
+
type: string
|
|
218
|
+
instructions: string
|
|
219
|
+
/** Sent verbatim when present, absent otherwise. */
|
|
220
|
+
criteria?: unknown
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Every question `evaluate` accepts: the modelled three plus a raw one. */
|
|
224
|
+
export type AnyQuestion = Question | RawQuestion
|
|
225
|
+
|
|
226
|
+
const MODELLED_TYPES = new Set(["noul", "choice", "score"])
|
|
227
|
+
|
|
228
|
+
/** True when `q` is a raw question (its type is not noul/choice/score). Not a type guard: every
|
|
229
|
+
* modelled question is structurally a `RawQuestion` too. */
|
|
230
|
+
export function isRawQuestion(q: AnyQuestion): boolean {
|
|
231
|
+
return !MODELLED_TYPES.has(q?.type as string)
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** A raw question, sent as written. */
|
|
235
|
+
export function raw(type: string, instructions: string, criteria?: unknown): RawQuestion {
|
|
236
|
+
return criteria === undefined ? { type, instructions } : { type, instructions, criteria }
|
|
237
|
+
}
|
|
238
|
+
|
|
103
239
|
/** A noul question. Pass `criteria` only when you mean it — absent ≠ empty on the wire. */
|
|
104
240
|
export function noul(instructions: string, criteria?: NoulCriteria): NoulQuestion {
|
|
105
241
|
return criteria === undefined ? { type: "noul", instructions } : { type: "noul", instructions, criteria }
|
|
@@ -125,7 +261,14 @@ export function choiceOver(instructions: string, items: Record<string, string>):
|
|
|
125
261
|
}
|
|
126
262
|
|
|
127
263
|
/** The wire form of one question — the object itself, minus an absent `criteria`. */
|
|
128
|
-
export function questionWire(
|
|
264
|
+
export function questionWire(question: AnyQuestion): Record<string, unknown> {
|
|
265
|
+
if (isRawQuestion(question)) {
|
|
266
|
+
const r = question as RawQuestion
|
|
267
|
+
return r.criteria === undefined
|
|
268
|
+
? { type: r.type, instructions: r.instructions }
|
|
269
|
+
: { type: r.type, instructions: r.instructions, criteria: r.criteria }
|
|
270
|
+
}
|
|
271
|
+
const q = question as Question
|
|
129
272
|
switch (q.type) {
|
|
130
273
|
case "noul":
|
|
131
274
|
return q.criteria === undefined
|
|
@@ -139,7 +282,12 @@ export function questionWire(q: Question): Record<string, unknown> {
|
|
|
139
282
|
}
|
|
140
283
|
|
|
141
284
|
/** Client-side limits, enforced BEFORE the request. The error names the key and the limit. */
|
|
142
|
-
function validateQuestion(key: string,
|
|
285
|
+
function validateQuestion(key: string, question: AnyQuestion): void {
|
|
286
|
+
const q = question as Question
|
|
287
|
+
if (isRawQuestion(question)) {
|
|
288
|
+
if (!question?.type) throw new Error(`classifier: question "${key}": a raw question needs a type`)
|
|
289
|
+
return
|
|
290
|
+
}
|
|
143
291
|
if (q.type === "choice") {
|
|
144
292
|
const n = Object.keys(q.criteria ?? {}).length
|
|
145
293
|
if (n < 1 || n > MAX_CHOICE_OPTIONS) {
|
|
@@ -192,7 +340,7 @@ export function canonicalJson(value: unknown): string {
|
|
|
192
340
|
* ways across our own seven runtimes). Do not re-widen this: a caller who needs their state
|
|
193
341
|
* pinned canonicalises it themselves before handing it over.
|
|
194
342
|
*/
|
|
195
|
-
export function canonicalRequest(model: string, questions: Record<string,
|
|
343
|
+
export function canonicalRequest(model: string, questions: Record<string, AnyQuestion>): string {
|
|
196
344
|
const qs: Record<string, unknown> = {}
|
|
197
345
|
for (const [key, q] of Object.entries(questions)) {
|
|
198
346
|
if (!q) throw new Error(`classifier: question "${key}" is missing`)
|
|
@@ -241,6 +389,22 @@ export interface ScoreAnswer {
|
|
|
241
389
|
*/
|
|
242
390
|
export type DecisionAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer
|
|
243
391
|
|
|
392
|
+
/**
|
|
393
|
+
* An answer of a type the library does not model (the open union, §8B). It may appear in
|
|
394
|
+
* `Decision.answers` at runtime (read it with `decision.raw(key)` or `isRawAnswer`); `value` is the
|
|
395
|
+
* whole answer object as decoded, `type` included. It is kept out of `DecisionAnswer` so existing
|
|
396
|
+
* `switch (a.type)` narrowing keeps compiling.
|
|
397
|
+
*/
|
|
398
|
+
export interface RawAnswer {
|
|
399
|
+
type: string
|
|
400
|
+
value: Record<string, unknown>
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/** True when an answer is a raw (unmodelled-type) answer. */
|
|
404
|
+
export function isRawAnswer(a: unknown): a is RawAnswer {
|
|
405
|
+
return !!a && typeof a === "object" && !MODELLED_TYPES.has((a as any).type) && "value" in (a as any)
|
|
406
|
+
}
|
|
407
|
+
|
|
244
408
|
/** The wire's usage block. `cost` is absent on some backends. */
|
|
245
409
|
export interface ClassifierUsage {
|
|
246
410
|
inputTokens: number
|
|
@@ -271,8 +435,18 @@ export class Decision {
|
|
|
271
435
|
* BACKEND DOES NOT TRANSFER TO ANOTHER.
|
|
272
436
|
*/
|
|
273
437
|
readonly calibrated: boolean,
|
|
438
|
+
/** True when the decision came from `ClassifierOptions.cache` and no request was sent. */
|
|
439
|
+
readonly cached: boolean = false,
|
|
274
440
|
) {}
|
|
275
441
|
|
|
442
|
+
/** Typed read of a raw (unmodelled-type) answer. Throws on an absent key or a modelled type. */
|
|
443
|
+
raw(key: string): RawAnswer {
|
|
444
|
+
const a: unknown = this.answers[key]
|
|
445
|
+
if (a === undefined) throw new Error(`classifier: no answer "${key}" in this decision`)
|
|
446
|
+
if (!isRawAnswer(a)) throw new Error(`classifier: answer "${key}" is a ${(a as any).type} answer, not raw`)
|
|
447
|
+
return a
|
|
448
|
+
}
|
|
449
|
+
|
|
276
450
|
/** Typed read. Throws (never returns undefined) on a wrong-type or absent key. */
|
|
277
451
|
noul(key: string): NoulAnswer {
|
|
278
452
|
return this.typed(key, "noul")
|
|
@@ -345,7 +519,9 @@ export function decodeDecision(raw: unknown, fallbackModel: string): Decision {
|
|
|
345
519
|
}
|
|
346
520
|
break
|
|
347
521
|
default:
|
|
348
|
-
|
|
522
|
+
// The union is OPEN (§8B): an unmodelled type (e.g. Jev 1.14 "box") decodes to a raw
|
|
523
|
+
// answer instead of failing the whole decision.
|
|
524
|
+
;(answers as Record<string, unknown>)[key] = { type: String(a.type ?? ""), value: { ...a } } satisfies RawAnswer
|
|
349
525
|
}
|
|
350
526
|
}
|
|
351
527
|
const usage: ClassifierUsage = {
|
|
@@ -376,7 +552,12 @@ function strings(v: unknown): Record<string, string> {
|
|
|
376
552
|
|
|
377
553
|
// ---------------------------------------------------------------- options
|
|
378
554
|
|
|
379
|
-
/**
|
|
555
|
+
/**
|
|
556
|
+
* `"systemone" | "llm" | "custom" | "static"` — the same slot as the client's `style`.
|
|
557
|
+
*
|
|
558
|
+
* `"llm"` is DEPRECATED (ADR 0038 D3: System One is the single product wire). It keeps working
|
|
559
|
+
* byte-identically, as an alias, for at least one minor release; there is no runtime warning.
|
|
560
|
+
*/
|
|
380
561
|
export type ClassifierStyle = "systemone" | "llm" | "custom" | "static"
|
|
381
562
|
|
|
382
563
|
/**
|
|
@@ -387,7 +568,7 @@ export type ClassifierStyle = "systemone" | "llm" | "custom" | "static"
|
|
|
387
568
|
*/
|
|
388
569
|
export interface RecordedDecision {
|
|
389
570
|
state: unknown
|
|
390
|
-
questions: Record<string,
|
|
571
|
+
questions: Record<string, AnyQuestion>
|
|
391
572
|
/** The recorded backend response body, verbatim. */
|
|
392
573
|
response: unknown
|
|
393
574
|
}
|
|
@@ -395,7 +576,7 @@ export interface RecordedDecision {
|
|
|
395
576
|
/** The host's own backend (`style: "custom"`). Every wire option is ignored. */
|
|
396
577
|
export type EvaluateFn = (
|
|
397
578
|
state: unknown,
|
|
398
|
-
questions: Record<string,
|
|
579
|
+
questions: Record<string, AnyQuestion>,
|
|
399
580
|
signal?: AbortSignal,
|
|
400
581
|
) => Promise<Decision> | Decision
|
|
401
582
|
|
|
@@ -406,7 +587,7 @@ export type EvaluateFn = (
|
|
|
406
587
|
export interface ClassifierOptions {
|
|
407
588
|
/** Default `"systemone"`. */
|
|
408
589
|
style?: ClassifierStyle
|
|
409
|
-
/** A named `
|
|
590
|
+
/** A named backend row (`"typesafe"` | `"openrouter"` | `"cloudflare"` | `"vercel"` | `"custom"`), set as a
|
|
410
591
|
* UNIT. Default `"typesafe"`. The three individual options still win where given, for a
|
|
411
592
|
* self-hosted origin — but a known-bad mixture is refused at construction, not 700 ms later
|
|
412
593
|
* as an "Unknown model" 400. */
|
|
@@ -451,12 +632,37 @@ export interface ClassifierOptions {
|
|
|
451
632
|
/** Emits `classifier.evaluate` events into the SAME §8 sink, and carries the
|
|
452
633
|
* degenerate-criteria warning as `classifier.warning`. */
|
|
453
634
|
onMetric?: (ev: MetricEvent) => void
|
|
454
|
-
/** `style: "llm"` only — the §8 Client to emulate over.
|
|
635
|
+
/** `style: "llm"` only — the §8 Client to emulate over.
|
|
636
|
+
* @deprecated `style: "llm"` is deprecated (System One is the single wire); it still works. */
|
|
455
637
|
client?: Client
|
|
456
638
|
/** `style: "custom"` only — the host's own function. */
|
|
457
639
|
evaluate?: EvaluateFn
|
|
458
640
|
/** `style: "static"` only — the recorded corpus. */
|
|
459
641
|
decisions?: RecordedDecision[]
|
|
642
|
+
/** Overrides the backend table's support row for this classifier. Required with `backend: "custom"`. */
|
|
643
|
+
supports?: ClassifierSupports
|
|
644
|
+
/** Opt-in answer cache (§8B). Absent ⇒ no caching. Consulted by style `systemone` only; only
|
|
645
|
+
* replies that pass `validateDecision` are stored. Methods may return values or Promises. */
|
|
646
|
+
cache?: ClassifierCache
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/** An injected answer cache. Values are opaque strings: the served model under a model key, the
|
|
650
|
+
* System One reply body under a decision key. */
|
|
651
|
+
export interface ClassifierCache {
|
|
652
|
+
get(key: string): string | undefined | null | Promise<string | undefined | null>
|
|
653
|
+
set(key: string, value: string): void | Promise<void>
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
/** One state's outcome from `states(...).evaluate`: exactly one of `decision` / `error` is set. */
|
|
657
|
+
export interface BatchResult {
|
|
658
|
+
decision?: Decision
|
|
659
|
+
error?: Error
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
/** A named question (`judge.noul(...)` etc. build these). */
|
|
663
|
+
export interface NamedClassifierQuestion {
|
|
664
|
+
name: string
|
|
665
|
+
question: AnyQuestion
|
|
460
666
|
}
|
|
461
667
|
|
|
462
668
|
// ---------------------------------------------------------------- classifier
|
|
@@ -467,7 +673,13 @@ export class Classifier {
|
|
|
467
673
|
private readonly baseUrl: string
|
|
468
674
|
private readonly model: string
|
|
469
675
|
private readonly apiKeyEnv: string
|
|
676
|
+
/** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
|
|
677
|
+
* and only to its own host (classifierKeyEnv, #155). */
|
|
678
|
+
private readonly explicitKeyEnv: boolean
|
|
470
679
|
private readonly timeoutMs: number
|
|
680
|
+
private readonly backend: ClassifierBackend
|
|
681
|
+
private readonly preset: Readonly<ClassifierBackendPreset>
|
|
682
|
+
private readonly supports: ClassifierSupports
|
|
471
683
|
private readonly corpus = new Map<string, unknown>()
|
|
472
684
|
/** The once-per-question-key set for the degenerate warning, so a per-turn judge does not
|
|
473
685
|
* flood the sink. */
|
|
@@ -475,13 +687,25 @@ export class Classifier {
|
|
|
475
687
|
|
|
476
688
|
constructor(private readonly opts: ClassifierOptions) {
|
|
477
689
|
this.style = opts.style ?? "systemone"
|
|
478
|
-
|
|
479
|
-
|
|
690
|
+
this.backend = opts.backend ?? "typesafe"
|
|
691
|
+
if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, this.backend)) {
|
|
692
|
+
throw new Error(
|
|
693
|
+
`classifier: unknown backend "${this.backend}" (want "typesafe", "openrouter", "cloudflare", "vercel" or "custom")`,
|
|
694
|
+
)
|
|
695
|
+
}
|
|
696
|
+
const preset = CLASSIFIER_BACKENDS[this.backend]
|
|
697
|
+
this.preset = preset
|
|
698
|
+
if (this.backend === "custom" && this.style === "systemone" && (!opts.baseUrl || !opts.supports)) {
|
|
699
|
+
throw new Error('classifier: backend "custom" requires baseUrl and supports')
|
|
700
|
+
}
|
|
701
|
+
this.baseUrl = opts.baseUrl || preset.baseUrl
|
|
480
702
|
this.model = opts.model ?? preset.model
|
|
481
703
|
this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv
|
|
704
|
+
this.explicitKeyEnv = opts.apiKeyEnv !== undefined
|
|
482
705
|
const mismatch = backendMismatch(this.baseUrl, this.model)
|
|
483
706
|
if (mismatch) throw new Error(mismatch)
|
|
484
707
|
this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
|
|
708
|
+
this.supports = opts.supports ? { ...opts.supports } : (preset.supports?.(this.model) ?? { images: false })
|
|
485
709
|
switch (this.style) {
|
|
486
710
|
case "systemone":
|
|
487
711
|
break
|
|
@@ -511,12 +735,67 @@ export class Classifier {
|
|
|
511
735
|
* The whole contract: a state plus typed questions in, a Decision out. Questions are
|
|
512
736
|
* INDEPENDENT — one answer is never context for another.
|
|
513
737
|
*/
|
|
514
|
-
async evaluate(state: unknown, questions: Record<string,
|
|
738
|
+
async evaluate(state: unknown, questions: Record<string, AnyQuestion>, signal?: AbortSignal): Promise<Decision> {
|
|
739
|
+
return this.run(state, questions, signal, false)
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Binds one state (a VALUE, not a session: nothing is sent until `evaluate`). Its `evaluate`
|
|
744
|
+
* runs the same path as `evaluate` PLUS fail-closed validation (`validateDecision`).
|
|
745
|
+
*/
|
|
746
|
+
state(value: unknown, opts: { signal?: AbortSignal } = {}): { evaluate(...questions: NamedClassifierQuestion[]): Promise<Decision> } {
|
|
747
|
+
return {
|
|
748
|
+
evaluate: async (...qs: NamedClassifierQuestion[]) => this.run(value, namedQuestions(qs), opts.signal, true),
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/**
|
|
753
|
+
* Binds a batch of states. Its `evaluate` runs `state(v).evaluate` per state, at most 16 in
|
|
754
|
+
* flight, and returns one `{decision | error}` per state IN ORDER. One state's failure never
|
|
755
|
+
* fails the batch; only a question-set error (duplicate name) throws, before any request.
|
|
756
|
+
*/
|
|
757
|
+
states(...values: unknown[]): { evaluate(...questions: NamedClassifierQuestion[]): Promise<BatchResult[]> } {
|
|
758
|
+
return {
|
|
759
|
+
evaluate: async (...qs: NamedClassifierQuestion[]) => {
|
|
760
|
+
const questions = namedQuestions(qs)
|
|
761
|
+
const out: BatchResult[] = new Array(values.length)
|
|
762
|
+
let next = 0
|
|
763
|
+
const worker = async () => {
|
|
764
|
+
while (next < values.length) {
|
|
765
|
+
const i = next++
|
|
766
|
+
try {
|
|
767
|
+
out[i] = { decision: await this.run(values[i], questions, undefined, true) }
|
|
768
|
+
} catch (e) {
|
|
769
|
+
out[i] = { error: e instanceof Error ? e : new Error(String(e)) }
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
}
|
|
773
|
+
await Promise.all(Array.from({ length: Math.min(DEFAULT_BATCH_CONCURRENCY, values.length) }, worker))
|
|
774
|
+
return out
|
|
775
|
+
},
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
/** The one path. `validate` = fail closed on `validateDecision` (the value builders). */
|
|
780
|
+
private async run(
|
|
781
|
+
state: unknown,
|
|
782
|
+
questions: Record<string, AnyQuestion>,
|
|
783
|
+
signal: AbortSignal | undefined,
|
|
784
|
+
validate: boolean,
|
|
785
|
+
): Promise<Decision> {
|
|
515
786
|
const start = Date.now()
|
|
516
787
|
const keys = Object.keys(questions ?? {}).sort()
|
|
788
|
+
// L1: count + hash of the canonical request on every evaluate event ("" when unencodable).
|
|
789
|
+
let qHash = ""
|
|
790
|
+
try {
|
|
791
|
+
qHash = sha256Hex(canonicalRequest(this.model, questions ?? {}))
|
|
792
|
+
} catch {
|
|
793
|
+
qHash = ""
|
|
794
|
+
}
|
|
795
|
+
const l1 = { questions: keys.length, questionsHash: qHash }
|
|
517
796
|
if (keys.length === 0) {
|
|
518
797
|
const err = new Error("classifier: no questions to evaluate")
|
|
519
|
-
this.emitError(start, err)
|
|
798
|
+
this.emitError(start, err, l1)
|
|
520
799
|
throw err
|
|
521
800
|
}
|
|
522
801
|
try {
|
|
@@ -529,7 +808,7 @@ export class Classifier {
|
|
|
529
808
|
validateQuestion(key, q)
|
|
530
809
|
}
|
|
531
810
|
} catch (e) {
|
|
532
|
-
this.emitError(start, e)
|
|
811
|
+
this.emitError(start, e, l1)
|
|
533
812
|
throw e
|
|
534
813
|
}
|
|
535
814
|
// Detection, never repair (ADR 0020/0021): the request goes out BYTE-UNCHANGED and the
|
|
@@ -549,10 +828,14 @@ export class Classifier {
|
|
|
549
828
|
decision = await this.evaluateLLM(state, questions, signal)
|
|
550
829
|
break
|
|
551
830
|
default:
|
|
552
|
-
decision = await this.
|
|
831
|
+
decision = await this.evaluateWire(state, questions, signal, validate)
|
|
832
|
+
}
|
|
833
|
+
if (validate && this.style !== "systemone") {
|
|
834
|
+
const verr = validateDecision(questions, decision)
|
|
835
|
+
if (verr) throw verr
|
|
553
836
|
}
|
|
554
837
|
} catch (e) {
|
|
555
|
-
this.emitError(start, e)
|
|
838
|
+
this.emitError(start, e, l1)
|
|
556
839
|
throw e
|
|
557
840
|
}
|
|
558
841
|
this.emit({
|
|
@@ -562,6 +845,8 @@ export class Classifier {
|
|
|
562
845
|
ms: Date.now() - start,
|
|
563
846
|
promptTokens: decision.usage.inputTokens,
|
|
564
847
|
completionTokens: decision.usage.outputTokens,
|
|
848
|
+
...l1,
|
|
849
|
+
cached: decision.cached === true,
|
|
565
850
|
})
|
|
566
851
|
return decision
|
|
567
852
|
}
|
|
@@ -574,7 +859,7 @@ export class Classifier {
|
|
|
574
859
|
*/
|
|
575
860
|
async evaluateBatch(
|
|
576
861
|
states: readonly unknown[],
|
|
577
|
-
questions: Record<string,
|
|
862
|
+
questions: Record<string, AnyQuestion>,
|
|
578
863
|
opts: { signal?: AbortSignal; concurrency?: number } = {},
|
|
579
864
|
): Promise<Decision[]> {
|
|
580
865
|
if (!states || states.length === 0) throw new Error("classifier: no states to evaluate")
|
|
@@ -606,9 +891,9 @@ export class Classifier {
|
|
|
606
891
|
* nothing about the request. Repairing would invent option descriptions the caller did not
|
|
607
892
|
* write, and the library has no way to know what the options mean.
|
|
608
893
|
*/
|
|
609
|
-
private reportDegenerate(keys: string[], questions: Record<string,
|
|
894
|
+
private reportDegenerate(keys: string[], questions: Record<string, AnyQuestion>): void {
|
|
610
895
|
for (const key of keys) {
|
|
611
|
-
const q = questions[key]
|
|
896
|
+
const q = questions[key] as Question
|
|
612
897
|
if (q.type !== "choice") continue
|
|
613
898
|
const reason = degenerateCriteria(q.criteria)
|
|
614
899
|
if (!reason || this.warned.has(key)) continue
|
|
@@ -627,14 +912,22 @@ export class Classifier {
|
|
|
627
912
|
// ---------------------------------------------------------------- backends
|
|
628
913
|
|
|
629
914
|
/**
|
|
630
|
-
* Assembles the request: the canonical model + questions, plus
|
|
631
|
-
*
|
|
632
|
-
* bodyTransform → marshal).
|
|
915
|
+
* Assembles the request for the backend's wire: the canonical model + questions, plus the state
|
|
916
|
+
* (VERBATIM unless it carried content parts) and, on systemone, `assets`; then the §8 Gap 1
|
|
917
|
+
* pipeline in §8 order (base → requestParams merge → bodyTransform → marshal).
|
|
633
918
|
*/
|
|
634
|
-
private body(
|
|
919
|
+
private body(p: PreparedRequest, questions: Record<string, AnyQuestion>): string {
|
|
635
920
|
const qs: Record<string, unknown> = {}
|
|
636
921
|
for (const [key, q] of Object.entries(questions)) qs[key] = questionWire(q)
|
|
637
|
-
let body: Record<string, any>
|
|
922
|
+
let body: Record<string, any>
|
|
923
|
+
if (this.preset.wire === "cloudflare") {
|
|
924
|
+
const input: Record<string, unknown> = { questions: qs, state: p.state }
|
|
925
|
+
if (p.images.length > 0) input.images = p.images.map((im) => `data:image/${im.format};base64,${im.data}`)
|
|
926
|
+
body = { model: this.model, input }
|
|
927
|
+
} else {
|
|
928
|
+
body = { model: this.model, questions: qs, state: p.state }
|
|
929
|
+
if (p.assets) body.assets = p.assets
|
|
930
|
+
}
|
|
638
931
|
// A requestParams key WINS on collision.
|
|
639
932
|
if (this.opts.requestParams) body = { ...body, ...this.opts.requestParams }
|
|
640
933
|
if (this.opts.bodyTransform) body = this.opts.bodyTransform(body) || body
|
|
@@ -642,29 +935,76 @@ export class Classifier {
|
|
|
642
935
|
}
|
|
643
936
|
|
|
644
937
|
/** Identifies a recorded decision by the canonical request AND the state. */
|
|
645
|
-
private staticKey(state: unknown, questions: Record<string,
|
|
938
|
+
private staticKey(state: unknown, questions: Record<string, AnyQuestion>): string {
|
|
646
939
|
return canonicalRequest(this.model, questions) + "\u0000" + canonicalJson(state)
|
|
647
940
|
}
|
|
648
941
|
|
|
649
|
-
private evaluateStatic(state: unknown, questions: Record<string,
|
|
942
|
+
private evaluateStatic(state: unknown, questions: Record<string, AnyQuestion>): Decision {
|
|
650
943
|
const raw = this.corpus.get(this.staticKey(state, questions))
|
|
651
944
|
if (raw === undefined) throw new Error("classifier: static: no recorded decision for this request+state")
|
|
652
945
|
return decodeDecision(raw, this.model)
|
|
653
946
|
}
|
|
654
947
|
|
|
655
|
-
|
|
948
|
+
/** Lift images, apply the support table and the limits — all before any request. */
|
|
949
|
+
private prepare(state: unknown): PreparedRequest {
|
|
950
|
+
const { state: sent, images } = liftImages(state)
|
|
951
|
+
if (images.length > 0 && !this.supports.images) throw new ClassifierUnsupported(this.backend, this.model, "images")
|
|
952
|
+
checkImages(images)
|
|
953
|
+
let assets: Record<string, unknown> | undefined
|
|
954
|
+
if (images.length > 0) {
|
|
955
|
+
assets = {}
|
|
956
|
+
for (const im of images) assets[im.ref] = { type: "image", format: im.format, data_base64: im.data }
|
|
957
|
+
}
|
|
958
|
+
return { state: sent, images, assets }
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
/** The systemone style on every backend: lift, check, cache, post, decode. */
|
|
962
|
+
private async evaluateWire(
|
|
656
963
|
state: unknown,
|
|
657
|
-
questions: Record<string,
|
|
658
|
-
signal
|
|
964
|
+
questions: Record<string, AnyQuestion>,
|
|
965
|
+
signal: AbortSignal | undefined,
|
|
966
|
+
validate: boolean,
|
|
659
967
|
): Promise<Decision> {
|
|
660
|
-
const
|
|
968
|
+
const p = this.prepare(state)
|
|
969
|
+
const cache = this.opts.cache
|
|
970
|
+
let modelKey = ""
|
|
971
|
+
if (cache) {
|
|
972
|
+
modelKey = classifierModelCacheKey(this.baseUrl, this.model)
|
|
973
|
+
const served = await cache.get(modelKey)
|
|
974
|
+
if (typeof served === "string") {
|
|
975
|
+
const hit = await cache.get(classifierDecisionCacheKey(this.baseUrl, served, p.state, p.assets, questions))
|
|
976
|
+
if (typeof hit === "string") {
|
|
977
|
+
try {
|
|
978
|
+
const d = decodeDecision(JSON.parse(hit), served)
|
|
979
|
+
if (!validateDecision(questions, d)) return new Decision(d.model, d.answers, d.usage, d.calibrated, true)
|
|
980
|
+
} catch {
|
|
981
|
+
// a corrupt entry is a miss
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
const extra: Record<string, string> =
|
|
987
|
+
p.images.length > 0 && this.preset.imagesHeader ? { [CLASSIFIER_IMAGES_HEADER]: "true" } : {}
|
|
988
|
+
let text = await this.post(this.body(p, questions), signal, extra)
|
|
661
989
|
let parsed: unknown
|
|
662
990
|
try {
|
|
663
991
|
parsed = JSON.parse(text)
|
|
664
992
|
} catch (e) {
|
|
665
993
|
throw new Error(`classifier: response was not JSON: ${(e as Error).message}`)
|
|
666
994
|
}
|
|
667
|
-
|
|
995
|
+
if (this.preset.wire === "cloudflare") {
|
|
996
|
+
parsed = unwrapCloudflare(parsed)
|
|
997
|
+
text = canonicalJson(parsed)
|
|
998
|
+
}
|
|
999
|
+
const d = decodeDecision(parsed, this.model)
|
|
1000
|
+
const verr = validateDecision(questions, d)
|
|
1001
|
+
if (validate && verr) throw verr
|
|
1002
|
+
if (cache && !verr) {
|
|
1003
|
+
const served = d.model || this.model
|
|
1004
|
+
await cache.set(modelKey, served)
|
|
1005
|
+
await cache.set(classifierDecisionCacheKey(this.baseUrl, served, p.state, p.assets, questions), text)
|
|
1006
|
+
}
|
|
1007
|
+
return d
|
|
668
1008
|
}
|
|
669
1009
|
|
|
670
1010
|
/**
|
|
@@ -676,8 +1016,12 @@ export class Classifier {
|
|
|
676
1016
|
* is never echoed back (a gateway happily reflects a bad Authorization header into its own
|
|
677
1017
|
* 401 text).
|
|
678
1018
|
*/
|
|
679
|
-
private async post(payload: string, signal?: AbortSignal): Promise<string> {
|
|
680
|
-
|
|
1019
|
+
private async post(payload: string, signal?: AbortSignal, extraHeaders: Record<string, string> = {}): Promise<string> {
|
|
1020
|
+
// `endpoint` is what errors name: the CONFIGURED base, ${ENV} unexpanded, so an account id in
|
|
1021
|
+
// a templated URL never reaches an error. `url` is what is dialled, expanded at call time.
|
|
1022
|
+
let endpoint = this.baseUrl.replace(/\/+$/, "")
|
|
1023
|
+
if (this.preset.wire !== "cloudflare") endpoint += "/systemone"
|
|
1024
|
+
const url = endpoint.includes("${") ? expandEnv(endpoint) : endpoint
|
|
681
1025
|
const retries = this.opts.retries ?? 2
|
|
682
1026
|
const base = this.opts.retryBaseMs ?? 500
|
|
683
1027
|
const classify = this.opts.onError ?? ((info: ErrorInfo): ErrorTier => (info.retryable ? "retry" : "fail"))
|
|
@@ -700,9 +1044,9 @@ export class Classifier {
|
|
|
700
1044
|
let res: Response | undefined
|
|
701
1045
|
let thrown: unknown
|
|
702
1046
|
try {
|
|
703
|
-
res = await doFetch(
|
|
1047
|
+
res = await doFetch(url, {
|
|
704
1048
|
method: "POST",
|
|
705
|
-
headers: this.requestHeaders(),
|
|
1049
|
+
headers: this.requestHeaders(extraHeaders, url),
|
|
706
1050
|
body: payload,
|
|
707
1051
|
signal: timer.signal,
|
|
708
1052
|
})
|
|
@@ -720,7 +1064,7 @@ export class Classifier {
|
|
|
720
1064
|
retryAfter = res.headers.get("retry-after")
|
|
721
1065
|
lastErr = new Error(`classifier: POST ${endpoint}: HTTP ${res.status}${cause(res.status, text)}`)
|
|
722
1066
|
} else {
|
|
723
|
-
lastErr = new Error(`classifier: POST ${endpoint}: ${errText(thrown)}`)
|
|
1067
|
+
lastErr = new Error(`classifier: POST ${endpoint}: ${errText(thrown).split(url).join(endpoint)}`)
|
|
724
1068
|
}
|
|
725
1069
|
const retryable = res ? res.status === 408 || isRetryableStatus(res.status, this.opts.retryableStatuses) : true
|
|
726
1070
|
if (attempt >= retries) throw lastErr
|
|
@@ -732,10 +1076,13 @@ export class Classifier {
|
|
|
732
1076
|
}
|
|
733
1077
|
|
|
734
1078
|
/** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
|
|
735
|
-
private requestHeaders(): Record<string, string> {
|
|
1079
|
+
private requestHeaders(extra: Record<string, string>, url: string): Record<string, string> {
|
|
736
1080
|
const headers: Record<string, string> = { "Content-Type": "application/json" }
|
|
737
|
-
const
|
|
1081
|
+
const keyEnv = this.explicitKeyEnv ? this.apiKeyEnv : classifierKeyEnv(this.backend, url)
|
|
1082
|
+
const key = keyEnv ? process.env[keyEnv] : undefined
|
|
738
1083
|
if (key) headers["Authorization"] = `Bearer ${key}`
|
|
1084
|
+
// The image opt-in header goes BEFORE host headers, which win.
|
|
1085
|
+
for (const [k, v] of Object.entries(extra)) headers[k] = v
|
|
739
1086
|
for (const [k, v] of Object.entries(this.opts.headers ?? {})) headers[k] = expandEnv(v)
|
|
740
1087
|
return headers
|
|
741
1088
|
}
|
|
@@ -748,7 +1095,7 @@ export class Classifier {
|
|
|
748
1095
|
*/
|
|
749
1096
|
private async evaluateLLM(
|
|
750
1097
|
state: unknown,
|
|
751
|
-
questions: Record<string,
|
|
1098
|
+
questions: Record<string, AnyQuestion>,
|
|
752
1099
|
signal?: AbortSignal,
|
|
753
1100
|
): Promise<Decision> {
|
|
754
1101
|
const qs: Record<string, unknown> = {}
|
|
@@ -789,7 +1136,7 @@ export class Classifier {
|
|
|
789
1136
|
this.opts.onMetric?.(ev)
|
|
790
1137
|
}
|
|
791
1138
|
|
|
792
|
-
private emitError(start: number, e: unknown): void {
|
|
1139
|
+
private emitError(start: number, e: unknown, l1: { questions: number; questionsHash: string }): void {
|
|
793
1140
|
this.emit({
|
|
794
1141
|
event: "classifier.evaluate",
|
|
795
1142
|
model: this.model,
|
|
@@ -798,6 +1145,8 @@ export class Classifier {
|
|
|
798
1145
|
promptTokens: 0,
|
|
799
1146
|
completionTokens: 0,
|
|
800
1147
|
error: errText(e),
|
|
1148
|
+
...l1,
|
|
1149
|
+
cached: false,
|
|
801
1150
|
})
|
|
802
1151
|
}
|
|
803
1152
|
}
|
|
@@ -883,3 +1232,330 @@ function sleep(ms: number): Promise<void> {
|
|
|
883
1232
|
})
|
|
884
1233
|
}
|
|
885
1234
|
|
|
1235
|
+
|
|
1236
|
+
// ---------------------------------------------------------------- backends, images, cache, validation
|
|
1237
|
+
// SPEC.md §8B "Backends, images, cache and validation" (change add-classifier-backends, ADR 0038).
|
|
1238
|
+
// Every default here is a PROPOSED default, owner to confirm (design.md PD-1..PD-15).
|
|
1239
|
+
|
|
1240
|
+
/** Ordered named questions -> the questions map. A duplicate name is an error naming it. */
|
|
1241
|
+
function namedQuestions(qs: readonly NamedClassifierQuestion[]): Record<string, AnyQuestion> {
|
|
1242
|
+
const out: Record<string, AnyQuestion> = {}
|
|
1243
|
+
for (const { name, question } of qs) {
|
|
1244
|
+
if (Object.prototype.hasOwnProperty.call(out, name)) throw new Error(`duplicate question name ${JSON.stringify(name)}`)
|
|
1245
|
+
out[name] = question
|
|
1246
|
+
}
|
|
1247
|
+
return out
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
function sha256Hex(s: string): string {
|
|
1251
|
+
return createHash("sha256").update(s, "utf8").digest("hex")
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
interface LiftedImage {
|
|
1255
|
+
ref: string
|
|
1256
|
+
/** The MIME type until `checkImages` normalises it to `png` | `jpeg`. */
|
|
1257
|
+
format: string
|
|
1258
|
+
data: string
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
interface PreparedRequest {
|
|
1262
|
+
state: unknown
|
|
1263
|
+
images: LiftedImage[]
|
|
1264
|
+
assets?: Record<string, unknown>
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
function containsPart(v: unknown): boolean {
|
|
1268
|
+
if (Array.isArray(v)) return v.some(containsPart)
|
|
1269
|
+
if (v !== null && typeof v === "object") {
|
|
1270
|
+
if (isContentPart(v)) return true
|
|
1271
|
+
return Object.values(v as Record<string, unknown>).some(containsPart)
|
|
1272
|
+
}
|
|
1273
|
+
return false
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
/**
|
|
1277
|
+
* The §8B lifting walk. A state with no content part is returned VERBATIM (the same value), so a
|
|
1278
|
+
* text-only state is byte-identical to before. Otherwise depth-first, object keys in ASCII order,
|
|
1279
|
+
* arrays in order: an image part with inline data becomes a reference (its key when unused, else
|
|
1280
|
+
* the smallest unused `image<n>`), a text part becomes its text, any other part is an error.
|
|
1281
|
+
*/
|
|
1282
|
+
function liftImages(state: unknown): { state: unknown; images: LiftedImage[] } {
|
|
1283
|
+
if (!containsPart(state)) return { state, images: [] }
|
|
1284
|
+
const used = new Set<string>()
|
|
1285
|
+
const images: LiftedImage[] = []
|
|
1286
|
+
const nextRef = () => {
|
|
1287
|
+
for (let n = 1; ; n++) if (!used.has(`image${n}`)) return `image${n}`
|
|
1288
|
+
}
|
|
1289
|
+
const walk = (v: unknown, key: string): unknown => {
|
|
1290
|
+
if (Array.isArray(v)) return v.map((e) => walk(e, ""))
|
|
1291
|
+
if (v === null || typeof v !== "object") return v
|
|
1292
|
+
if (isContentPart(v)) {
|
|
1293
|
+
const part = v as ContentPart
|
|
1294
|
+
if (part.type === "text") return part.text
|
|
1295
|
+
if (part.type === "image") {
|
|
1296
|
+
if (!part.data) throw new Error("classifier: state: an image part needs inline data (a URL image cannot be sent)")
|
|
1297
|
+
const ref = key === "" || used.has(key) ? nextRef() : key
|
|
1298
|
+
used.add(ref)
|
|
1299
|
+
images.push({ ref, format: part.mimeType, data: part.data })
|
|
1300
|
+
return ref
|
|
1301
|
+
}
|
|
1302
|
+
throw new Error(`classifier: state: ${part.type} parts are not supported`)
|
|
1303
|
+
}
|
|
1304
|
+
const obj = v as Record<string, unknown>
|
|
1305
|
+
const out: Record<string, unknown> = {}
|
|
1306
|
+
for (const k of Object.keys(obj).sort()) out[k] = walk(obj[k], k)
|
|
1307
|
+
return out
|
|
1308
|
+
}
|
|
1309
|
+
return { state: walk(state, ""), images }
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
const IMAGE_FORMATS: Record<string, string> = { "image/png": "png", "image/jpeg": "jpeg", "image/jpg": "jpeg" }
|
|
1313
|
+
|
|
1314
|
+
/** Format, per-image and per-request limits, in collection order; normalises `format`. */
|
|
1315
|
+
function checkImages(images: LiftedImage[]): void {
|
|
1316
|
+
let total = 0
|
|
1317
|
+
for (const im of images) {
|
|
1318
|
+
const format = IMAGE_FORMATS[im.format]
|
|
1319
|
+
if (!format) throw new Error(`classifier: image "${im.ref}": type "${im.format}" is not supported (PNG or JPEG only)`)
|
|
1320
|
+
im.format = format
|
|
1321
|
+
if (im.data.length % 4 !== 0 || !/^[A-Za-z0-9+/]*={0,2}$/.test(im.data)) {
|
|
1322
|
+
throw new Error(`classifier: image "${im.ref}": data is not base64`)
|
|
1323
|
+
}
|
|
1324
|
+
const raw = Buffer.from(im.data, "base64")
|
|
1325
|
+
if (raw.length > CLASSIFIER_IMAGE_MAX_BYTES) {
|
|
1326
|
+
throw new Error(`classifier: image "${im.ref}": ${raw.length} bytes, over 16 MiB`)
|
|
1327
|
+
}
|
|
1328
|
+
const size = imageSize(format, raw)
|
|
1329
|
+
if (!size) throw new Error(`classifier: image "${im.ref}": not a readable ${format.toUpperCase()}`)
|
|
1330
|
+
const [w, h] = size
|
|
1331
|
+
const ok = (n: number) => n >= CLASSIFIER_IMAGE_MIN_SIDE && n <= CLASSIFIER_IMAGE_MAX_SIDE
|
|
1332
|
+
if (!ok(w) || !ok(h)) {
|
|
1333
|
+
throw new Error(
|
|
1334
|
+
`classifier: image "${im.ref}": ${w}x${h}; each side must be ${CLASSIFIER_IMAGE_MIN_SIDE}..${CLASSIFIER_IMAGE_MAX_SIDE} px`,
|
|
1335
|
+
)
|
|
1336
|
+
}
|
|
1337
|
+
total += raw.length
|
|
1338
|
+
}
|
|
1339
|
+
if (total > CLASSIFIER_REQUEST_IMAGE_BYTES) {
|
|
1340
|
+
throw new Error(`classifier: image "${images[images.length - 1].ref}": request images total ${total} bytes, over 32 MiB`)
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/** Width/height from a PNG IHDR or the first JPEG SOF marker; undefined when unreadable. */
|
|
1345
|
+
function imageSize(format: string, b: Uint8Array): [number, number] | undefined {
|
|
1346
|
+
const be16 = (i: number) => (b[i] << 8) | b[i + 1]
|
|
1347
|
+
const be32 = (i: number) => ((b[i] << 24) >>> 0) + (b[i + 1] << 16) + (b[i + 2] << 8) + b[i + 3]
|
|
1348
|
+
if (format === "png") {
|
|
1349
|
+
const sig = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]
|
|
1350
|
+
if (b.length < 24 || sig.some((x, i) => b[i] !== x)) return undefined
|
|
1351
|
+
if (String.fromCharCode(b[12], b[13], b[14], b[15]) !== "IHDR") return undefined
|
|
1352
|
+
return [be32(16), be32(20)]
|
|
1353
|
+
}
|
|
1354
|
+
if (b.length < 4 || b[0] !== 0xff || b[1] !== 0xd8) return undefined
|
|
1355
|
+
let i = 2
|
|
1356
|
+
while (i + 3 < b.length) {
|
|
1357
|
+
if (b[i] !== 0xff) return undefined
|
|
1358
|
+
const m = b[i + 1]
|
|
1359
|
+
if (m === 0xff) {
|
|
1360
|
+
i++
|
|
1361
|
+
continue
|
|
1362
|
+
}
|
|
1363
|
+
if (m >= 0xc0 && m <= 0xcf && m !== 0xc4 && m !== 0xc8 && m !== 0xcc) {
|
|
1364
|
+
if (i + 8 >= b.length) return undefined
|
|
1365
|
+
return [be16(i + 7), be16(i + 5)]
|
|
1366
|
+
}
|
|
1367
|
+
i += 2 + be16(i + 2)
|
|
1368
|
+
}
|
|
1369
|
+
return undefined
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
/**
|
|
1373
|
+
* `: <code> <message>` from errors[0] of a failed envelope (SPEC §8B), through the same redaction
|
|
1374
|
+
* and cap as cause(). Cloudflare reports a missing balance, a bad model id and a malformed
|
|
1375
|
+
* request alike as HTTP 200 + success:false, so this is the only thing that tells them apart.
|
|
1376
|
+
* `""` when there is nothing to show.
|
|
1377
|
+
*/
|
|
1378
|
+
function cloudflareCause(env: unknown): string {
|
|
1379
|
+
const errs = env !== null && typeof env === "object" ? (env as any).errors : undefined
|
|
1380
|
+
const e0 = Array.isArray(errs) ? errs[0] : undefined
|
|
1381
|
+
if (e0 === null || typeof e0 !== "object") return ""
|
|
1382
|
+
const parts: string[] = []
|
|
1383
|
+
if ((typeof e0.code === "number" || typeof e0.code === "string") && String(e0.code) !== "") parts.push(String(e0.code))
|
|
1384
|
+
if (typeof e0.message === "string" && e0.message !== "") parts.push(e0.message)
|
|
1385
|
+
const s = capErrorBody(redactErrorBody(parts.join(" ")))
|
|
1386
|
+
return s ? ": " + s : ""
|
|
1387
|
+
}
|
|
1388
|
+
|
|
1389
|
+
/** The System One reply inside a Cloudflare `/ai/run` envelope. Only errors[0] is ever echoed. */
|
|
1390
|
+
function unwrapCloudflare(env: unknown): unknown {
|
|
1391
|
+
if (env === null || typeof env !== "object" || Array.isArray(env) || (env as any).success !== true) {
|
|
1392
|
+
throw new Error("classifier: cloudflare: envelope not successful" + cloudflareCause(env))
|
|
1393
|
+
}
|
|
1394
|
+
let result = (env as any).result
|
|
1395
|
+
if (result !== null && typeof result === "object" && !Array.isArray(result) && typeof result.state === "string") {
|
|
1396
|
+
if (result.state !== "Completed") throw new Error(`classifier: cloudflare: run state "${result.state}"`)
|
|
1397
|
+
result = result.result
|
|
1398
|
+
}
|
|
1399
|
+
return result
|
|
1400
|
+
}
|
|
1401
|
+
|
|
1402
|
+
/** §8B model key: which model an endpoint + requested model (alias) served last. */
|
|
1403
|
+
export function classifierModelCacheKey(endpoint: string, model: string): string {
|
|
1404
|
+
return sha256Hex(canonicalJson({ endpoint, kind: "model", model }))
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
/** §8B decision key, on the SERVED model. No assets ⇒ the `assets` member is omitted. */
|
|
1408
|
+
export function classifierDecisionCacheKey(
|
|
1409
|
+
endpoint: string,
|
|
1410
|
+
servedModel: string,
|
|
1411
|
+
state: unknown,
|
|
1412
|
+
assets: Record<string, unknown> | undefined,
|
|
1413
|
+
questions: Record<string, AnyQuestion>,
|
|
1414
|
+
): string {
|
|
1415
|
+
return sha256Hex(classifierDecisionCacheKeyBytes(endpoint, servedModel, state, assets, questions))
|
|
1416
|
+
}
|
|
1417
|
+
|
|
1418
|
+
/** The canonical JSON a decision key hashes (exported for conformance tests). */
|
|
1419
|
+
export function classifierDecisionCacheKeyBytes(
|
|
1420
|
+
endpoint: string,
|
|
1421
|
+
servedModel: string,
|
|
1422
|
+
state: unknown,
|
|
1423
|
+
assets: Record<string, unknown> | undefined,
|
|
1424
|
+
questions: Record<string, AnyQuestion>,
|
|
1425
|
+
): string {
|
|
1426
|
+
const qs: Record<string, unknown> = {}
|
|
1427
|
+
for (const [k, q] of Object.entries(questions)) qs[k] = questionWire(q)
|
|
1428
|
+
const m: Record<string, unknown> = { endpoint, kind: "decision", model: servedModel, questions: qs, state }
|
|
1429
|
+
if (assets && Object.keys(assets).length > 0) m.assets = assets
|
|
1430
|
+
return canonicalJson(m)
|
|
1431
|
+
}
|
|
1432
|
+
|
|
1433
|
+
/**
|
|
1434
|
+
* In-process TTL cache with a size cap (oldest-inserted out first). `ttlMs` 0 = no expiry; `max`
|
|
1435
|
+
* 0 = unbounded. toolnexus persists nothing itself: implement `ClassifierCache` to persist.
|
|
1436
|
+
*/
|
|
1437
|
+
export class MemoryCache implements ClassifierCache {
|
|
1438
|
+
private readonly m = new Map<string, { v: string; at: number }>()
|
|
1439
|
+
constructor(
|
|
1440
|
+
readonly ttlMs = 0,
|
|
1441
|
+
readonly max = 0,
|
|
1442
|
+
private readonly now: () => number = Date.now,
|
|
1443
|
+
) {}
|
|
1444
|
+
get(key: string): string | undefined {
|
|
1445
|
+
const e = this.m.get(key)
|
|
1446
|
+
if (!e) return undefined
|
|
1447
|
+
if (this.ttlMs > 0 && this.now() - e.at > this.ttlMs) {
|
|
1448
|
+
this.m.delete(key)
|
|
1449
|
+
return undefined
|
|
1450
|
+
}
|
|
1451
|
+
return e.v
|
|
1452
|
+
}
|
|
1453
|
+
set(key: string, value: string): void {
|
|
1454
|
+
this.m.delete(key) // a re-set is a fresh insertion
|
|
1455
|
+
this.m.set(key, { v: value, at: this.now() })
|
|
1456
|
+
if (this.max > 0) while (this.m.size > this.max) this.m.delete(this.m.keys().next().value as string)
|
|
1457
|
+
}
|
|
1458
|
+
get size(): number {
|
|
1459
|
+
return this.m.size
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
/**
|
|
1464
|
+
* V1: fail closed on a reply that does not answer exactly the questions asked, within their
|
|
1465
|
+
* schema. First failure wins, in the §8B order. Returns the error, or undefined when valid.
|
|
1466
|
+
*/
|
|
1467
|
+
export function validateDecision(
|
|
1468
|
+
questions: Record<string, AnyQuestion>,
|
|
1469
|
+
decision: { answers: Record<string, unknown> },
|
|
1470
|
+
): Error | undefined {
|
|
1471
|
+
const bad = (msg: string) => new Error(`classifier: invalid decision: ${msg}`)
|
|
1472
|
+
const answers = decision.answers ?? {}
|
|
1473
|
+
const has = (o: object, k: string) => Object.prototype.hasOwnProperty.call(o, k)
|
|
1474
|
+
const qKeys = Object.keys(questions).sort()
|
|
1475
|
+
for (const k of qKeys) if (!has(answers, k)) return bad(`no answer "${k}"`)
|
|
1476
|
+
const extra = Object.keys(answers)
|
|
1477
|
+
.filter((k) => !has(questions, k))
|
|
1478
|
+
.sort()
|
|
1479
|
+
if (extra.length > 0) return bad(`unexpected answer "${extra[0]}"`)
|
|
1480
|
+
const in01 = (p: unknown) => typeof p === "number" && !Number.isNaN(p) && p >= 0 && p <= 1
|
|
1481
|
+
const dist = (k: string, probs: Record<string, number>, offered: (o: string) => boolean): Error | undefined => {
|
|
1482
|
+
let sum = 0
|
|
1483
|
+
const pk = Object.keys(probs ?? {}).sort()
|
|
1484
|
+
for (const o of pk) {
|
|
1485
|
+
if (!offered(o)) return bad(`answer "${k}": probability for "${o}", which was not offered`)
|
|
1486
|
+
if (!in01(probs[o])) return bad(`answer "${k}": probability ${probs[o]} outside [0,1]`)
|
|
1487
|
+
sum += probs[o]
|
|
1488
|
+
}
|
|
1489
|
+
if (pk.length > 0 && Math.abs(sum - 1) > 0.02) return bad(`answer "${k}": probabilities sum to ${sum.toFixed(4)}`)
|
|
1490
|
+
return undefined
|
|
1491
|
+
}
|
|
1492
|
+
for (const k of qKeys) {
|
|
1493
|
+
const raw = questions[k]
|
|
1494
|
+
const q = raw as Question
|
|
1495
|
+
const a = answers[k] as any
|
|
1496
|
+
const qType = raw.type
|
|
1497
|
+
const aType = a?.type
|
|
1498
|
+
if (aType !== qType) return bad(`answer "${k}" is a ${aType} answer, not ${qType}`)
|
|
1499
|
+
if (isRawQuestion(raw)) continue
|
|
1500
|
+
if (q.type === "noul") {
|
|
1501
|
+
if (!in01(a.noul)) return bad(`answer "${k}": noul ${a.noul} outside [0,1]`)
|
|
1502
|
+
} else if (q.type === "choice") {
|
|
1503
|
+
const offered = (o: string) => has(q.criteria ?? {}, o)
|
|
1504
|
+
if (!offered(a.choice)) return bad(`answer "${k}": choice "${a.choice}" was not offered`)
|
|
1505
|
+
const err = dist(k, a.probabilities, offered)
|
|
1506
|
+
if (err) return err
|
|
1507
|
+
if (!in01(a.confidence)) return bad(`answer "${k}": confidence ${a.confidence} outside [0,1]`)
|
|
1508
|
+
} else {
|
|
1509
|
+
const n = (q.criteria ?? []).length
|
|
1510
|
+
if (typeof a.score !== "number" || Number.isNaN(a.score) || a.score < 0 || a.score > n - 1) {
|
|
1511
|
+
return bad(`answer "${k}": score ${a.score} outside [0,${n - 1}]`)
|
|
1512
|
+
}
|
|
1513
|
+
const offered = (o: string) => Array.from({ length: n }, (_, i) => String(i)).includes(o)
|
|
1514
|
+
const err = dist(k, a.probabilities, offered)
|
|
1515
|
+
if (err) return err
|
|
1516
|
+
if (!in01(a.confidence)) return bad(`answer "${k}": confidence ${a.confidence} outside [0,1]`)
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
return undefined
|
|
1520
|
+
}
|
|
1521
|
+
|
|
1522
|
+
/** jevx's INCLUSIVE cut-points (T1). They sit alongside `Bands` (exclusive), which stays the
|
|
1523
|
+
* default of `ask` / `gate`. */
|
|
1524
|
+
export interface Thresholds {
|
|
1525
|
+
yes: number
|
|
1526
|
+
no: number
|
|
1527
|
+
minConfidence: number
|
|
1528
|
+
}
|
|
1529
|
+
|
|
1530
|
+
/** 0.8 / 0.2 / 0.6 (jevx's builtin profile). */
|
|
1531
|
+
export const DEFAULT_THRESHOLDS: Readonly<Thresholds> = Object.freeze({ yes: 0.8, no: 0.2, minConfidence: 0.6 })
|
|
1532
|
+
|
|
1533
|
+
/**
|
|
1534
|
+
* The band of one answer under `Thresholds`: noul `p >= yes` ⇒ yes, `p <= no` ⇒ no, else
|
|
1535
|
+
* uncertain; choice/score `confidence >= minConfidence` ⇒ yes, else uncertain. No near-uniform
|
|
1536
|
+
* demotion (that is `Bands`' rule). A raw answer is uncertain.
|
|
1537
|
+
*/
|
|
1538
|
+
export function thresholdBand(answer: DecisionAnswer | RawAnswer, t: Thresholds = DEFAULT_THRESHOLDS): "yes" | "no" | "uncertain" {
|
|
1539
|
+
const a = answer as any
|
|
1540
|
+
if (a.type === "noul") {
|
|
1541
|
+
if (a.noul >= t.yes) return "yes"
|
|
1542
|
+
if (a.noul <= t.no) return "no"
|
|
1543
|
+
} else if ((a.type === "choice" || a.type === "score") && a.confidence >= t.minConfidence) {
|
|
1544
|
+
return "yes"
|
|
1545
|
+
}
|
|
1546
|
+
return "uncertain"
|
|
1547
|
+
}
|
|
1548
|
+
|
|
1549
|
+
/**
|
|
1550
|
+
* Split a question set into chunks of `size`, keys in ASCII order. A helper, not automatic:
|
|
1551
|
+
* joint calibration across a split is unproven, so the host decides to split and merges.
|
|
1552
|
+
*/
|
|
1553
|
+
export function chunkQuestions<Q extends AnyQuestion>(questions: Record<string, Q>, size: number): Record<string, Q>[] {
|
|
1554
|
+
if (!(size >= 1)) throw new Error(`classifier: chunk size must be at least 1, got ${size}`)
|
|
1555
|
+
const keys = Object.keys(questions).sort()
|
|
1556
|
+
const out: Record<string, Q>[] = []
|
|
1557
|
+
for (let i = 0; i < keys.length; i += size) {
|
|
1558
|
+
out.push(Object.fromEntries(keys.slice(i, i + size).map((k) => [k, questions[k]])))
|
|
1559
|
+
}
|
|
1560
|
+
return out
|
|
1561
|
+
}
|