toolnexus 0.17.0 → 0.18.1

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/client.ts CHANGED
@@ -6,6 +6,7 @@
6
6
  import type { Toolkit } from "./toolkit.js"
7
7
  import type { ToolResult, Request, Answer } from "./types.js"
8
8
  import { pendingOf } from "./types.js"
9
+ import { isRetryableStatus, retryAfterMs } from "./retry.js"
9
10
  import type { ContentPart, PromptInput, UnsupportedPartMode } from "./content.js"
10
11
  import { toOpenAIWire, toAnthropicWire, checkPromptParts, type WireOptions } from "./wire.js"
11
12
  import type { TranslateRequest, TranslateResult, TranslatedToolCall } from "./translate.js"
@@ -30,10 +31,20 @@ export interface ClientOptions {
30
31
  systemPrompt?: string
31
32
  maxTurns?: number
32
33
  hooks?: Hooks
33
- /** Retries on transient LLM errors (429/5xx/network). Default 2. */
34
+ /** Retries on transient LLM errors (`429`/`500`/`502`/`503`/`504`/`529` + network). Default 2.
35
+ * Widen the status set with `retryableStatuses`. */
34
36
  retries?: number
35
37
  /** Base backoff in ms (exponential + jitter). Default 500. */
36
38
  retryBaseMs?: number
39
+ /**
40
+ * Extra HTTP statuses to treat as retryable, ADDED to the default set
41
+ * (`429`/`500`/`502`/`503`/`504`/`529`). It can only widen: a host cannot remove `429` and
42
+ * lose `Retry-After` handling with it. This sets the DEFAULT classification; `onError` still
43
+ * runs per attempt and has the final say, so `onError` returning `"fail"` overrides a status
44
+ * listed here. Example: a Cloudflare-fronted origin that answers `520`–`527`.
45
+ */
46
+ retryableStatuses?: readonly number[]
47
+
37
48
  /** Whole-run deadline in ms; aborts the run (and its in-flight request) when exceeded. */
38
49
  timeoutMs?: number
39
50
  /** Conversation provider for `ask(prompt, { id })`. Default: in-memory (process lifetime).
@@ -84,7 +95,8 @@ export interface ErrorInfo {
84
95
  status?: number
85
96
  /** Zero-based attempt index (0 = first try). */
86
97
  attempt: number
87
- /** Whether `status`/the error is in the default retryable set (429/5xx/network). */
98
+ /** Whether `status`/the error is in the default retryable set
99
+ * (429/500/502/503/504/529/network, plus anything `retryableStatuses` added). */
88
100
  retryable: boolean
89
101
  }
90
102
 
@@ -97,6 +109,20 @@ export type MetricEvent =
97
109
  | { event: "llm"; model: string; status: "ok" | "error"; ms: number; promptTokens: number; completionTokens: number }
98
110
  | { event: "tool"; tool: string; source: string; isError: boolean; ms: number; pending?: boolean }
99
111
  | { event: "run"; model: string; turns: number; toolCalls: number; totalTokens: number; ms: number; error?: string }
112
+ // §8B. The classifier emits into this SAME sink. Neither event is folded into the Prometheus
113
+ // registry, so `client.metrics()` text stays byte-identical to a build with no classifier.
114
+ | {
115
+ event: "classifier.evaluate"
116
+ model: string
117
+ status: "ok" | "error"
118
+ ms: number
119
+ promptTokens: number
120
+ completionTokens: number
121
+ error?: string
122
+ }
123
+ // `warning`, never `error`: a degenerate-criteria report is advisory, and a consumer that
124
+ // counts "has an error" as a failure must not count this.
125
+ | { event: "classifier.warning"; question: string; warning: string }
100
126
 
