toolnexus 0.25.0 → 0.26.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 CHANGED
@@ -136,6 +136,41 @@ const out = await gate(c, state, questions, [
136
136
  filter, content and model batteries into `hooks`. A `beforeLLM` hook may return `{ model }` to
137
137
  send another model for that turn only.
138
138
 
139
+ ### Backends, images, cache and helpers
140
+
141
+ Every default below is a **proposed default, owner to confirm**
142
+ (`openspec/changes/add-classifier-backends`).
143
+
144
+ ```ts
145
+ import { createClassifier, judge, MemoryCache, validateDecision, thresholdBand, chunkQuestions } from "toolnexus"
146
+
147
+ const c = createClassifier({ backend: "openrouter", cache: new MemoryCache(3_600_000, 1000) })
148
+ const d = await c.state({ note: "listing", photo: { type: "image", mimeType: "image/png", data: b64 } })
149
+ .evaluate(judge.noul("red", "Is the object in `photo` red?")) // fails closed on an invalid reply
150
+ const rs = await c.states("a", "b").evaluate(judge.noul("q", "…")) // [{ decision } | { error }], in order
151
+ ```
152
+
153
+ - `backend`: `typesafe` (default) · `openrouter` (`typesafe/jev-1.14`) · `cloudflare` (`/ai/run`
154
+ envelope; `${CLOUDFLARE_ACCOUNT_ID}` in the URL expands at call time, errors name it unexpanded)
155
+ · `vercel` · `custom` (requires `baseUrl` and `supports`). The table is data:
156
+ `CLASSIFIER_BACKENDS`; `supports: { images }` overrides it per classifier.
157
+ - Images (style `systemone` only): image parts in the state are lifted into `assets` (cloudflare:
158
+ data URLs). PNG/JPEG, 280–1568 px a side, ≤ 16 MiB each and 32 MiB per request — never resized.
159
+ A backend without image support throws `ClassifierUnsupported` before any request. A state with
160
+ no content part is sent byte-identically to before. JS has no distinct part type, so **any**
161
+ object shaped like a `ContentPart` (`{type:"text",text}`, `{type:"image",mimeType,…}`) inside a
162
+ systemone state is treated as a part.
163
+ - `cache: { get(key), set(key, value) }` (sync or async, string values) — off when absent; only
164
+ valid replies are stored; keys follow the served model. A hit sets `decision.cached` and the
165
+ `classifier.evaluate` event's `cached`. Every such event also carries `questions` and
166
+ `questionsHash`.
167
+ - Helpers: `validateDecision(questions, decision)` → `Error | undefined`; `thresholdBand(answer,
168
+ thresholds?)` with `DEFAULT_THRESHOLDS` 0.8 / 0.2 / 0.6 (inclusive; `Bands` stays the `ask`/`gate`
169
+ default); `chunkQuestions(questions, size)`.
170
+ - Open union: `judge.raw(name, type, instructions, criteria?)` / `raw(...)`; an answer of an
171
+ unmodelled type decodes to `{ type, value }` (`decision.raw(key)`).
172
+ - `style: "llm"` is **deprecated** — it still works unchanged, with no runtime warning.
173
+
139
174
  ## Media generation
140
175
 
141
176
  Generate images, video and speech (plus transcription, voice cloning and talking-head video)
@@ -1,14 +1,3 @@
1
- /**
2
- * Classifier (SPEC.md §8B) — the contract for a JUDGMENT, as `Tool` is the contract for an
3
- * ACTION. A System One model takes a state plus pre-declared, typed questions and returns
4
- * calibrated answers with no free text. It has no messages, no tool calling and no streaming,
5
- * so it never enters the §8 client loop.
6
- *
7
- * A classifier INTERPRETS; it never AUTHORISES. Schema validity is not correctness: a decision
8
- * can be confidently wrong, and "cannot hallucinate" means only that the returned value is in
9
- * the declared schema. Numeric limits, permission checks and allowlists stay in code. Nothing
10
- * here is a security control.
11
- */
12
1
  import type { Client } from "./client.js";
13
2
  import { type ErrorInfo, type ErrorTier, type MetricEvent } from "./client.js";
14
3
  /** The System One endpoint base. */
@@ -24,15 +13,50 @@ export declare const DEFAULT_BATCH_CONCURRENCY = 16;
24
13
  /**
25
14
  * A named BACKEND: `baseUrl`, `model` and `apiKeyEnv` are only jointly valid, so they travel as
26
15
  * a UNIT (ADR 0027 D1). `jev-latest` is TypeSafe's spelling; the gateway spells the same model
27
- * `typesafe/jev-1.13`, and mixing the two is issue #91.
16
+ * `typesafe/jev-1.14`, and mixing the two is issue #91. `custom` is a self-hosted System One
17
+ * endpoint and requires `baseUrl` + `supports` (SPEC §8B "Backends, images, cache and validation").
28
18
  */
29
- export type ClassifierBackend = "typesafe" | "openrouter";
30
- /** The two servable pairings, as configurations rather than six independently-choosable cells. */
31
- export declare const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, {
19
+ export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "custom";
20
+ /** What a backend+model can take. DATA: a fail-fast for what the table says, never a claim about
21
+ * the vendor. Override per classifier with `ClassifierOptions.supports`. */
22
+ export interface ClassifierSupports {
23
+ images: boolean;
24
+ }
25
+ /** One row of the backend table. */
26
+ export interface ClassifierBackendPreset {
27
+ /** `""` on `custom`, which requires `baseUrl`. May carry `${NAME}`, expanded at call time. */
32
28
  baseUrl: string;
33
29
  model: string;
34
30
  apiKeyEnv: string;
35
- }>>;
31
+ /** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope. */
32
+ wire: "systemone" | "cloudflare";
33
+ /** Send `TypeSafe-Danger-Zone-Images: true` when a request carries images. */
34
+ imagesHeader: boolean;
35
+ /** The default support for a model on this backend; absent on `custom`. */
36
+ supports?: (model: string) => ClassifierSupports;
37
+ }
38
+ /** TypeSafe's required opt-in header for image ("danger zone") input. */
39
+ export declare const CLASSIFIER_IMAGES_HEADER = "TypeSafe-Danger-Zone-Images";
40
+ /** Image limits (docs.typesafe.ai/images): checked client-side, never resized. */
41
+ export declare const CLASSIFIER_IMAGE_MIN_SIDE = 280;
42
+ export declare const CLASSIFIER_IMAGE_MAX_SIDE = 1568;
43
+ export declare const CLASSIFIER_IMAGE_MAX_BYTES: number;
44
+ export declare const CLASSIFIER_REQUEST_IMAGE_BYTES: number;
45
+ /**
46
+ * The backend table (PROPOSED defaults, owner to confirm — design.md PD-1..PD-4). Vercel and
47
+ * Cloudflare-Jev images stay false until a live probe proves them.
48
+ */
49
+ export declare const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, Readonly<ClassifierBackendPreset>>>;
50
+ /**
51
+ * Raised BEFORE any network call when the backend table says the backend cannot take what the
52
+ * request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
53
+ */
54
+ export declare class ClassifierUnsupported extends Error {
55
+ readonly backend: string;
56
+ readonly model: string;
57
+ readonly feature: string;
58
+ constructor(backend: string, model: string, feature: string);
59
+ }
36
60
  /** Client-side cap on a choice's named options (§8B). */
37
61
  export declare const MAX_CHOICE_OPTIONS = 255;
38
62
  /** Bounds of a score rubric (§8B). */
@@ -83,8 +107,25 @@ export interface ScoreQuestion {
83
107
  instructions: string;
84
108
  criteria: string[];
85
109
  }
86
- /** The closed set of question types, discriminated by `type`. */
110
+ /** The modelled question types, discriminated by `type`. */
87
111
  export type Question = NoulQuestion | ChoiceQuestion | ScoreQuestion;
112
+ /**
113
+ * The OPEN end of the question union (§8B): a System One type the library does not model (e.g.
114
+ * Jev 1.14 `box`), sent as written with no client-side limits. `type` must be non-empty.
115
+ */
116
+ export interface RawQuestion {
117
+ type: string;
118
+ instructions: string;
119
+ /** Sent verbatim when present, absent otherwise. */
120
+ criteria?: unknown;
121
+ }
122
+ /** Every question `evaluate` accepts: the modelled three plus a raw one. */
123
+ export type AnyQuestion = Question | RawQuestion;
124
+ /** True when `q` is a raw question (its type is not noul/choice/score). Not a type guard: every
125
+ * modelled question is structurally a `RawQuestion` too. */
126
+ export declare function isRawQuestion(q: AnyQuestion): boolean;
127
+ /** A raw question, sent as written. */
128
+ export declare function raw(type: string, instructions: string, criteria?: unknown): RawQuestion;
88
129
  /** A noul question. Pass `criteria` only when you mean it — absent ≠ empty on the wire. */
89
130
  export declare function noul(instructions: string, criteria?: NoulCriteria): NoulQuestion;
90
131
  /** A choice question. Every description must say what picking that option would MEAN. */
@@ -98,7 +139,7 @@ export declare function score(instructions: string, criteria: string[]): ScoreQu
98
139
  */
99
140
  export declare function choiceOver(instructions: string, items: Record<string, string>): ChoiceQuestion;
100
141
  /** The wire form of one question — the object itself, minus an absent `criteria`. */
101
- export declare function questionWire(q: Question): Record<string, unknown>;
142
+ export declare function questionWire(question: AnyQuestion): Record<string, unknown>;
102
143
  /**
103
144
  * Canonical JSON: object keys sorted recursively in CODE-UNIT (ASCII) order, arrays NEVER
104
145
  * reordered, compact separators, and `<>&'"` plus non-ASCII transmitted raw.
@@ -120,7 +161,7 @@ export declare function canonicalJson(value: unknown): string;
120
161
  * ways across our own seven runtimes). Do not re-widen this: a caller who needs their state
121
162
  * pinned canonicalises it themselves before handing it over.
122
163
  */
123
- export declare function canonicalRequest(model: string, questions: Record<string, Question>): string;
164
+ export declare function canonicalRequest(model: string, questions: Record<string, AnyQuestion>): string;
124
165
  /** The probability that a statement holds. NO confidence: the number is the answer. */
125
166
  export interface NoulAnswer {
126
167
  type: "noul";
@@ -155,6 +196,18 @@ export interface ScoreAnswer {
155
196
  * `Answer` (the suspension resolution) — same idea, different seam.
156
197
  */
157
198
  export type DecisionAnswer = NoulAnswer | ChoiceAnswer | ScoreAnswer;
199
+ /**
200
+ * An answer of a type the library does not model (the open union, §8B). It may appear in
201
+ * `Decision.answers` at runtime (read it with `decision.raw(key)` or `isRawAnswer`); `value` is the
202
+ * whole answer object as decoded, `type` included. It is kept out of `DecisionAnswer` so existing
203
+ * `switch (a.type)` narrowing keeps compiling.
204
+ */
205
+ export interface RawAnswer {
206
+ type: string;
207
+ value: Record<string, unknown>;
208
+ }
209
+ /** True when an answer is a raw (unmodelled-type) answer. */
210
+ export declare function isRawAnswer(a: unknown): a is RawAnswer;
158
211
  /** The wire's usage block. `cost` is absent on some backends. */
159
212
  export interface ClassifierUsage {
160
213
  inputTokens: number;
@@ -178,6 +231,8 @@ export declare class Decision {
178
231
  * BACKEND DOES NOT TRANSFER TO ANOTHER.
179
232
  */
180
233
  readonly calibrated: boolean;
234
+ /** True when the decision came from `ClassifierOptions.cache` and no request was sent. */
235
+ readonly cached: boolean;
181
236
  constructor(
182
237
  /** What actually answered, which may be more specific than the model that was asked for. */
183
238
  model: string, answers: Record<string, DecisionAnswer>, usage: ClassifierUsage,
@@ -186,7 +241,11 @@ export declare class Decision {
186
241
  * unless it derived them from provider token probabilities. A THRESHOLD TUNED AGAINST ONE
187
242
  * BACKEND DOES NOT TRANSFER TO ANOTHER.
188
243
  */
189
- calibrated: boolean);
244
+ calibrated: boolean,
245
+ /** True when the decision came from `ClassifierOptions.cache` and no request was sent. */
246
+ cached?: boolean);
247
+ /** Typed read of a raw (unmodelled-type) answer. Throws on an absent key or a modelled type. */
248
+ raw(key: string): RawAnswer;
190
249
  /** Typed read. Throws (never returns undefined) on a wrong-type or absent key. */
191
250
  noul(key: string): NoulAnswer;
192
251
  choice(key: string): ChoiceAnswer;
@@ -210,7 +269,12 @@ export declare function nearUniform(probabilities: Record<string, number>): bool
210
269
  * `nearUniform` is derived. A malformed answer is an error, never a repaired one (ADR 0020).
211
270
  */
212
271
  export declare function decodeDecision(raw: unknown, fallbackModel: string): Decision;
213
- /** `"systemone" | "llm" | "custom" | "static"` — the same slot as the client's `style`. */
272
+ /**
273
+ * `"systemone" | "llm" | "custom" | "static"` — the same slot as the client's `style`.
274
+ *
275
+ * `"llm"` is DEPRECATED (ADR 0038 D3: System One is the single product wire). It keeps working
276
+ * byte-identically, as an alias, for at least one minor release; there is no runtime warning.
277
+ */
214
278
  export type ClassifierStyle = "systemone" | "llm" | "custom" | "static";
215
279
  /**
216
280
  * One entry of the `static` corpus. It is keyed on the canonical request AND the state: several
@@ -220,12 +284,12 @@ export type ClassifierStyle = "systemone" | "llm" | "custom" | "static";
220
284
  */
221
285
  export interface RecordedDecision {
222
286
  state: unknown;
223
- questions: Record<string, Question>;
287
+ questions: Record<string, AnyQuestion>;
224
288
  /** The recorded backend response body, verbatim. */
225
289
  response: unknown;
226
290
  }
227
291
  /** The host's own backend (`style: "custom"`). Every wire option is ignored. */
228
- export type EvaluateFn = (state: unknown, questions: Record<string, Question>, signal?: AbortSignal) => Promise<Decision> | Decision;
292
+ export type EvaluateFn = (state: unknown, questions: Record<string, AnyQuestion>, signal?: AbortSignal) => Promise<Decision> | Decision;
229
293
  /**
230
294
  * Mirrors §8 `ClientOptions` field-for-field wherever a field makes sense, so a host that has
231
295
  * configured one has configured the other.
@@ -233,7 +297,7 @@ export type EvaluateFn = (state: unknown, questions: Record<string, Question>, s
233
297
  export interface ClassifierOptions {
234
298
  /** Default `"systemone"`. */
235
299
  style?: ClassifierStyle;
236
- /** A named `baseUrl` + `model` + `apiKeyEnv` pairing (`"typesafe"` | `"openrouter"`), set as a
300
+ /** A named backend row (`"typesafe"` | `"openrouter"` | `"cloudflare"` | `"vercel"` | `"custom"`), set as a
237
301
  * UNIT. Default `"typesafe"`. The three individual options still win where given, for a
238
302
  * self-hosted origin — but a known-bad mixture is refused at construction, not 700 ms later
239
303
  * as an "Unknown model" 400. */
@@ -277,12 +341,34 @@ export interface ClassifierOptions {
277
341
  /** Emits `classifier.evaluate` events into the SAME §8 sink, and carries the
278
342
  * degenerate-criteria warning as `classifier.warning`. */
279
343
  onMetric?: (ev: MetricEvent) => void;
280
- /** `style: "llm"` only — the §8 Client to emulate over. */
344
+ /** `style: "llm"` only — the §8 Client to emulate over.
345
+ * @deprecated `style: "llm"` is deprecated (System One is the single wire); it still works. */
281
346
  client?: Client;
282
347
  /** `style: "custom"` only — the host's own function. */
283
348
  evaluate?: EvaluateFn;
284
349
  /** `style: "static"` only — the recorded corpus. */
285
350
  decisions?: RecordedDecision[];
351
+ /** Overrides the backend table's support row for this classifier. Required with `backend: "custom"`. */
352
+ supports?: ClassifierSupports;
353
+ /** Opt-in answer cache (§8B). Absent ⇒ no caching. Consulted by style `systemone` only; only
354
+ * replies that pass `validateDecision` are stored. Methods may return values or Promises. */
355
+ cache?: ClassifierCache;
356
+ }
357
+ /** An injected answer cache. Values are opaque strings: the served model under a model key, the
358
+ * System One reply body under a decision key. */
359
+ export interface ClassifierCache {
360
+ get(key: string): string | undefined | null | Promise<string | undefined | null>;
361
+ set(key: string, value: string): void | Promise<void>;
362
+ }
363
+ /** One state's outcome from `states(...).evaluate`: exactly one of `decision` / `error` is set. */
364
+ export interface BatchResult {
365
+ decision?: Decision;
366
+ error?: Error;
367
+ }
368
+ /** A named question (`judge.noul(...)` etc. build these). */
369
+ export interface NamedClassifierQuestion {
370
+ name: string;
371
+ question: AnyQuestion;
286
372
  }
287
373
  /** The whole seam: one verb. */
288
374
  export declare class Classifier {
@@ -292,6 +378,9 @@ export declare class Classifier {
292
378
  private readonly model;
293
379
  private readonly apiKeyEnv;
294
380
  private readonly timeoutMs;
381
+ private readonly backend;
382
+ private readonly preset;
383
+ private readonly supports;
295
384
  private readonly corpus;
296
385
  /** The once-per-question-key set for the degenerate warning, so a per-turn judge does not
297
386
  * flood the sink. */
@@ -301,14 +390,33 @@ export declare class Classifier {
301
390
  * The whole contract: a state plus typed questions in, a Decision out. Questions are
302
391
  * INDEPENDENT — one answer is never context for another.
303
392
  */
304
- evaluate(state: unknown, questions: Record<string, Question>, signal?: AbortSignal): Promise<Decision>;
393
+ evaluate(state: unknown, questions: Record<string, AnyQuestion>, signal?: AbortSignal): Promise<Decision>;
394
+ /**
395
+ * Binds one state (a VALUE, not a session: nothing is sent until `evaluate`). Its `evaluate`
396
+ * runs the same path as `evaluate` PLUS fail-closed validation (`validateDecision`).
397
+ */
398
+ state(value: unknown, opts?: {
399
+ signal?: AbortSignal;
400
+ }): {
401
+ evaluate(...questions: NamedClassifierQuestion[]): Promise<Decision>;
402
+ };
403
+ /**
404
+ * Binds a batch of states. Its `evaluate` runs `state(v).evaluate` per state, at most 16 in
405
+ * flight, and returns one `{decision | error}` per state IN ORDER. One state's failure never
406
+ * fails the batch; only a question-set error (duplicate name) throws, before any request.
407
+ */
408
+ states(...values: unknown[]): {
409
+ evaluate(...questions: NamedClassifierQuestion[]): Promise<BatchResult[]>;
410
+ };
411
+ /** The one path. `validate` = fail closed on `validateDecision` (the value builders). */
412
+ private run;
305
413
  /**
306
414
  * The same questions over many states (SPEC §8B "Batch"). Each state goes through
307
415
  * `evaluate`, at most `concurrency` (default 16) in flight; decisions come back in STATE
308
416
  * order. Fails closed: the first failing state's index is named and no decisions return.
309
417
  * No states is an error and sends nothing.
310
418
  */
311
- evaluateBatch(states: readonly unknown[], questions: Record<string, Question>, opts?: {
419
+ evaluateBatch(states: readonly unknown[], questions: Record<string, AnyQuestion>, opts?: {
312
420
  signal?: AbortSignal;
313
421
  concurrency?: number;
314
422
  }): Promise<Decision[]>;
@@ -319,15 +427,18 @@ export declare class Classifier {
319
427
  */
320
428
  private reportDegenerate;
321
429
  /**
322
- * Assembles the request: the canonical model + questions, plus state VERBATIM as the host
323
- * supplied it, then the §8 Gap 1 pipeline in §8 order (base → requestParams merge →
324
- * bodyTransform → marshal).
430
+ * Assembles the request for the backend's wire: the canonical model + questions, plus the state
431
+ * (VERBATIM unless it carried content parts) and, on systemone, `assets`; then the §8 Gap 1
432
+ * pipeline in §8 order (base → requestParams merge → bodyTransform → marshal).
325
433
  */
326
434
  private body;
327
435
  /** Identifies a recorded decision by the canonical request AND the state. */
328
436
  private staticKey;
329
437
  private evaluateStatic;
330
- private evaluateSystemOne;
438
+ /** Lift images, apply the support table and the limits — all before any request. */
439
+ private prepare;
440
+ /** The systemone style on every backend: lift, check, cache, post, decode. */
441
+ private evaluateWire;
331
442
  /**
332
443
  * The one POST, with the §8 retry budget, reusing the client's `ErrorInfo`/`ErrorTier`
333
444
  * classifier and the `Retry-After` delay-seconds rule verbatim.
@@ -369,3 +480,50 @@ export declare function degenerateCriteria(criteria: Record<string, string>): st
369
480
  * with, or `""` when the pairing is fine.
370
481
  */
371
482
  export declare function backendMismatch(baseUrl: string, model: string): string;
483
+ /** §8B model key: which model an endpoint + requested model (alias) served last. */
484
+ export declare function classifierModelCacheKey(endpoint: string, model: string): string;
485
+ /** §8B decision key, on the SERVED model. No assets ⇒ the `assets` member is omitted. */
486
+ export declare function classifierDecisionCacheKey(endpoint: string, servedModel: string, state: unknown, assets: Record<string, unknown> | undefined, questions: Record<string, AnyQuestion>): string;
487
+ /** The canonical JSON a decision key hashes (exported for conformance tests). */
488
+ export declare function classifierDecisionCacheKeyBytes(endpoint: string, servedModel: string, state: unknown, assets: Record<string, unknown> | undefined, questions: Record<string, AnyQuestion>): string;
489
+ /**
490
+ * In-process TTL cache with a size cap (oldest-inserted out first). `ttlMs` 0 = no expiry; `max`
491
+ * 0 = unbounded. toolnexus persists nothing itself: implement `ClassifierCache` to persist.
492
+ */
493
+ export declare class MemoryCache implements ClassifierCache {
494
+ readonly ttlMs: number;
495
+ readonly max: number;
496
+ private readonly now;
497
+ private readonly m;
498
+ constructor(ttlMs?: number, max?: number, now?: () => number);
499
+ get(key: string): string | undefined;
500
+ set(key: string, value: string): void;
501
+ get size(): number;
502
+ }
503
+ /**
504
+ * V1: fail closed on a reply that does not answer exactly the questions asked, within their
505
+ * schema. First failure wins, in the §8B order. Returns the error, or undefined when valid.
506
+ */
507
+ export declare function validateDecision(questions: Record<string, AnyQuestion>, decision: {
508
+ answers: Record<string, unknown>;
509
+ }): Error | undefined;
510
+ /** jevx's INCLUSIVE cut-points (T1). They sit alongside `Bands` (exclusive), which stays the
511
+ * default of `ask` / `gate`. */
512
+ export interface Thresholds {
513
+ yes: number;
514
+ no: number;
515
+ minConfidence: number;
516
+ }
517
+ /** 0.8 / 0.2 / 0.6 (jevx's builtin profile). */
518
+ export declare const DEFAULT_THRESHOLDS: Readonly<Thresholds>;
519
+ /**
520
+ * The band of one answer under `Thresholds`: noul `p >= yes` ⇒ yes, `p <= no` ⇒ no, else
521
+ * uncertain; choice/score `confidence >= minConfidence` ⇒ yes, else uncertain. No near-uniform
522
+ * demotion (that is `Bands`' rule). A raw answer is uncertain.
523
+ */
524
+ export declare function thresholdBand(answer: DecisionAnswer | RawAnswer, t?: Thresholds): "yes" | "no" | "uncertain";
525
+ /**
526
+ * Split a question set into chunks of `size`, keys in ASCII order. A helper, not automatic:
527
+ * joint calibration across a split is unproven, so the host decides to split and merges.
528
+ */
529
+ export declare function chunkQuestions<Q extends AnyQuestion>(questions: Record<string, Q>, size: number): Record<string, Q>[];