toolnexus 0.26.0 → 0.28.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/src/classifier.ts CHANGED
@@ -34,7 +34,7 @@ export const DEFAULT_BATCH_CONCURRENCY = 16
34
34
  * `typesafe/jev-1.14`, and mixing the two is issue #91. `custom` is a self-hosted System One
35
35
  * endpoint and requires `baseUrl` + `supports` (SPEC §8B "Backends, images, cache and validation").
36
36
  */
37
- export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "custom"
37
+ export type ClassifierBackend = "typesafe" | "openrouter" | "cloudflare" | "vercel" | "openai" | "custom"
38
38
 
39
39
  /** What a backend+model can take. DATA: a fail-fast for what the table says, never a claim about
40
40
  * the vendor. Override per classifier with `ClassifierOptions.supports`. */
@@ -48,8 +48,9 @@ export interface ClassifierBackendPreset {
48
48
  baseUrl: string
49
49
  model: string
50
50
  apiKeyEnv: string
51
- /** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope. */
52
- wire: "systemone" | "cloudflare"
51
+ /** `systemone`: POST {baseUrl}/systemone. `cloudflare`: POST {baseUrl}, `/ai/run` envelope.
52
+ * `decisions`: POST {baseUrl}/decisions, OpenAI's Decisions API (#157). */
53
+ wire: "systemone" | "cloudflare" | "decisions"
53
54
  /** Send `TypeSafe-Danger-Zone-Images: true` when a request carries images. */
54
55
  imagesHeader: boolean
55
56
  /** The default support for a model on this backend; absent on `custom`. */
@@ -101,15 +102,46 @@ export const CLASSIFIER_BACKENDS: Readonly<Record<ClassifierBackend, Readonly<Cl
101
102
  imagesHeader: true,
102
103
  supports: () => ({ images: false }),
103
104
  },
105
+ openai: {
106
+ baseUrl: "https://api.openai.com/v1",
107
+ model: "gpt-6-luna",
108
+ apiKeyEnv: "OPENAI_API_KEY",
109
+ wire: "decisions",
110
+ imagesHeader: false,
111
+ supports: () => ({ images: true }),
112
+ },
104
113
  custom: {
105
114
  baseUrl: "",
106
115
  model: DEFAULT_CLASSIFIER_MODEL,
107
- apiKeyEnv: DEFAULT_CLASSIFIER_API_KEY_ENV,
116
+ apiKeyEnv: "",
108
117
  wire: "systemone",
109
118
  imagesHeader: true,
110
119
  },
111
120
  })
112
121
 
122
+ /**
123
+ * The env var whose key may be sent to `baseUrl` (already `${NAME}`-expanded): the backend's own
124
+ * variable, and only when `baseUrl` is that backend's own host over https (OpenRouter also its
125
+ * subdomains). `""` when none may — `custom` has no preset key at all. A key is never sent to a
126
+ * host the caller did not name (#155, SPEC §8B).
127
+ */
128
+ export function classifierKeyEnv(backend: ClassifierBackend, baseUrl: string): string {
129
+ const p = Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, backend) ? CLASSIFIER_BACKENDS[backend] : undefined
130
+ if (!p || !p.apiKeyEnv || !p.baseUrl) return ""
131
+ const hostOf = (u: string): string => {
132
+ try {
133
+ const url = new URL(u)
134
+ return url.protocol === "https:" ? url.hostname.toLowerCase() : ""
135
+ } catch {
136
+ return ""
137
+ }
138
+ }
139
+ const h = hostOf(baseUrl)
140
+ const own = hostOf(p.baseUrl)
141
+ if (!h || !own) return ""
142
+ return h === own || (backend === "openrouter" && h.endsWith(".openrouter.ai")) ? p.apiKeyEnv : ""
143
+ }
144
+
113
145
  /**
114
146
  * Raised BEFORE any network call when the backend table says the backend cannot take what the
115
147
  * request carries. Message, exact: `classifier: <backend> (<model>) does not support <feature>`.
@@ -650,6 +682,9 @@ export class Classifier {
650
682
  private readonly baseUrl: string
651
683
  private readonly model: string
652
684
  private readonly apiKeyEnv: string
685
+ /** The caller NAMED apiKeyEnv: sent wherever baseUrl points. Otherwise the preset's own key,
686
+ * and only to its own host (classifierKeyEnv, #155). */
687
+ private readonly explicitKeyEnv: boolean
653
688
  private readonly timeoutMs: number
