@jxburros/ai-nugget 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (67) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +316 -0
  3. package/dist/adapters/engines/anthropic.d.ts +8 -0
  4. package/dist/adapters/engines/anthropic.js +278 -0
  5. package/dist/adapters/engines/base.d.ts +23 -0
  6. package/dist/adapters/engines/base.js +93 -0
  7. package/dist/adapters/engines/google.d.ts +8 -0
  8. package/dist/adapters/engines/google.js +217 -0
  9. package/dist/adapters/engines/ollama.d.ts +10 -0
  10. package/dist/adapters/engines/ollama.js +160 -0
  11. package/dist/adapters/engines/openaiChat.d.ts +15 -0
  12. package/dist/adapters/engines/openaiChat.js +228 -0
  13. package/dist/adapters/index.d.ts +5 -0
  14. package/dist/adapters/index.js +17 -0
  15. package/dist/adapters/profiles.d.ts +42 -0
  16. package/dist/adapters/profiles.js +111 -0
  17. package/dist/agent/index.d.ts +3 -0
  18. package/dist/agent/index.js +3 -0
  19. package/dist/agent/loop.d.ts +70 -0
  20. package/dist/agent/loop.js +263 -0
  21. package/dist/agent/tools.d.ts +29 -0
  22. package/dist/agent/tools.js +48 -0
  23. package/dist/errors.d.ts +22 -0
  24. package/dist/errors.js +80 -0
  25. package/dist/handler.d.ts +48 -0
  26. package/dist/handler.js +371 -0
  27. package/dist/index.d.ts +11 -0
  28. package/dist/index.js +11 -0
  29. package/dist/json.d.ts +7 -0
  30. package/dist/json.js +114 -0
  31. package/dist/keys.d.ts +7 -0
  32. package/dist/keys.js +78 -0
  33. package/dist/policy.d.ts +6 -0
  34. package/dist/policy.js +37 -0
  35. package/dist/redact.d.ts +8 -0
  36. package/dist/redact.js +46 -0
  37. package/dist/tokens.d.ts +5 -0
  38. package/dist/tokens.js +18 -0
  39. package/dist/transport.d.ts +28 -0
  40. package/dist/transport.js +145 -0
  41. package/dist/types.d.ts +191 -0
  42. package/dist/types.js +4 -0
  43. package/dist/util.d.ts +8 -0
  44. package/dist/util.js +51 -0
  45. package/nugget/VERSION.txt +2 -0
  46. package/nugget/src/adapters/engines/anthropic.ts +260 -0
  47. package/nugget/src/adapters/engines/base.ts +90 -0
  48. package/nugget/src/adapters/engines/google.ts +209 -0
  49. package/nugget/src/adapters/engines/ollama.ts +153 -0
  50. package/nugget/src/adapters/engines/openaiChat.ts +223 -0
  51. package/nugget/src/adapters/index.ts +17 -0
  52. package/nugget/src/adapters/profiles.ts +149 -0
  53. package/nugget/src/agent/index.ts +2 -0
  54. package/nugget/src/agent/loop.ts +314 -0
  55. package/nugget/src/agent/tools.ts +55 -0
  56. package/nugget/src/errors.ts +80 -0
  57. package/nugget/src/handler.ts +408 -0
  58. package/nugget/src/index.ts +10 -0
  59. package/nugget/src/json.ts +108 -0
  60. package/nugget/src/keys.ts +70 -0
  61. package/nugget/src/policy.ts +39 -0
  62. package/nugget/src/redact.ts +48 -0
  63. package/nugget/src/tokens.ts +21 -0
  64. package/nugget/src/transport.ts +148 -0
  65. package/nugget/src/types.ts +175 -0
  66. package/nugget/src/util.ts +51 -0
  67. package/package.json +69 -0
