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.
- package/dist/classes/ToolCall.js +4 -3
- package/dist/classes/message/AssistantMessage.js +5 -3
- package/dist/classes/message/DeveloperMessage.js +5 -3
- package/dist/classes/message/SystemMessage.js +5 -3
- package/dist/classes/message/ToolMessage.js +4 -3
- package/dist/classes/message/UserMessage.js +5 -3
- package/dist/clients/anthropic.js +6 -3
- package/dist/clients/google.d.ts +1 -0
- package/dist/clients/google.js +16 -17
- package/dist/clients/ollama.d.ts +1 -0
- package/dist/clients/ollama.js +17 -6
- package/dist/clients/openai.js +6 -3
- package/dist/clients/openaiResponses.js +6 -3
- package/dist/smolError.d.ts +78 -5
- package/dist/smolError.js +134 -9
- package/dist/util/httpError.d.ts +23 -0
- package/dist/util/httpError.js +237 -0
- package/package.json +1 -1
package/dist/classes/ToolCall.js
CHANGED
|
@@ -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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
}
|
package/dist/clients/google.d.ts
CHANGED
|
@@ -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>;
|
package/dist/clients/google.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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();
|
package/dist/clients/ollama.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/clients/ollama.js
CHANGED
|
@@ -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
|
-
|
|
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);
|
package/dist/clients/openai.js
CHANGED
|
@@ -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
|
}
|
package/dist/smolError.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|