654
689
  private readonly backend: ClassifierBackend
655
690
  private readonly preset: Readonly<ClassifierBackendPreset>
@@ -664,7 +699,7 @@ export class Classifier {
664
699
  this.backend = opts.backend ?? "typesafe"
665
700
  if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, this.backend)) {
666
701
  throw new Error(
667
- `classifier: unknown backend "${this.backend}" (want "typesafe", "openrouter", "cloudflare", "vercel" or "custom")`,
702
+ `classifier: unknown backend "${this.backend}" (want "typesafe", "openrouter", "cloudflare", "vercel", "openai" or "custom")`,
668
703
  )
669
704
  }
670
705
  const preset = CLASSIFIER_BACKENDS[this.backend]
@@ -675,6 +710,7 @@ export class Classifier {
675
710
  this.baseUrl = opts.baseUrl || preset.baseUrl
676
711
  this.model = opts.model ?? preset.model
677
712
  this.apiKeyEnv = opts.apiKeyEnv ?? preset.apiKeyEnv
713
+ this.explicitKeyEnv = opts.apiKeyEnv !== undefined
678
714
  const mismatch = backendMismatch(this.baseUrl, this.model)
679
715
  if (mismatch) throw new Error(mismatch)
680
716
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_CLASSIFIER_TIMEOUT_MS
@@ -893,7 +929,9 @@ export class Classifier {
893
929
  const qs: Record<string, unknown> = {}
894
930
  for (const [key, q] of Object.entries(questions)) qs[key] = questionWire(q)
895
931
  let body: Record<string, any>
896
- if (this.preset.wire === "cloudflare") {
932
+ if (this.preset.wire === "decisions") {
933
+ body = { model: this.model, input: decisionsInput(p), questions: decisionsQuestions(this.backend, this.model, questions) }
934
+ } else if (this.preset.wire === "cloudflare") {
897
935
  const input: Record<string, unknown> = { questions: qs, state: p.state }
898
936
  if (p.images.length > 0) input.images = p.images.map((im) => `data:image/${im.format};base64,${im.data}`)
899
937
  body = { model: this.model, input }
@@ -968,6 +1006,9 @@ export class Classifier {
968
1006
  if (this.preset.wire === "cloudflare") {
969
1007
  parsed = unwrapCloudflare(parsed)
970
1008
  text = canonicalJson(parsed)
1009
+ } else if (this.preset.wire === "decisions") {
1010
+ text = decisionsReply(parsed, questions)
1011
+ parsed = JSON.parse(text)
971
1012
  }
972
1013
  const d = decodeDecision(parsed, this.model)
973
1014
  const verr = validateDecision(questions, d)
@@ -993,7 +1034,8 @@ export class Classifier {
993
1034
  // `endpoint` is what errors name: the CONFIGURED base, ${ENV} unexpanded, so an account id in
994
1035
  // a templated URL never reaches an error. `url` is what is dialled, expanded at call time.
995
1036
  let endpoint = this.baseUrl.replace(/\/+$/, "")
996
- if (this.preset.wire !== "cloudflare") endpoint += "/systemone"
1037
+ if (this.preset.wire === "decisions") endpoint += "/decisions"
1038
+ else if (this.preset.wire !== "cloudflare") endpoint += "/systemone"
997
1039
  const url = endpoint.includes("${") ? expandEnv(endpoint) : endpoint
998
1040
  const retries = this.opts.retries ?? 2
999
1041
  const base = this.opts.retryBaseMs ?? 500
@@ -1019,7 +1061,7 @@ export class Classifier {
1019
1061
  try {
1020
1062
  res = await doFetch(url, {
1021
1063
  method: "POST",
1022
- headers: this.requestHeaders(extraHeaders),
1064
+ headers: this.requestHeaders(extraHeaders, url),
1023
1065
  body: payload,
1024
1066
  signal: timer.signal,
1025
1067
  })
@@ -1049,9 +1091,10 @@ export class Classifier {
1049
1091
  }
1050
1092
 
1051
1093
  /** Credential and `${ENV_VAR}` headers resolve AT CALL TIME. Never logged, never returned. */
1052
- private requestHeaders(extra: Record<string, string> = {}): Record<string, string> {
1094
+ private requestHeaders(extra: Record<string, string>, url: string): Record<string, string> {
1053
1095
  const headers: Record<string, string> = { "Content-Type": "application/json" }
1054
- const key = process.env[this.apiKeyEnv]
1096
+ const keyEnv = this.explicitKeyEnv ? this.apiKeyEnv : classifierKeyEnv(this.backend, url)
1097
+ const key = keyEnv ? process.env[keyEnv] : undefined
1055
1098
  if (key) headers["Authorization"] = `Bearer ${key}`
1056
1099
  // The image opt-in header goes BEFORE host headers, which win.
1057
1100
  for (const [k, v] of Object.entries(extra)) headers[k] = v
@@ -1341,10 +1384,27 @@ function imageSize(format: string, b: Uint8Array): [number, number] | undefined
1341
1384
  return undefined
1342
1385
  }
1343
1386
 
1344
- /** The System One reply inside a Cloudflare `/ai/run` envelope. Its error list is never echoed. */
1387
+ /**
1388
+ * `: <code> <message>` from errors[0] of a failed envelope (SPEC §8B), through the same redaction
1389
+ * and cap as cause(). Cloudflare reports a missing balance, a bad model id and a malformed
1390
+ * request alike as HTTP 200 + success:false, so this is the only thing that tells them apart.
1391
+ * `""` when there is nothing to show.
1392
+ */
1393
+ function cloudflareCause(env: unknown): string {
1394
+ const errs = env !== null && typeof env === "object" ? (env as any).errors : undefined
1395
+ const e0 = Array.isArray(errs) ? errs[0] : undefined
1396
+ if (e0 === null || typeof e0 !== "object") return ""
1397
+ const parts: string[] = []
1398
+ if ((typeof e0.code === "number" || typeof e0.code === "string") && String(e0.code) !== "") parts.push(String(e0.code))
1399
+ if (typeof e0.message === "string" && e0.message !== "") parts.push(e0.message)
1400
+ const s = capErrorBody(redactErrorBody(parts.join(" ")))
1401
+ return s ? ": " + s : ""
1402
+ }
1403
+
1404
+ /** The System One reply inside a Cloudflare `/ai/run` envelope. Only errors[0] is ever echoed. */
1345
1405
  function unwrapCloudflare(env: unknown): unknown {
1346
1406
  if (env === null || typeof env !== "object" || Array.isArray(env) || (env as any).success !== true) {
1347
- throw new Error("classifier: cloudflare: envelope not successful")
1407
+ throw new Error("classifier: cloudflare: envelope not successful" + cloudflareCause(env))
1348
1408
  }
1349
1409
  let result = (env as any).result
1350
1410
  if (result !== null && typeof result === "object" && !Array.isArray(result) && typeof result.state === "string") {
@@ -1514,3 +1574,99 @@ export function chunkQuestions<Q extends AnyQuestion>(questions: Record<string,
1514
1574
  }
1515
1575
  return out
1516
1576
  }
1577
+
1578
+ // ---------------------------------------------------------------- the Decisions wire (#157)
1579
+
1580
+ /** The Decisions API's per-request question cap. */
1581
+ export const MAX_DECISIONS_QUESTIONS = 400
1582
+
1583
+ /**
1584
+ * Maps the question set to the Decisions API list, in ASCII key order. Caller keys are NOT sent
1585
+ * (no `name`): §8B keys are addressing, not content, and answers come back in request order.
1586
+ * Every check fires before any request.
1587
+ */
1588
+ function decisionsQuestions(backend: string, model: string, questions: Record<string, AnyQuestion>): unknown[] {
1589
+ const keys = Object.keys(questions).sort()
1590
+ if (keys.length > MAX_DECISIONS_QUESTIONS) {
1591
+ throw new Error(`classifier: the decisions wire takes at most ${MAX_DECISIONS_QUESTIONS} questions, got ${keys.length}`)
1592
+ }
1593
+ return keys.map((k) => {
1594
+ const q = questions[k]
1595
+ if (isRawQuestion(q)) throw new ClassifierUnsupported(backend, model, `raw question type "${q.type}"`)
1596
+ const m = q as Question
1597
+ switch (m.type) {
1598
+ case "noul": {
1599
+ let instructions = m.instructions
1600
+ if (m.criteria !== undefined) instructions += `\n\nTrue means: ${m.criteria.true}\nFalse means: ${m.criteria.false}`
1601
+ return { type: "predicate", instructions }
1602
+ }
1603
+ case "choice": {
1604
+ const ids = Object.keys(m.criteria ?? {}).sort()
1605
+ if (ids.length < 2) {
1606
+ throw new Error(`classifier: question "${k}": the decisions wire needs 2..${MAX_CHOICE_OPTIONS} choice options, got ${ids.length}`)
1607
+ }
1608
+ const choices = ids.map((id) => (m.criteria[id] === "" ? { value: id } : { value: id, description: m.criteria[id] }))
1609
+ return { type: "choice", instructions: m.instructions, choices }
1610
+ }
1611
+ case "score":
1612
+ return { type: "score", instructions: m.instructions, levels: m.criteria.map((label) => ({ label })) }
1613
+ }
1614
+ })
1615
+ }
1616
+
1617
+ /** The state as sent: a string verbatim, else canonical JSON text; with images, one user message. */
1618
+ function decisionsInput(p: PreparedRequest): unknown {
1619
+ const text = typeof p.state === "string" ? p.state : canonicalJson(p.state)
1620
+ if (p.images.length === 0) return text
1621
+ const content: unknown[] = [{ type: "input_text", text }]
1622
+ for (const im of p.images) content.push({ type: "input_image", image_url: `data:image/${im.format};base64,${im.data}` })
1623
+ return [{ role: "user", content }]
1624
+ }
1625
+
1626
+ /** A choice value or level index as the §8B key: string as written, boolean "true"/"false", number shortest. */
1627
+ function decisionsValue(v: unknown): string {
1628
+ return typeof v === "string" ? v : String(v)
1629
+ }
1630
+
1631
+ /**
1632
+ * Reads a Decisions API reply back into a System One-shaped body (canonical JSON), so decode,
1633
+ * validation and the cache are the one path. `calibrated` is false. Exported for conformance tests.
1634
+ */
1635
+ export function decisionsReply(reply: unknown, questions: Record<string, AnyQuestion>): string {
1636
+ const r = reply as any
1637
+ if (r === null || typeof r !== "object" || Array.isArray(r) || !Array.isArray(r.answers)) {
1638
+ throw new Error("classifier: decisions: malformed reply")
1639
+ }
1640
+ const keys = Object.keys(questions).sort()
1641
+ if (r.answers.length !== keys.length) {
1642
+ throw new Error(`classifier: decisions: ${r.answers.length} answers for ${keys.length} questions`)
1643
+ }
1644
+ const num = (v: unknown): number => (typeof v === "number" ? v : 0)
1645
+ const want: Record<string, string> = { noul: "predicate", choice: "choice", score: "score" }
1646
+ const answers: Record<string, unknown> = {}
1647
+ keys.forEach((k, i) => {
1648
+ const a = r.answers[i]
1649
+ if (a === null || typeof a !== "object" || Array.isArray(a)) throw new Error("classifier: decisions: malformed reply")
1650
+ if (a.type === "refusal") throw new Error(`classifier: decisions: question "${k}" was refused`)
1651
+ const q = questions[k]
1652
+ const w = want[q.type] ?? ""
1653
+ if (a.type !== w) throw new Error(`classifier: decisions: answer "${k}" is a ${a.type} answer, not ${w}`)
1654
+ const probs: Record<string, number> = {}
1655
+ for (const p of Array.isArray(a.probabilities) ? a.probabilities : []) probs[decisionsValue(p?.value)] = num(p?.probability)
1656
+ if (q.type === "noul") answers[k] = { type: "noul", noul: num(a.probability) }
1657
+ else if (q.type === "choice") {
1658
+ answers[k] = { type: "choice", choice: decisionsValue(a.choice), probabilities: probs, confidence: num(a.confidence) }
1659
+ } else {
1660
+ const legend: Record<string, string> = {}
1661
+ ;(q as ScoreQuestion).criteria.forEach((l, j) => (legend[String(j)] = l))
1662
+ answers[k] = { type: "score", score: num(a.score), legend, probabilities: probs, confidence: num(a.confidence) }
1663
+ }
1664
+ })
1665
+ const u = r.usage ?? {}
1666
+ return canonicalJson({
1667
+ answers,
1668
+ calibrated: false,
1669
+ model: typeof r.model === "string" ? r.model : "",
1670
+ usage: { input_tokens: num(u.input_tokens), output_tokens: num(u.output_tokens) },
1671
+ })
1672
+ }