@@ -0,0 +1,314 @@
1
+ import { profileFor } from '../adapters/profiles.js';
2
+ import { AIError } from '../errors.js';
3
+ import { extractJson } from '../json.js';
4
+ import { mergeUsage } from '../tokens.js';
5
+ import type { AIHandler } from '../handler.js';
6
+ import type { ChatMessage, Connection, StreamEvent, ToolCall, Usage } from '../types.js';
7
+ import { sleep } from '../util.js';
8
+ import type { ToolSpec } from './tools.js';
9
+ import { validateToolArgs } from './tools.js';
10
+
11
+ export interface AgentOptions {
12
+ handler: AIHandler;
13
+ connection: Connection;
14
+ model: string;
15
+ tools: ToolSpec[];
16
+ messages: ChatMessage[];
17
+ /**
18
+ * `native` sends `tools` on the request; `promptJson` describes tools in a
19
+ * system message and parses a JSON directive back out of plain text.
20
+ * `auto` (the default) picks per-call from the connection's provider
21
+ * capability profile (`profileFor(provider).capabilities.nativeTools`) —
22
+ * hosted providers with reliable tool-calling get `native`, local runtimes
23
+ * (Ollama, llama.cpp, LM Studio, vLLM) and the `openai-compat` escape hatch
24
+ * default to `promptJson` since native tool support there is model-
25
+ * dependent, not protocol-guaranteed.
26
+ */
27
+ toolMode?: 'native' | 'promptJson' | 'auto';
28
+ budget?: {
29
+ maxSteps?: number;
30
+ maxTokens?: number;
31
+ deadlineMs?: number;
32
+ };
33
+ approval?: ApprovalGate;
34
+ onEvent?: (e: AgentEvent) => void;
35
+ signal?: AbortSignal;
36
+ metadata?: Record<string, unknown>;
37
+ }
38
+
39
+ export type ApprovalGate = (req: {
40
+ call: ToolCall;
41
+ tool: ToolSpec;
42
+ step: number;
43
+ }) => Promise<'allow' | 'deny' | { modifiedArguments: unknown }>;
44
+
45
+ export type AgentEvent =
46
+ | StreamEvent
47
+ | { type: 'step_start'; step: number }
48
+ | { type: 'tool_start'; step: number; call: ToolCall }
49
+ | { type: 'tool_result'; step: number; call: ToolCall; result: unknown; isError: boolean }
50
+ | { type: 'tool_denied'; step: number; call: ToolCall; reason: string }
51
+ | { type: 'agent_done'; result: AgentResult };
52
+
53
+ export interface AgentResult {
54
+ finalText: string;
55
+ messages: ChatMessage[];
56
+ usage: Usage;
57
+ steps: number;
58
+ stopReason: 'finished' | 'max_steps' | 'budget' | 'deadline' | 'canceled' | 'error';
59
+ }
60
+
61
+ export function runAgent(opts: AgentOptions): AsyncIterable<AgentEvent> & { result: Promise<AgentResult> } {
62
+ let resolveResult!: (result: AgentResult) => void;
63
+ const result = new Promise<AgentResult>((resolve) => {
64
+ resolveResult = resolve;
65
+ });
66
+ const iterable = run(opts, resolveResult);
67
+ return Object.assign(iterable, { result });
68
+ }
69
+
70
+ async function* run(opts: AgentOptions, resolveResult: (result: AgentResult) => void): AsyncIterable<AgentEvent> {
71
+ const messages = [...opts.messages];
72
+ const toolMode = resolveToolMode(opts);
73
+ const maxSteps = opts.budget?.maxSteps ?? 8;
74
+ const agentSignal = createAgentSignal(opts.signal, opts.budget?.deadlineMs);
75
+ let usage: Usage | undefined;
76
+ let finalText = '';
77
+ let step = 0;
78
+ let settled = false;
79
+
80
+ try {
81
+ while (step < maxSteps) {
82
+ step += 1;
83
+ if (agentSignal.signal?.aborted) return yield* yieldDone(agentSignal.timedOut() ? 'deadline' : 'canceled');
84
+ yield emit(opts, { type: 'step_start', step });
85
+
86
+ const calls: ToolCall[] = [];
87
+ let stepText = '';
88
+ let streamFailure: AIError | undefined;
89
+ const requestMessages = toolMode === 'promptJson' ? withPromptJsonInstruction(messages, opts.tools) : messages;
90
+ for await (const event of opts.handler.stream(opts.connection, {
91
+ model: opts.model,
92
+ messages: requestMessages,
93
+ tools: toolMode === 'promptJson' ? undefined : opts.tools,
94
+ signal: agentSignal.signal,
95
+ metadata: { ...opts.metadata, agentStep: step },
96
+ })) {
97
+ if (event.type === 'delta') stepText += event.text;
98
+ if (event.type === 'tool_call') calls.push(event.call);
99
+ if (event.type === 'error') streamFailure = event.error;
100
+ if (event.type === 'done') {
101
+ usage = usage ? mergeUsage(usage, event.result.usage) : event.result.usage;
102
+ if (toolMode === 'promptJson' && calls.length === 0) calls.push(...callsFromPromptJson(stepText));
103
+ }
104
+ yield emit(opts, event);
105
+ }
106
+
107
+ // A handler-level failure (auth, policy, cancel, exhausted retries) must
108
+ // stop the loop honestly rather than looking like an empty completion.
109
+ if (streamFailure) return yield* yieldDone(agentSignal.timedOut() ? 'deadline' : streamFailure.kind === 'canceled' ? 'canceled' : 'error');
110
+
111
+ finalText = stepText;
112
+ messages.push(toolMode === 'promptJson'
113
+ ? { role: 'assistant', content: stepText }
114
+ : { role: 'assistant', content: stepText, toolCalls: calls.length ? calls : undefined });
115
+ if (calls.length === 0) return yield* yieldDone('finished');
116
+
117
+ for (const call of calls) {
118
+ if (agentSignal.signal?.aborted) return yield* yieldDone(agentSignal.timedOut() ? 'deadline' : 'canceled');
119
+ const tool = opts.tools.find((candidate) => candidate.name === call.name);
120
+ if (!tool) {
121
+ yield* appendToolError(opts, messages, step, call, `Unknown tool: ${call.name}`);
122
+ continue;
123
+ }
124
+ const validation = validateToolArgs(tool, call);
125
+ if (!validation.ok) {
126
+ yield* appendToolError(opts, messages, step, call, validation.message);
127
+ continue;
128
+ }
129
+ let args = validation.args;
130
+ if (tool.sideEffects) {
131
+ if (!opts.approval) {
132
+ yield* appendToolDenied(opts, messages, step, call, 'No approval gate configured');
133
+ continue;
134
+ }
135
+ const approval = await opts.approval({ call, tool, step });
136
+ if (agentSignal.signal?.aborted) return yield* yieldDone(agentSignal.timedOut() ? 'deadline' : 'canceled');
137
+ if (approval === 'deny') {
138
+ yield* appendToolDenied(opts, messages, step, call, 'Denied by approval gate');
139
+ continue;
140
+ }
141
+ if (approval !== 'allow') {
142
+ const modified = validateToolArgs(tool, { ...call, arguments: approval.modifiedArguments });
143
+ if (!modified.ok) {
144
+ yield* appendToolError(opts, messages, step, call, modified.message);
145
+ continue;
146
+ }
147
+ args = modified.args;
148
+ }
149
+ }
150
+ yield emit(opts, { type: 'tool_start', step, call });
151
+ let executed: { ok: true; result: unknown } | { ok: false; message: string };
152
+ try {
153
+ const result = await tool.execute(args, {
154
+ signal: agentSignal.signal ?? new AbortController().signal,
155
+ callId: call.id,
156
+ step,
157
+ metadata: opts.metadata,
158
+ });
159
+ executed = { ok: true, result };
160
+ } catch (error) {
161
+ executed = { ok: false, message: error instanceof Error ? error.message : 'Tool failed' };
162
+ }
163
+ if (!executed.ok) {
164
+ yield* appendToolError(opts, messages, step, call, executed.message);
165
+ continue;
166
+ }
167
+ try {
168
+ messages.push({ role: 'tool', toolCallId: call.id, name: call.name, content: JSON.stringify(executed.result) });
169
+ yield emit(opts, { type: 'tool_result', step, call, result: executed.result, isError: false });
170
+ } catch (error) {
171
+ const message = error instanceof Error ? error.message : 'Tool result was not serializable';
172
+ yield* appendToolError(opts, messages, step, call, message);
173
+ }
174
+ }
175
+
176
+ const totalTokens = (usage?.inputTokens ?? 0) + (usage?.outputTokens ?? 0);
177
+ if (opts.budget?.maxTokens && totalTokens > opts.budget.maxTokens) return yield* yieldDone('budget');
178
+ await sleep(0, agentSignal.signal);
179
+ }
180
+ return yield* yieldDone('max_steps');
181
+ } catch (error) {
182
+ const result = makeResult(finalText, messages, usage ?? { estimated: true }, step, agentSignal.timedOut() ? 'deadline' : (agentSignal.signal?.aborted || (error instanceof AIError && error.kind === 'canceled')) ? 'canceled' : 'error');
183
+ settled = true;
184
+ resolveResult(result);
185
+ yield emit(opts, { type: 'agent_done', result });
186
+ } finally {
187
+ agentSignal.dispose();
188
+ if (!settled) {
189
+ const result = makeResult(finalText, messages, usage ?? { estimated: true }, step, 'canceled');
190
+ resolveResult(result);
191
+ }
192
+ }
193
+
194
+ function* yieldDone(stopReason: AgentResult['stopReason']): Generator<AgentEvent> {
195
+ const result = makeResult(finalText, messages, usage ?? { estimated: true }, step, stopReason);
196
+ settled = true;
197
+ resolveResult(result);
198
+ yield emit(opts, { type: 'agent_done', result });
199
+ }
200
+ }
201
+
202
+ function emit<T extends AgentEvent>(opts: AgentOptions, event: T): T {
203
+ opts.onEvent?.(event);
204
+ return event;
205
+ }
206
+
207
+ function makeResult(finalText: string, messages: ChatMessage[], usage: Usage, steps: number, stopReason: AgentResult['stopReason']): AgentResult {
208
+ return { finalText, messages, usage, steps, stopReason };
209
+ }
210
+
211
+ function* appendToolError(opts: AgentOptions, messages: ChatMessage[], step: number, call: ToolCall, message: string): Generator<AgentEvent> {
212
+ const result = { error: message };
213
+ messages.push({ role: 'tool', toolCallId: call.id, name: call.name, content: JSON.stringify(result) });
214
+ yield emit(opts, { type: 'tool_result', step, call, result, isError: true });
215
+ }
216
+
217
+ function* appendToolDenied(opts: AgentOptions, messages: ChatMessage[], step: number, call: ToolCall, reason: string): Generator<AgentEvent> {
218
+ messages.push({ role: 'tool', toolCallId: call.id, name: call.name, content: JSON.stringify({ denied: true, reason }) });
219
+ yield emit(opts, { type: 'tool_denied', step, call, reason });
220
+ }
221
+
222
+ function resolveToolMode(opts: AgentOptions): 'native' | 'promptJson' {
223
+ const mode = opts.toolMode ?? 'auto';
224
+ if (mode !== 'auto') return mode;
225
+ return profileFor(opts.connection.provider, opts.connection.baseUrl).capabilities.nativeTools ? 'native' : 'promptJson';
226
+ }
227
+
228
+ function withPromptJsonInstruction(messages: ChatMessage[], tools: ToolSpec[]): ChatMessage[] {
229
+ return [
230
+ {
231
+ role: 'system',
232
+ content: `When you need tools, respond only with JSON. For one tool: {"tool":"name","input":{...}}. For several in one turn: {"tools":[{"tool":"name","input":{...}}]}. Available tools: ${tools.map((tool) => `${tool.name}: ${tool.description}`).join('; ')}`,
233
+ },
234
+ ...messages.map(toPromptJsonMessage),
235
+ ];
236
+ }
237
+
238
+ function toPromptJsonMessage(message: ChatMessage): ChatMessage {
239
+ if (message.role === 'tool') {
240
+ return {
241
+ role: 'user',
242
+ content: `Tool ${message.name ?? message.toolCallId ?? 'unknown'} returned: ${textContent(message.content)}`,
243
+ };
244
+ }
245
+ return {
246
+ role: message.role,
247
+ content: textContent(message.content),
248
+ };
249
+ }
250
+
251
+ /**
252
+ * Parses a promptJson tool directive into zero or more calls. Accepts the single
253
+ * form ({"tool","input"}), the batched form ({"tools":[...]}), and a bare array
254
+ * of directives, so a promptJson-mode model can request several tools per turn
255
+ * just like native tool-calling can. Malformed entries are skipped, not thrown.
256
+ */
257
+ function callsFromPromptJson(text: string): ToolCall[] {
258
+ const value = extractJson(text);
259
+ if (!value || typeof value !== 'object') return [];
260
+ const entries: unknown[] = Array.isArray(value)
261
+ ? value
262
+ : Array.isArray((value as Record<string, unknown>).tools)
263
+ ? ((value as Record<string, unknown>).tools as unknown[])
264
+ : [value];
265
+ const calls: ToolCall[] = [];
266
+ for (const entry of entries) {
267
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue;
268
+ const record = entry as Record<string, unknown>;
269
+ if (typeof record.tool !== 'string') continue;
270
+ calls.push({
271
+ id: crypto.randomUUID(),
272
+ name: record.tool,
273
+ arguments: record.input && typeof record.input === 'object' ? record.input : {},
274
+ raw: JSON.stringify(record.input ?? {}),
275
+ });
276
+ }
277
+ return calls;
278
+ }
279
+
280
+ function textContent(content: ChatMessage['content']): string {
281
+ if (typeof content === 'string') return content;
282
+ if (!content) return '';
283
+ return content.filter((part) => part.type === 'text').map((part) => part.text ?? '').join('\n');
284
+ }
285
+
286
+ function createAgentSignal(signal: AbortSignal | undefined, deadlineMs: number | undefined): {
287
+ signal?: AbortSignal;
288
+ timedOut(): boolean;
289
+ dispose(): void;
290
+ } {
291
+ if (!signal && deadlineMs === undefined) return { signal: undefined, timedOut: () => false, dispose: () => undefined };
292
+ const controller = new AbortController();
293
+ let didTimeOut = false;
294
+ let timer: ReturnType<typeof setTimeout> | undefined;
295
+ const onAbort = () => controller.abort(signal?.reason);
296
+ if (signal) {
297
+ if (signal.aborted) onAbort();
298
+ else signal.addEventListener('abort', onAbort, { once: true });
299
+ }
300
+ if (deadlineMs !== undefined) {
301
+ timer = setTimeout(() => {
302
+ didTimeOut = true;
303
+ controller.abort(new AIError(`Agent deadline exceeded after ${deadlineMs}ms`, { kind: 'budget_exceeded', retryable: false }));
304
+ }, deadlineMs);
305
+ }
306
+ return {
307
+ signal: controller.signal,
308
+ timedOut: () => didTimeOut,
309
+ dispose() {
310
+ if (timer) clearTimeout(timer);
311
+ signal?.removeEventListener('abort', onAbort);
312
+ },
313
+ };
314
+ }
@@ -0,0 +1,55 @@
1
+ import type { ToolCall, ToolSchema } from '../types.js';
2
+
3
+ export interface ToolSpec<A = unknown, R = unknown> extends ToolSchema {
4
+ sideEffects?: boolean;
5
+ execute(args: A, ctx: ToolContext): Promise<R> | R;
6
+ }
7
+
8
+ export interface ToolContext {
9
+ signal: AbortSignal;
10
+ callId: string;
11
+ step: number;
12
+ metadata?: Record<string, unknown>;
13
+ }
14
+
15
+ export function defineTool<A, R>(spec: ToolSpec<A, R>): ToolSpec<A, R> {
16
+ return spec;
17
+ }
18
+
19
+ /**
20
+ * Light validation only: object-ness, `required` presence, and top-level
21
+ * `properties[key].type` against JS typeof/Array.isArray/Number.isInteger.
22
+ * It does not implement full JSON Schema — no `enum`, nested `properties`,
23
+ * `oneOf`/`anyOf`, numeric bounds, string `pattern`, array `items` schemas, or
24
+ * `additionalProperties`. That's intentional for a zero-dependency nugget; a
25
+ * caller that needs stricter guarantees should validate `args` again inside
26
+ * `tool.execute` with its own schema library.
27
+ */
28
+ export function validateToolArgs(tool: ToolSpec, call: ToolCall): { ok: true; args: unknown } | { ok: false; message: string } {
29
+ const schema = tool.parameters as { required?: unknown; properties?: unknown; type?: unknown };
30
+ if (schema.type && schema.type !== 'object') return { ok: false, message: `Tool ${tool.name} parameters must be an object schema` };
31
+ if (typeof call.arguments !== 'object' || call.arguments === null || Array.isArray(call.arguments)) {
32
+ return { ok: false, message: `Tool ${tool.name} expected object arguments` };
33
+ }
34
+ const args = call.arguments as Record<string, unknown>;
35
+ const required = Array.isArray(schema.required) ? schema.required.filter((key): key is string => typeof key === 'string') : [];
36
+ for (const key of required) {
37
+ if (!(key in args)) return { ok: false, message: `Tool ${tool.name} missing required argument: ${key}` };
38
+ }
39
+ const properties = schema.properties && typeof schema.properties === 'object' ? schema.properties as Record<string, { type?: string }> : {};
40
+ for (const [key, spec] of Object.entries(properties)) {
41
+ if (!(key in args) || spec.type === undefined) continue;
42
+ const value = args[key];
43
+ const ok = matchesType(value, spec.type);
44
+ if (!ok) return { ok: false, message: `Tool ${tool.name} argument ${key} must be ${spec.type}` };
45
+ }
46
+ return { ok: true, args };
47
+ }
48
+
49
+ function matchesType(value: unknown, type: string): boolean {
50
+ if (type === 'array') return Array.isArray(value);
51
+ if (type === 'integer') return typeof value === 'number' && Number.isInteger(value);
52
+ if (type === 'null') return value === null;
53
+ if (type === 'object') return typeof value === 'object' && value !== null && !Array.isArray(value);
54
+ return typeof value === type;
55
+ }
@@ -0,0 +1,80 @@
1
+ import type { AIErrorKind } from './types.js';
2
+
3
+ export class AIError extends Error {
4
+ kind: AIErrorKind;
5
+ status?: number;
6
+ retryable: boolean;
7
+ provider?: string;
8
+ raw?: string;
9
+ retryAfterMs?: number;
10
+
11
+ constructor(message: string, opts: {
12
+ kind: AIErrorKind;
13
+ status?: number;
14
+ retryable?: boolean;
15
+ provider?: string;
16
+ raw?: string;
17
+ retryAfterMs?: number;
18
+ cause?: unknown;
19
+ }) {
20
+ super(message);
21
+ this.name = 'AIError';
22
+ this.kind = opts.kind;
23
+ this.retryable = opts.retryable ?? defaultRetryable(opts.kind);
24
+ if (opts.status !== undefined) this.status = opts.status;
25
+ if (opts.provider !== undefined) this.provider = opts.provider;
26
+ if (opts.raw !== undefined) this.raw = opts.raw;
27
+ if (opts.retryAfterMs !== undefined) this.retryAfterMs = opts.retryAfterMs;
28
+ if (opts.cause !== undefined) this.cause = opts.cause;
29
+ }
30
+ }
31
+
32
+ export function defaultRetryable(kind: AIErrorKind): boolean {
33
+ return kind === 'rate_limit' || kind === 'timeout' || kind === 'network' || kind === 'server' || kind === 'invalid_response';
34
+ }
35
+
36
+ export function classify(status: number, body = '', provider?: string, headers?: Headers): AIError {
37
+ const excerpt = body.slice(0, 200);
38
+ const lower = body.toLowerCase();
39
+ let kind: AIErrorKind = 'server';
40
+ if (status === 401 || status === 403) kind = 'auth';
41
+ else if (status === 408) kind = 'timeout';
42
+ else if (status === 429) kind = 'rate_limit';
43
+ else if (status === 400 || status === 422) {
44
+ kind = lower.includes('context') || lower.includes('maximum context') || lower.includes('token limit')
45
+ ? 'context_length'
46
+ : 'invalid_request';
47
+ } else if (status >= 500) kind = 'server';
48
+ else kind = 'invalid_request';
49
+
50
+ return new AIError(`HTTP ${status}: ${excerpt}`, {
51
+ kind,
52
+ status,
53
+ provider,
54
+ raw: excerpt,
55
+ retryAfterMs: parseRetryAfter(headers?.get('retry-after') ?? null),
56
+ });
57
+ }
58
+
59
+ export function fromUnknown(error: unknown, provider?: string): AIError {
60
+ if (error instanceof AIError) return error;
61
+ if (error instanceof DOMException && error.name === 'AbortError') {
62
+ return new AIError('Request was canceled', { kind: 'canceled', retryable: false, provider, cause: error });
63
+ }
64
+ if (error instanceof Error && /timeout|timed out/i.test(error.message)) {
65
+ return new AIError(error.message, { kind: 'timeout', provider, cause: error });
66
+ }
67
+ if (error instanceof Error) {
68
+ return new AIError(error.message, { kind: 'network', provider, cause: error });
69
+ }
70
+ return new AIError('Unknown AI provider error', { kind: 'network', provider, cause: error });
71
+ }
72
+
73
+ function parseRetryAfter(value: string | null): number | undefined {
74
+ if (!value) return undefined;
75
+ const seconds = Number(value);
76
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
77
+ const time = Date.parse(value);
78
+ if (!Number.isNaN(time)) return Math.max(0, time - Date.now());
79
+ return undefined;
80
+ }