101
127
  /**
102
128
  * Where `ask()` conversations are remembered — two methods. Ship the in-memory
@@ -121,30 +147,6 @@ export class InMemoryConversationStore implements ConversationStore {
121
147
  }
122
148
  }
123
149
 
124
- const RETRYABLE = new Set([429, 500, 502, 503, 504])
125
-
126
- /** ~68 years; the widest whole-second count all seven ports represent exactly. */
127
- const RETRY_AFTER_MAX_SECONDS = 2147483647
128
-
129
- /**
130
- * Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
131
- * (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
132
- *
133
- * `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
134
- * and `" "` (as 0), which is how this port came to wait for fractional seconds
135
- * that five of the other six rejected outright. Fractional, signed, HTTP-date and
136
- * out-of-range values are not delays we can honour, so the caller falls back to
137
- * backoff rather than guessing. `0` is a real answer, so it returns `0`, not
138
- * `null`, and callers must use `??` rather than `||`.
139
- */
140
- function retryAfterMs(raw: string | null | undefined): number | null {
141
- if (raw == null) return null
142
- const s = raw.trim()
143
- if (!/^[0-9]+$/.test(s)) return null
144
- const secs = Number(s)
145
- return secs <= RETRY_AFTER_MAX_SECONDS ? secs * 1000 : null
146
- }
147
-
148
150
  function delay(ms: number, signal: AbortSignal): Promise<void> {
149
151
  return new Promise((resolve, reject) => {
150
152
  const t = setTimeout(resolve, ms)
@@ -665,7 +667,8 @@ export class Client {
665
667
  return ctrl.signal
666
668
  }
667
669
 
668
- /** fetch with retry + exponential backoff on 429/5xx/network, honoring Retry-After; aborts via signal.
670
+ /** fetch with retry + exponential backoff on the default retryable set (429/500/502/503/504/529)
671
+ * plus network errors and anything `retryableStatuses` added, honoring Retry-After; aborts via signal.
669
672
  * The retry-vs-fail decision is the host's `onError` (default: retryable-within-budget ⇒ retry). */
670
673
  private async llmFetch(url: string, init: RequestInit, signal: AbortSignal): Promise<Response> {
671
674
  const retries = this.opts.retries ?? 2
@@ -678,7 +681,7 @@ export class Client {
678
681
  try {
679
682
  const res = await (this.opts.fetch ?? fetch)(url, { ...init, signal })
680
683
  if (res.ok) return res
681
- const retryable = RETRYABLE.has(res.status)
684
+ const retryable = isRetryableStatus(res.status, this.opts.retryableStatuses)
682
685
  const tier = classify({ status: res.status, attempt, retryable })
683
686
  if (tier === "fail" || attempt === retries) return res // caller surfaces the non-ok status
684
687
  const ra = retryAfterMs(res.headers.get("retry-after"))
package/src/index.ts CHANGED
@@ -13,4 +13,5 @@ export * from "./adapters.js"
13
13
  export * from "./translate.js"
14
14
  export * from "./toolkit.js"
15
15
  export * from "./client.js"
16
+ export * from "./classifier.js"
16
17
  export * as agents from "./agents/index.js"
package/src/retry.ts ADDED
@@ -0,0 +1,57 @@
1
+ /**
2
+ * The ONE §8 retry policy — the retryable-status set and the `Retry-After` rule.
3
+ *
4
+ * INTERNAL to the package. This module is deliberately NOT re-exported from
5
+ * `index.ts`: `client.ts` and `classifier.ts` (§8B) both need the policy, and a
6
+ * second copy is exactly the drift this file exists to prevent — but sharing it
7
+ * is an implementation detail, not npm API. Nothing here is in the parity-checked
8
+ * option surface, and nothing here may be added to `index.ts`.
9
+ */
10
+
11
+ /** ~68 years; the widest whole-second count all seven ports represent exactly. */
12
+ const RETRY_AFTER_MAX_SECONDS = 2147483647
13
+
14
+ /**
15
+ * Honour `Retry-After` only in its delay-seconds form: a run of ASCII digits
16
+ * (RFC 9110 §10.2.3) in 0…2147483647, returned in milliseconds.
17
+ *
18
+ * `Number()` is far too permissive for this — it accepts `"0.5"`, `"-5"`, `"1e3"`
19
+ * and `" "` (as 0), which is how this port came to wait for fractional seconds
20
+ * that five of the other six rejected outright. Fractional, signed, HTTP-date and
21
+ * out-of-range values are not delays we can honour, so the caller falls back to
22
+ * backoff rather than guessing. `0` is a real answer, so it returns `0`, not
23
+ * `null`, and callers must use `??` rather than `||`.
24
+ */
25
+ export function retryAfterMs(raw: string | null | undefined): number | null {
26
+ if (raw == null) return null
27
+ const s = raw.trim()
28
+ if (!/^[0-9]+$/.test(s)) return null
29
+ const secs = Number(s)
30
+ return secs <= RETRY_AFTER_MAX_SECONDS ? secs * 1000 : null
31
+ }
32
+
33
+ /**
34
+ * The default retryable set: `429` plus the 5xx worth another try — and `529 Overloaded`,
35
+ * which TypeSafe documents as "retry with backoff" and which an enumeration made terminal.
36
+ *
37
+ * It is an ENUMERATION on purpose. "Any 5xx" would sweep in permanently-broken statuses
38
+ * (`501 Not Implemented`, `505 HTTP Version Not Supported`) and change the retry behaviour of
39
+ * every existing host without asking. A backend with its own transient status — a Cloudflare
40
+ * origin answering `520`–`527`, say — opts in declaratively through `retryableStatuses`, which
41
+ * ADDS to this set and cannot remove from it.
42
+ *
43
+ * Shared with §8B's classifier, which adds `408` to it rather than inventing a second policy.
44
+ */
45
+ const RETRYABLE = new Set([429, 500, 502, 503, 504, 529])
46
+
47
+ /**
48
+ * Whether a status is retryable by default, optionally widened by a host's `retryableStatuses`.
49
+ *
50
+ * `extra` is ADDITIVE: it can only make more statuses retryable, never fewer, so a host cannot
51
+ * accidentally drop `429` and lose `Retry-After` handling with it. It decides the DEFAULT
52
+ * classification only — `onError` still runs afterwards and has the final say on every attempt,
53
+ * so `onError` returning `"fail"` overrides a status the host itself listed here.
54
+ */
55
+ export function isRetryableStatus(status: number, extra?: readonly number[] | null): boolean {
56
+ return RETRYABLE.has(status) || (extra != null && extra.includes(status))
57
+ }