smoltalk 0.4.0 → 0.4.2

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/dist/smolError.js CHANGED
@@ -1,30 +1,155 @@
1
+ /**
2
+ * Base error type for all smoltalk failures. Carries a curated, safe-to-log
3
+ * snapshot of the HTTP response on `status` / `headers` / `retryAfterMs` /
4
+ * `requestId`.
5
+ *
6
+ * **Security note on `cause`:** the raw provider error is attached at `cause`
7
+ * as a non-enumerable property (using the ES2022 `Error` `{ cause }` option),
8
+ * so `JSON.stringify(err)` and most error serializers (Sentry, pino, Bunyan)
9
+ * will NOT walk it. The custom `toJSON()` below similarly omits both `cause`
10
+ * and `stack`. The provider error can still contain `authorization` headers,
11
+ * cookies, and echoed prompt bodies — direct access (`err.cause`) and Node's
12
+ * native `console.error` chain still work, so handle deliberately.
13
+ */
1
14
  export class SmolError extends Error {
2
- constructor(message) {
15
+ /** HTTP status code from the provider response, when available. */
16
+ status;
17
+ /**
18
+ * Curated, safe-to-log subset of the provider response headers. For the full,
19
+ * unredacted set (including any session cookies the provider sent), reach for
20
+ * the raw provider error on `cause` — but note that `cause` is non-enumerable
21
+ * specifically to keep it out of casual logging.
22
+ */
23
+ headers;
24
+ /**
25
+ * Suggested wait before retrying, in milliseconds, parsed from the
26
+ * `retry-after-ms` / `retry-after` response headers (when available).
27
+ * Always non-negative and capped at a sane ceiling (see `httpError.ts`).
28
+ */
29
+ retryAfterMs;
30
+ /** Provider request id (`x-request-id` / `request-id`), useful for support tickets. */
31
+ requestId;
32
+ constructor(message, options = {}) {
3
33
  super(message);
4
34
  this.name = "SmolTalkError";
35
+ this.status = options.status;
36
+ this.headers = options.headers;
37
+ this.retryAfterMs = options.retryAfterMs;
38
+ this.requestId = options.requestId;
39
+ if (options.cause !== undefined) {
40
+ // Explicit non-enumerable install. A plain `this.cause = options.cause`
41
+ // would create an *enumerable* own property — `JSON.stringify(err)` and
42
+ // structured loggers (`logger.error({ err })`) would then walk `cause`
43
+ // and re-leak the raw provider error (including the unredacted
44
+ // `set-cookie` / `authorization` headers the allowlist stripped from
45
+ // `headers`). This restores the behavior the spec intends for
46
+ // `new Error(msg, { cause })`: `cause` is the escape hatch, not the
47
+ // default-serialized field.
48
+ Object.defineProperty(this, "cause", {
49
+ value: options.cause,
50
+ enumerable: false,
51
+ writable: true,
52
+ configurable: true,
53
+ });
54
+ }
55
+ }
56
+ /**
57
+ * Custom JSON serializer. Returns only the curated/safe fields — never
58
+ * `cause` or `stack`, both of which can leak secrets (auth headers in
59
+ * `cause`, file system paths and source snippets in `stack`).
60
+ */
61
+ toJSON() {
62
+ return {
63
+ name: this.name,
64
+ message: this.message,
65
+ status: this.status,
66
+ headers: this.headers,
67
+ retryAfterMs: this.retryAfterMs,
68
+ requestId: this.requestId,
69
+ };
70
+ }
71
+ /**
72
+ * Custom Node.js inspect. Node's default error formatter walks `cause` and
73
+ * prints it even when the property is non-enumerable, which would re-leak
74
+ * the raw provider error through `console.error(err)` / `util.inspect(err)`.
75
+ * Override that path to show a placeholder; callers who want the full chain
76
+ * can still reach for `err.cause` explicitly.
77
+ */
78
+ [Symbol.for("nodejs.util.inspect.custom")]() {
79
+ const fields = [`${this.name}: ${this.message}`];
80
+ if (this.status !== undefined)
81
+ fields.push(`status: ${this.status}`);
82
+ if (this.requestId !== undefined)
83
+ fields.push(`requestId: ${JSON.stringify(this.requestId)}`);
84
+ if (this.retryAfterMs !== undefined)
85
+ fields.push(`retryAfterMs: ${this.retryAfterMs}`);
86
+ if (this.headers !== undefined)
87
+ fields.push(`headers: ${JSON.stringify(this.headers)}`);
88
+ if (this.cause !== undefined)
89
+ fields.push("cause: [hidden — access err.cause directly]");
90
+ return fields.join("\n ");
5
91
  }
6
92
  }
7
93
  export class SmolStructuredOutputError extends SmolError {
8
- constructor(message) {
9
- super(message);
94
+ constructor(message, options = {}) {
95
+ super(message, options);
10
96
  this.name = "SmolStructuredOutputError";
11
97
  }
12
98
  }
13
99
  export class SmolTimeoutError extends SmolError {
14
- constructor(message) {
15
- super(message);
100
+ constructor(message, options = {}) {
101
+ super(message, options);
16
102
  this.name = "SmolTimeoutError";
17
103
  }
18
104
  }
19
105
  export class SmolContentPolicyError extends SmolError {
20
- constructor(message) {
21
- super(message);
106
+ constructor(message, options = {}) {
107
+ super(message, options);
22
108
  this.name = "SmolContentPolicyError";
23
109
  }
24
110
  }
25
111
  export class SmolContextWindowExceededError extends SmolError {
26
- constructor(message) {
27
- super(message);
112
+ constructor(message, options = {}) {
113
+ super(message, options);
28
114
  this.name = "SmolContextWindowExceededError";
29
115
  }
30
116
  }
117
+ /** Rate limited (HTTP 429). Inspect `retryAfterMs` to back off. */
118
+ export class SmolRateLimitError extends SmolError {
119
+ constructor(message, options = {}) {
120
+ super(message, options);
121
+ this.name = "SmolRateLimitError";
122
+ }
123
+ }
124
+ /** Provider temporarily overloaded/unavailable (HTTP 503, or Anthropic's 529). */
125
+ export class SmolOverloadedError extends SmolError {
126
+ constructor(message, options = {}) {
127
+ super(message, options);
128
+ this.name = "SmolOverloadedError";
129
+ }
130
+ }
131
+ /** Authentication or permission failure (HTTP 401/403). */
132
+ export class SmolAuthError extends SmolError {
133
+ constructor(message, options = {}) {
134
+ super(message, options);
135
+ this.name = "SmolAuthError";
136
+ }
137
+ }
138
+ /**
139
+ * Pick the most specific SmolError subclass for an HTTP status code, so retry
140
+ * code can `instanceof`-dispatch instead of sniffing magic status numbers.
141
+ * Falls back to the base `SmolError` for unclassified statuses.
142
+ */
143
+ export function smolErrorForStatus(message, options = {}) {
144
+ const status = options.status;
145
+ if (status === 429) {
146
+ return new SmolRateLimitError(message, options);
147
+ }
148
+ if (status === 503 || status === 529) {
149
+ return new SmolOverloadedError(message, options);
150
+ }
151
+ if (status === 401 || status === 403) {
152
+ return new SmolAuthError(message, options);
153
+ }
154
+ return new SmolError(message, options);
155
+ }
@@ -0,0 +1,23 @@
1
+ /**
2
+ * Best-effort extraction of HTTP status and headers from a provider SDK error.
3
+ *
4
+ * Each provider SDK shapes its errors slightly differently:
5
+ * - OpenAI / Anthropic: `status` (number) + `headers` (web `Headers`)
6
+ * - Google Gemini (`@google/genai`): `status` (number), no headers
7
+ * - Ollama: `status_code` (number), no headers
8
+ */
9
+ export interface HttpErrorFields {
10
+ status?: number;
11
+ /** Allowlisted, safe-to-log subset of response headers (see `ALLOWED_HEADERS`). */
12
+ headers?: Record<string, string>;
13
+ /**
14
+ * Suggested wait before retrying, in milliseconds, parsed from the
15
+ * `retry-after-ms` / `retry-after` response headers. Only ever populated for
16
+ * OpenAI/Anthropic — Google and Ollama errors carry no headers to parse.
17
+ * Always non-negative and capped at `MAX_RETRY_AFTER_MS`.
18
+ */
19
+ retryAfterMs?: number;
20
+ /** Provider request id (`x-request-id` / `request-id`), useful for support tickets. */
21
+ requestId?: string;
22
+ }
23
+ export declare function extractHttpErrorFields(error: unknown): HttpErrorFields;
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Explicit allowlist (not denylist, not prefix-wildcard). smoltalk can be
3
+ * pointed at proxies (LiteLLM, OpenRouter) that re-emit upstream headers
4
+ * verbatim, and some APIs echo credentials back on errors — a denylist would
5
+ * pass `authorization`, `x-api-key`, forwarded `llm_provider-*` headers, etc.
6
+ * (cf. Traefik GHSA-p6hg-qh38-555r). A prefix wildcard like `x-ratelimit-*`
7
+ * is safer than a denylist but still risks absorbing a future
8
+ * `x-ratelimit-organization` style header that carries an identifier or
9
+ * secret. Capture only known-safe headers with diagnostic value; the raw
10
+ * provider error is reachable on `SmolError.cause` (non-enumerable) as the
11
+ * escape hatch for callers that explicitly opt in.
12
+ */
13
+ const ALLOWED_HEADERS = new Set([
14
+ // Retry hints
15
+ "retry-after",
16
+ "retry-after-ms",
17
+ // Request correlation
18
+ "request-id",
19
+ "x-request-id",
20
+ // Diagnostic
21
+ "content-type",
22
+ // OpenAI rate-limit family (exhaustive as of writing — new headers added
23
+ // by providers must be added explicitly here).
24
+ "x-ratelimit-limit-requests",
25
+ "x-ratelimit-limit-tokens",
26
+ "x-ratelimit-remaining-requests",
27
+ "x-ratelimit-remaining-tokens",
28
+ "x-ratelimit-reset-requests",
29
+ "x-ratelimit-reset-tokens",
30
+ // Anthropic rate-limit family
31
+ "anthropic-ratelimit-requests-limit",
32
+ "anthropic-ratelimit-requests-remaining",
33
+ "anthropic-ratelimit-requests-reset",
34
+ "anthropic-ratelimit-tokens-limit",
35
+ "anthropic-ratelimit-tokens-remaining",
36
+ "anthropic-ratelimit-tokens-reset",
37
+ "anthropic-ratelimit-input-tokens-limit",
38
+ "anthropic-ratelimit-input-tokens-remaining",
39
+ "anthropic-ratelimit-input-tokens-reset",
40
+ "anthropic-ratelimit-output-tokens-limit",
41
+ "anthropic-ratelimit-output-tokens-remaining",
42
+ "anthropic-ratelimit-output-tokens-reset",
43
+ ]);
44
+ function isAllowedHeader(name) {
45
+ return ALLOWED_HEADERS.has(name.toLowerCase());
46
+ }
47
+ /**
48
+ * Upper bound on `retryAfterMs`. Anything longer is almost certainly a
49
+ * misparsed HTTP-date or a hostile/buggy proxy injecting a far-future value
50
+ * (e.g. `Sat, 01 Jan 9999 …`). 5 minutes is well beyond any sane provider
51
+ * rate-limit window — callers that wanted to wait longer would have given
52
+ * up by then anyway.
53
+ */
54
+ const MAX_RETRY_AFTER_MS = 5 * 60 * 1000;
55
+ function clampRetryAfter(ms) {
56
+ return Math.min(MAX_RETRY_AFTER_MS, Math.max(0, Math.round(ms)));
57
+ }
58
+ /**
59
+ * Read a property without letting a throwing accessor break the whole
60
+ * extraction. Some error shapes (especially JSON-parsed bodies that bubble
61
+ * up as errors) can carry getters that throw or have side effects.
62
+ */
63
+ function safeGet(obj, key) {
64
+ try {
65
+ return obj[key];
66
+ }
67
+ catch {
68
+ return undefined;
69
+ }
70
+ }
71
+ export function extractHttpErrorFields(error) {
72
+ const fields = {};
73
+ if (!error || typeof error !== "object") {
74
+ return fields;
75
+ }
76
+ const err = error;
77
+ const status = safeGet(err, "status") ??
78
+ safeGet(err, "statusCode") ??
79
+ safeGet(err, "status_code");
80
+ if (typeof status === "number") {
81
+ fields.status = status;
82
+ }
83
+ const headers = normalizeHeaders(safeGet(err, "headers"));
84
+ if (headers) {
85
+ fields.headers = headers;
86
+ const retryAfterMs = parseRetryAfterMs(headers);
87
+ if (retryAfterMs !== undefined) {
88
+ fields.retryAfterMs = retryAfterMs;
89
+ }
90
+ }
91
+ const requestId = extractRequestId(err, headers);
92
+ if (requestId !== undefined) {
93
+ fields.requestId = requestId;
94
+ }
95
+ return fields;
96
+ }
97
+ function extractRequestId(err, headers) {
98
+ // The OpenAI/Anthropic SDK errors carry a parsed request id directly.
99
+ // NOTE: providers may embed account/org identifiers in request ids (e.g.
100
+ // OpenAI's `req_<orghash>_<reqhash>`). These are loggable but not entirely
101
+ // opaque — keep that in mind when forwarding to third-party telemetry.
102
+ const requestID = safeGet(err, "requestID");
103
+ if (typeof requestID === "string") {
104
+ return requestID;
105
+ }
106
+ const request_id = safeGet(err, "request_id");
107
+ if (typeof request_id === "string") {
108
+ return request_id;
109
+ }
110
+ if (headers) {
111
+ return (headerValue(headers, "x-request-id") ?? headerValue(headers, "request-id"));
112
+ }
113
+ return undefined;
114
+ }
115
+ function normalizeHeaders(raw) {
116
+ if (!raw || typeof raw !== "object") {
117
+ return undefined;
118
+ }
119
+ const out = {};
120
+ // Web `Headers` (used by the OpenAI/Anthropic SDKs) is iterable via forEach.
121
+ // We always lowercase the stored key so downstream `headers["x-request-id"]`
122
+ // lookups work regardless of the provider's original casing.
123
+ try {
124
+ if (typeof raw.forEach === "function") {
125
+ raw.forEach((value, key) => {
126
+ if (isAllowedHeader(key)) {
127
+ out[key.toLowerCase()] = value;
128
+ }
129
+ });
130
+ }
131
+ else {
132
+ // Fall back to a plain object of header key/value pairs.
133
+ for (const [key, value] of Object.entries(raw)) {
134
+ if (typeof value === "string" && isAllowedHeader(key)) {
135
+ out[key.toLowerCase()] = value;
136
+ }
137
+ }
138
+ }
139
+ }
140
+ catch {
141
+ // A malformed Headers-shaped object with a throwing iterator — give up
142
+ // on header capture rather than poisoning the entire extraction.
143
+ return undefined;
144
+ }
145
+ return Object.keys(out).length > 0 ? out : undefined;
146
+ }
147
+ /**
148
+ * Parse a retry delay (in milliseconds) from the response headers, mirroring
149
+ * the logic the OpenAI and Anthropic SDKs use:
150
+ * 1. `retry-after-ms` (non-standard, ms precision) takes precedence.
151
+ * 2. Otherwise `retry-after` (RFC 9110): either delta-seconds or an HTTP-date.
152
+ *
153
+ * Always returns a non-negative value capped at `MAX_RETRY_AFTER_MS`.
154
+ */
155
+ function parseRetryAfterMs(headers) {
156
+ // `retry-after-ms` (non-standard, OpenAI-specific) is always purely numeric;
157
+ // try whole-value first, then first-of-list, then give up.
158
+ const msRaw = headerValue(headers, "retry-after-ms");
159
+ const ms = numericHeader(msRaw) ?? numericHeader(firstNumericToken(msRaw));
160
+ if (ms !== undefined) {
161
+ return clampRetryAfter(ms);
162
+ }
163
+ const retryAfter = headerValue(headers, "retry-after");
164
+ if (retryAfter !== undefined) {
165
+ // 1. Try the whole value as a delta-seconds number ("30", "-3600").
166
+ const seconds = numericHeader(retryAfter);
167
+ if (seconds !== undefined) {
168
+ return clampRetryAfter(seconds * 1000);
169
+ }
170
+ // 2. Comma-separated numeric list? Take the first token if numeric.
171
+ // NOTE: HTTP-dates contain commas ("Sat, 01 Jan ..."), so we only
172
+ // take the first token when it parses as a pure number — otherwise
173
+ // we'd mangle a perfectly good HTTP-date into "Sat".
174
+ const firstNumeric = numericHeader(firstNumericToken(retryAfter));
175
+ if (firstNumeric !== undefined) {
176
+ return clampRetryAfter(firstNumeric * 1000);
177
+ }
178
+ // 3. Otherwise treat as RFC 9110 HTTP-date.
179
+ const dateMs = Date.parse(retryAfter) - Date.now();
180
+ if (!Number.isNaN(dateMs)) {
181
+ return clampRetryAfter(dateMs);
182
+ }
183
+ }
184
+ return undefined;
185
+ }
186
+ /**
187
+ * Returns the first comma-separated token of `value` only if it parses as a
188
+ * number. Returns undefined for non-numeric first tokens (notably HTTP-dates
189
+ * like `"Sat, 01 Jan 2026 ..."`) so the caller can fall back to date parsing.
190
+ */
191
+ function firstNumericToken(value) {
192
+ if (value === undefined) {
193
+ return undefined;
194
+ }
195
+ const first = value.split(",")[0];
196
+ if (first === undefined) {
197
+ return undefined;
198
+ }
199
+ const trimmed = first.trim();
200
+ if (trimmed === "" || Number.isNaN(Number(trimmed))) {
201
+ return undefined;
202
+ }
203
+ return trimmed;
204
+ }
205
+ /**
206
+ * Strict numeric parse — unlike `parseFloat`, this rejects values with trailing
207
+ * junk (`"5xyz"`, `"30abc"`) instead of silently accepting a prefix.
208
+ */
209
+ function numericHeader(value) {
210
+ if (value === undefined) {
211
+ return undefined;
212
+ }
213
+ const trimmed = value.trim();
214
+ if (trimmed === "") {
215
+ return undefined;
216
+ }
217
+ const parsed = Number(trimmed);
218
+ if (Number.isNaN(parsed)) {
219
+ return undefined;
220
+ }
221
+ return parsed;
222
+ }
223
+ function headerValue(headers, name) {
224
+ // Keys are stored lowercased by `normalizeHeaders`, so a direct lookup is
225
+ // sufficient. Keep a case-insensitive fallback for defense in depth in case
226
+ // a caller hands us a non-normalized object.
227
+ const lower = name.toLowerCase();
228
+ if (lower in headers) {
229
+ return headers[lower];
230
+ }
231
+ for (const [key, value] of Object.entries(headers)) {
232
+ if (key.toLowerCase() === lower) {
233
+ return value;
234
+ }
235
+ }
236
+ return undefined;
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "smoltalk",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "A common interface for LLM APIs",
5
5
  "homepage": "https://github.com/egonSchiele/smoltalk",
6
6
  "files": [