smoltalk 0.4.1 → 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.
@@ -47,9 +47,10 @@ export class ToolCall {
47
47
  static fromJSON(json) {
48
48
  const result = ToolCallJSONSchema.safeParse(json);
49
49
  if (!result.success) {
50
- console.error("Failed to parse ToolCall");
51
- console.error(JSON.stringify(json, null, 2));
52
- console.error(z.prettifyError(result.error));
50
+ const logger = getLogger();
51
+ logger.error("Failed to parse ToolCall:", z.prettifyError(result.error));
52
+ // Raw payload can contain tool-call arguments (often secrets) — only at debug level.
53
+ logger.debug("ToolCall payload that failed to parse:", JSON.stringify(json, null, 2));
53
54
  throw new Error("Failed to parse ToolCall");
54
55
  }
55
56
  return new ToolCall(result.data.id, result.data.name, result.data.arguments);
@@ -2,6 +2,7 @@ import { z } from "zod";
2
2
  import { BaseMessage } from "./BaseMessage.js";
3
3
  import { CostEstimateSchema, TextPartSchema, ThinkingBlockSchema, TokenUsageSchema, } from "../../types.js";
4
4
  import { ToolCall, ToolCallJSONSchema } from "../ToolCall.js";
5
+ import { getLogger } from "../../util/logger.js";
5
6
  export const AssistantMessageJSONSchema = z.object({
6
7
  role: z.literal("assistant"),
7
8
  content: z.union([z.string(), z.array(TextPartSchema), z.null()]),
@@ -86,9 +87,10 @@ export class AssistantMessage extends BaseMessage {
86
87
  static fromJSON(json) {
87
88
  const result = AssistantMessageJSONSchema.safeParse(json);
88
89
  if (!result.success) {
89
- console.error("Failed to parse AssistantMessage");
90
- console.error(JSON.stringify(json, null, 2));
91
- console.error(z.prettifyError(result.error));
90
+ const logger = getLogger();
91
+ logger.error("Failed to parse AssistantMessage:", z.prettifyError(result.error));
92
+ // Raw payload can contain assistant content / tool args — only at debug level.
93
+ logger.debug("AssistantMessage payload that failed to parse:", JSON.stringify(json, null, 2));
92
94
  throw new Error("Failed to parse AssistantMessage");
93
95
  }
94
96
  return new AssistantMessage(result.data.content, {
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { BaseMessage } from "./BaseMessage.js";
3
3
  import { TextPartSchema } from "../../types.js";
4
+ import { getLogger } from "../../util/logger.js";
4
5
  export const DeveloperMessageJSONSchema = z.object({
5
6
  role: z.literal("developer"),
6
7
  content: z.union([z.string(), z.array(TextPartSchema)]),
@@ -43,9 +44,10 @@ export class DeveloperMessage extends BaseMessage {
43
44
  static fromJSON(json) {
44
45
  const result = DeveloperMessageJSONSchema.safeParse(json);
45
46
  if (!result.success) {
46
- console.error("Failed to parse DeveloperMessage");
47
- console.error(JSON.stringify(json, null, 2));
48
- console.error(z.prettifyError(result.error));
47
+ const logger = getLogger();
48
+ logger.error("Failed to parse DeveloperMessage:", z.prettifyError(result.error));
49
+ // Raw payload can contain developer-message content — only at debug level.
50
+ logger.debug("DeveloperMessage payload that failed to parse:", JSON.stringify(json, null, 2));
49
51
  throw new Error("Failed to parse DeveloperMessage");
50
52
  }
51
53
  return new DeveloperMessage(result.data.content, {
@@ -1,6 +1,7 @@
1
1
  import { z } from "zod";
2
2
  import { BaseMessage } from "./BaseMessage.js";
3
3
  import { TextPartSchema } from "../../types.js";
4
+ import { getLogger } from "../../util/logger.js";
4
5
  export const SystemMessageJSONSchema = z.object({
5
6
  role: z.literal("system"),
6
7
  content: z.union([z.string(), z.array(TextPartSchema)]),
@@ -43,9 +44,10 @@ export class SystemMessage extends BaseMessage {
43
44
  static fromJSON(json) {
44
45
  const result = SystemMessageJSONSchema.safeParse(json);
45
46
  if (!result.success) {
46
- console.error("Failed to parse SystemMessage");
47
- console.error(JSON.stringify(json, null, 2));
48
- console.error(z.prettifyError(result.error));
47
+ const logger = getLogger();
48
+ logger.error("Failed to parse SystemMessage:", z.prettifyError(result.error));
49
+ // Raw payload can contain system prompt content — only at debug level.
50
+ logger.debug("SystemMessage payload that failed to parse:", JSON.stringify(json, null, 2));
49
51
  throw new Error("Failed to parse SystemMessage");
50
52
  }
51
53
  return new SystemMessage(result.data.content, {
@@ -52,9 +52,10 @@ export class ToolMessage extends BaseMessage {
52
52
  static fromJSON(json) {
53
53
  const result = ToolMessageJSONSchema.safeParse(json);
54
54
  if (!result.success) {
55
- console.error("Failed to parse ToolMessage");
56
- console.error(JSON.stringify(json, null, 2));
57
- console.error(z.prettifyError(result.error));
55
+ const logger = getLogger();
56
+ logger.error("Failed to parse ToolMessage:", z.prettifyError(result.error));
57
+ // Raw payload can contain tool results (file paths, secrets) — only at debug level.
58
+ logger.debug("ToolMessage payload that failed to parse:", JSON.stringify(json, null, 2));
58
59
  throw new Error("Failed to parse ToolMessage");
59
60
  }
60
61
  const TextPartArraySchema = z.array(TextPartSchema);
@@ -1,5 +1,6 @@
1
1
  import { z } from "zod";
2
2
  import { BaseMessage } from "./BaseMessage.js";
3
+ import { getLogger } from "../../util/logger.js";
3
4
  export const UserMessageJSONSchema = z.object({
4
5
  role: z.literal("user"),
5
6
  content: z.string(),
@@ -42,9 +43,10 @@ export class UserMessage extends BaseMessage {
42
43
  static fromJSON(json) {
43
44
  const result = UserMessageJSONSchema.safeParse(json);
44
45
  if (!result.success) {
45
- console.error("Failed to parse UserMessage");
46
- console.error(JSON.stringify(json, null, 2));
47
- console.error(z.prettifyError(result.error));
46
+ const logger = getLogger();
47
+ logger.error("Failed to parse UserMessage:", z.prettifyError(result.error));
48
+ // Raw payload can contain user prompts / PII — only at debug level.
49
+ logger.debug("UserMessage payload that failed to parse:", JSON.stringify(json, null, 2));
48
50
  throw new Error("Failed to parse UserMessage");
49
51
  }
50
52
  return new UserMessage(result.data.content, {
@@ -4,7 +4,8 @@ import { SystemMessage, DeveloperMessage } from "../classes/message/index.js";
4
4
  import { getLogger } from "../util/logger.js";
5
5
  import { success, } from "../types.js";
6
6
  import { zodToAnthropicTool } from "../util/tool.js";
7
- import { SmolContentPolicyError, SmolContextWindowExceededError, } from "../smolError.js";
7
+ import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
8
+ import { extractHttpErrorFields } from "../util/httpError.js";
8
9
  import { BaseClient } from "./baseClient.js";
9
10
  import { getModel, isTextModel } from "../models.js";
10
11
  import { Model } from "../model.js";
@@ -212,19 +213,21 @@ export class SmolAnthropic extends BaseClient {
212
213
  }
213
214
  rethrowAsSmolError(error) {
214
215
  if (error instanceof Anthropic.APIError) {
216
+ const http = { ...extractHttpErrorFields(error), cause: error };
215
217
  const msg = error.message.toLowerCase();
216
218
  if (msg.includes("prompt is too long") ||
217
219
  msg.includes("context length") ||
218
220
  msg.includes("context window") ||
219
221
  msg.includes("too many tokens")) {
220
- throw new SmolContextWindowExceededError(error.message);
222
+ throw new SmolContextWindowExceededError(error.message, http);
221
223
  }
222
224
  if (msg.includes("content policy") ||
223
225
  msg.includes("usage policies") ||
224
226
  msg.includes("content filtering") ||
225
227
  msg.includes("violates our")) {
226
- throw new SmolContentPolicyError(error.message);
228
+ throw new SmolContentPolicyError(error.message, http);
227
229
  }
230
+ throw smolErrorForStatus(error.message, http);
228
231
  }
229
232
  throw error;
230
233
  }
@@ -17,6 +17,7 @@ export declare class SmolGoogle extends BaseClient implements SmolClient {
17
17
  getModel(): ModelName;
18
18
  private calculateUsageAndCost;
19
19
  private buildRequest;
20
+ private rethrowAsSmolError;
20
21
  _textSync(config: SmolConfig): Promise<Result<PromptResult>>;
21
22
  __textSync(request: GeneratedRequest): Promise<Result<PromptResult>>;
22
23
  _textStream(config: SmolConfig): AsyncGenerator<StreamChunk>;
@@ -3,7 +3,8 @@ import { ToolCall } from "../classes/ToolCall.js";
3
3
  import { getLogger } from "../util/logger.js";
4
4
  import { addCosts, addTokenUsage, success, } from "../types.js";
5
5
  import { zodToGoogleTool } from "../util/tool.js";
6
- import { SmolContentPolicyError, SmolContextWindowExceededError, } from "../smolError.js";
6
+ import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
7
+ import { extractHttpErrorFields } from "../util/httpError.js";
7
8
  import { sanitizeAttributes } from "../util/util.js";
8
9
  import { BaseClient } from "./baseClient.js";
9
10
  import { Model } from "../model.js";
@@ -88,6 +89,18 @@ export class SmolGoogle extends BaseClient {
88
89
  ...sanitizeAttributes(config.rawAttributes),
89
90
  };
90
91
  }
92
+ rethrowAsSmolError(error) {
93
+ const http = { ...extractHttpErrorFields(error), cause: error };
94
+ const msg = (error.message || "").toLowerCase();
95
+ if (msg.includes("token") &&
96
+ (msg.includes("exceed") || msg.includes("too long") || msg.includes("limit"))) {
97
+ throw new SmolContextWindowExceededError(error.message, http);
98
+ }
99
+ if (http.status !== undefined) {
100
+ throw smolErrorForStatus(error.message, http);
101
+ }
102
+ throw error;
103
+ }
91
104
  async _textSync(config) {
92
105
  const signal = this.getAbortSignal(config);
93
106
  const request = {
@@ -180,14 +193,7 @@ export class SmolGoogle extends BaseClient {
180
193
  result = await this.client.models.generateContent(request);
181
194
  }
182
195
  catch (error) {
183
- const msg = (error.message || "").toLowerCase();
184
- if (msg.includes("token") &&
185
- (msg.includes("exceed") ||
186
- msg.includes("too long") ||
187
- msg.includes("limit"))) {
188
- throw new SmolContextWindowExceededError(error.message);
189
- }
190
- throw error;
196
+ this.rethrowAsSmolError(error);
191
197
  }
192
198
  this.logger.debug("Response from Google Gemini:", JSON.stringify(result, null, 2));
193
199
  this.statelogClient?.promptResponse(result);
@@ -257,14 +263,7 @@ export class SmolGoogle extends BaseClient {
257
263
  stream = await this.client.models.generateContentStream(request);
258
264
  }
259
265
  catch (error) {
260
- const msg = (error.message || "").toLowerCase();
261
- if (msg.includes("token") &&
262
- (msg.includes("exceed") ||
263
- msg.includes("too long") ||
264
- msg.includes("limit"))) {
265
- throw new SmolContextWindowExceededError(error.message);
266
- }
267
- throw error;
266
+ this.rethrowAsSmolError(error);
268
267
  }
269
268
  let content = "";
270
269
  const toolCallsMap = new Map();
@@ -12,6 +12,7 @@ export declare class SmolOllama extends BaseClient implements SmolClient {
12
12
  getClient(): Ollama;
13
13
  getModel(): ModelName;
14
14
  private calculateUsageAndCost;
15
+ private rethrowAsSmolError;
15
16
  _textSync(config: SmolConfig): Promise<Result<PromptResult>>;
16
17
  _textStream(config: SmolConfig): AsyncGenerator<StreamChunk>;
17
18
  }
@@ -5,7 +5,8 @@ import { success, } from "../types.js";
5
5
  import { zodToGoogleTool } from "../util/tool.js";
6
6
  import { sanitizeAttributes } from "../util/util.js";
7
7
  import { BaseClient } from "./baseClient.js";
8
- import { SmolContextWindowExceededError } from "../smolError.js";
8
+ import { SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
9
+ import { extractHttpErrorFields } from "../util/httpError.js";
9
10
  import { Model } from "../model.js";
10
11
  export const DEFAULT_OLLAMA_HOST = "http://localhost:11434";
11
12
  export class SmolOllama extends BaseClient {
@@ -51,6 +52,17 @@ export class SmolOllama extends BaseClient {
51
52
  }
52
53
  return { usage, cost };
53
54
  }
55
+ rethrowAsSmolError(error) {
56
+ const http = { ...extractHttpErrorFields(error), cause: error };
57
+ const msg = (error.message || "").toLowerCase();
58
+ if (msg.includes("context length") || msg.includes("context window")) {
59
+ throw new SmolContextWindowExceededError(error.message, http);
60
+ }
61
+ if (http.status !== undefined) {
62
+ throw smolErrorForStatus(error.message, http);
63
+ }
64
+ throw error;
65
+ }
54
66
  async _textSync(config) {
55
67
  const messages = config.messages.map((msg) => msg.toOpenAIMessage());
56
68
  const tools = (config.tools || []).map((tool) => {
@@ -82,11 +94,7 @@ export class SmolOllama extends BaseClient {
82
94
  result = await this.client.chat(request);
83
95
  }
84
96
  catch (error) {
85
- const msg = (error.message || "").toLowerCase();
86
- if (msg.includes("context length") || msg.includes("context window")) {
87
- throw new SmolContextWindowExceededError(error.message);
88
- }
89
- throw error;
97
+ this.rethrowAsSmolError(error);
90
98
  }
91
99
  finally {
92
100
  if (signal && abortHandler) {
@@ -201,6 +209,9 @@ export class SmolOllama extends BaseClient {
201
209
  },
202
210
  };
203
211
  }
212
+ catch (error) {
213
+ this.rethrowAsSmolError(error);
214
+ }
204
215
  finally {
205
216
  if (signal && abortHandler) {
206
217
  signal.removeEventListener("abort", abortHandler);
@@ -4,7 +4,8 @@ import { ToolCall } from "../classes/ToolCall.js";
4
4
  import { isFunctionToolCall, sanitizeAttributes } from "../util/util.js";
5
5
  import { getLogger } from "../util/logger.js";
6
6
  import { BaseClient } from "./baseClient.js";
7
- import { SmolContentPolicyError, SmolContextWindowExceededError, } from "../smolError.js";
7
+ import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
8
+ import { extractHttpErrorFields } from "../util/httpError.js";
8
9
  import { zodToOpenAITool } from "../util/tool.js";
9
10
  import { Model } from "../model.js";
10
11
  export class SmolOpenAi extends BaseClient {
@@ -74,12 +75,14 @@ export class SmolOpenAi extends BaseClient {
74
75
  }
75
76
  rethrowAsSmolError(error) {
76
77
  if (error instanceof OpenAI.APIError) {
78
+ const http = { ...extractHttpErrorFields(error), cause: error };
77
79
  if (error.code === "context_length_exceeded") {
78
- throw new SmolContextWindowExceededError(error.message);
80
+ throw new SmolContextWindowExceededError(error.message, http);
79
81
  }
80
82
  if (error.code === "content_policy_violation") {
81
- throw new SmolContentPolicyError(error.message);
83
+ throw new SmolContentPolicyError(error.message, http);
82
84
  }
85
+ throw smolErrorForStatus(error.message, http);
83
86
  }
84
87
  throw error;
85
88
  }
@@ -6,7 +6,8 @@ import { BaseClient } from "./baseClient.js";
6
6
  import { zodToOpenAIResponsesTool } from "../util/tool.js";
7
7
  import { sanitizeAttributes } from "../util/util.js";
8
8
  import { Model } from "../model.js";
9
- import { SmolContentPolicyError, SmolContextWindowExceededError, } from "../smolError.js";
9
+ import { SmolContentPolicyError, SmolContextWindowExceededError, smolErrorForStatus, } from "../smolError.js";
10
+ import { extractHttpErrorFields } from "../util/httpError.js";
10
11
  export class SmolOpenAiResponses extends BaseClient {
11
12
  client;
12
13
  logger;
@@ -107,12 +108,14 @@ export class SmolOpenAiResponses extends BaseClient {
107
108
  }
108
109
  rethrowAsSmolError(error) {
109
110
  if (error instanceof OpenAI.APIError) {
111
+ const http = { ...extractHttpErrorFields(error), cause: error };
110
112
  if (error.code === "context_length_exceeded") {
111
- throw new SmolContextWindowExceededError(error.message);
113
+ throw new SmolContextWindowExceededError(error.message, http);
112
114
  }
113
115
  if (error.code === "content_policy_violation") {
114
- throw new SmolContentPolicyError(error.message);
116
+ throw new SmolContentPolicyError(error.message, http);
115
117
  }
118
+ throw smolErrorForStatus(error.message, http);
116
119
  }
117
120
  throw error;
118
121
  }
@@ -1,15 +1,88 @@
1
+ export interface SmolErrorOptions {
2
+ /** HTTP status code from the provider response, when the error came from an HTTP call. */
3
+ status?: number;
4
+ /**
5
+ * Curated, safe-to-log subset of the provider response headers (see
6
+ * `extractHttpErrorFields` for the allowlist). The full, unredacted headers
7
+ * live on the raw provider error at `cause`.
8
+ */
9
+ headers?: Record<string, string>;
10
+ /**
11
+ * Suggested wait before retrying, in milliseconds, parsed from the
12
+ * `retry-after-ms` / `retry-after` response headers (when available).
13
+ */
14
+ retryAfterMs?: number;
15
+ /** Provider request id (`x-request-id` / `request-id`), useful for support tickets. */
16
+ requestId?: string;
17
+ /** The underlying provider error this was derived from, if any. */
18
+ cause?: unknown;
19
+ }
20
+ /**
21
+ * Base error type for all smoltalk failures. Carries a curated, safe-to-log
22
+ * snapshot of the HTTP response on `status` / `headers` / `retryAfterMs` /
23
+ * `requestId`.
24
+ *
25
+ * **Security note on `cause`:** the raw provider error is attached at `cause`
26
+ * as a non-enumerable property (using the ES2022 `Error` `{ cause }` option),
27
+ * so `JSON.stringify(err)` and most error serializers (Sentry, pino, Bunyan)
28
+ * will NOT walk it. The custom `toJSON()` below similarly omits both `cause`
29
+ * and `stack`. The provider error can still contain `authorization` headers,
30
+ * cookies, and echoed prompt bodies — direct access (`err.cause`) and Node's
31
+ * native `console.error` chain still work, so handle deliberately.
32
+ */
1
33
  export declare class SmolError extends Error {
2
- constructor(message: string);
34
+ /** HTTP status code from the provider response, when available. */
35
+ readonly status?: number;
36
+ /**
37
+ * Curated, safe-to-log subset of the provider response headers. For the full,
38
+ * unredacted set (including any session cookies the provider sent), reach for
39
+ * the raw provider error on `cause` — but note that `cause` is non-enumerable
40
+ * specifically to keep it out of casual logging.
41
+ */
42
+ readonly headers?: Record<string, string>;
43
+ /**
44
+ * Suggested wait before retrying, in milliseconds, parsed from the
45
+ * `retry-after-ms` / `retry-after` response headers (when available).
46
+ * Always non-negative and capped at a sane ceiling (see `httpError.ts`).
47
+ */
48
+ readonly retryAfterMs?: number;
49
+ /** Provider request id (`x-request-id` / `request-id`), useful for support tickets. */
50
+ readonly requestId?: string;
51
+ constructor(message: string, options?: SmolErrorOptions);
52
+ /**
53
+ * Custom JSON serializer. Returns only the curated/safe fields — never
54
+ * `cause` or `stack`, both of which can leak secrets (auth headers in
55
+ * `cause`, file system paths and source snippets in `stack`).
56
+ */
57
+ toJSON(): Record<string, unknown>;
3
58
  }
4
59
  export declare class SmolStructuredOutputError extends SmolError {
5
- constructor(message: string);
60
+ constructor(message: string, options?: SmolErrorOptions);
6
61
  }
7
62
  export declare class SmolTimeoutError extends SmolError {
8
- constructor(message: string);
63
+ constructor(message: string, options?: SmolErrorOptions);
9
64
  }
10
65
  export declare class SmolContentPolicyError extends SmolError {
11
- constructor(message: string);
66
+ constructor(message: string, options?: SmolErrorOptions);
12
67
  }
13
68
  export declare class SmolContextWindowExceededError extends SmolError {
14
- constructor(message: string);
69
+ constructor(message: string, options?: SmolErrorOptions);
70
+ }
71
+ /** Rate limited (HTTP 429). Inspect `retryAfterMs` to back off. */
72
+ export declare class SmolRateLimitError extends SmolError {
73
+ constructor(message: string, options?: SmolErrorOptions);
74
+ }
75
+ /** Provider temporarily overloaded/unavailable (HTTP 503, or Anthropic's 529). */
76
+ export declare class SmolOverloadedError extends SmolError {
77
+ constructor(message: string, options?: SmolErrorOptions);
78
+ }
79
+ /** Authentication or permission failure (HTTP 401/403). */
80
+ export declare class SmolAuthError extends SmolError {
81
+ constructor(message: string, options?: SmolErrorOptions);
15
82
  }
83
+ /**
84
+ * Pick the most specific SmolError subclass for an HTTP status code, so retry
85
+ * code can `instanceof`-dispatch instead of sniffing magic status numbers.
86
+ * Falls back to the base `SmolError` for unclassified statuses.
87
+ */
88
+ export declare function smolErrorForStatus(message: string, options?: SmolErrorOptions): SmolError;
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.1",
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": [