toolnexus 0.27.0 → 0.29.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,6 +102,14 @@ 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,
@@ -690,7 +699,7 @@ export class Classifier {
690
699
  this.backend = opts.backend ?? "typesafe"
691
700
  if (!Object.prototype.hasOwnProperty.call(CLASSIFIER_BACKENDS, this.backend)) {
692
701
  throw new Error(
693
- `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")`,
694
703
  )
695
704
  }
696
705
  const preset = CLASSIFIER_BACKENDS[this.backend]
@@ -920,7 +929,9 @@ export class Classifier {
920
929
  const qs: Record<string, unknown> = {}
921
930
  for (const [key, q] of Object.entries(questions)) qs[key] = questionWire(q)
922
931
  let body: Record<string, any>
923
- 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") {
924
935
  const input: Record<string, unknown> = { questions: qs, state: p.state }
925
936
  if (p.images.length > 0) input.images = p.images.map((im) => `data:image/${im.format};base64,${im.data}`)
926
937
  body = { model: this.model, input }
@@ -995,6 +1006,9 @@ export class Classifier {
995
1006
  if (this.preset.wire === "cloudflare") {
996
1007
  parsed = unwrapCloudflare(parsed)
997
1008
  text = canonicalJson(parsed)
1009
+ } else if (this.preset.wire === "decisions") {
1010
+ text = decisionsReply(parsed, questions)
1011
+ parsed = JSON.parse(text)
998
1012
  }
999
1013
  const d = decodeDecision(parsed, this.model)
1000
1014
  const verr = validateDecision(questions, d)
@@ -1020,7 +1034,8 @@ export class Classifier {
1020
1034
  // `endpoint` is what errors name: the CONFIGURED base, ${ENV} unexpanded, so an account id in
1021
1035
  // a templated URL never reaches an error. `url` is what is dialled, expanded at call time.
1022
1036
  let endpoint = this.baseUrl.replace(/\/+$/, "")
1023
- if (this.preset.wire !== "cloudflare") endpoint += "/systemone"
1037
+ if (this.preset.wire === "decisions") endpoint += "/decisions"
1038
+ else if (this.preset.wire !== "cloudflare") endpoint += "/systemone"
1024
1039
  const url = endpoint.includes("${") ? expandEnv(endpoint) : endpoint
1025
1040
  const retries = this.opts.retries ?? 2
1026
1041
  const base = this.opts.retryBaseMs ?? 500
@@ -1559,3 +1574,99 @@ export function chunkQuestions<Q extends AnyQuestion>(questions: Record<string,
1559
1574
  }
1560
1575
  return out
1561
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
+ }