@namzu/sdk 1.2.0 → 1.4.0
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/CHANGELOG.md +99 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts +12 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts.map +1 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.js +73 -0
- package/dist/compaction/__tests__/verifier-empty-reply.test.js.map +1 -0
- package/dist/compaction/verifier.d.ts.map +1 -1
- package/dist/compaction/verifier.js +10 -1
- package/dist/compaction/verifier.js.map +1 -1
- package/dist/config/__tests__/compaction-budget-schema.test.d.ts +18 -0
- package/dist/config/__tests__/compaction-budget-schema.test.d.ts.map +1 -0
- package/dist/config/__tests__/compaction-budget-schema.test.js +96 -0
- package/dist/config/__tests__/compaction-budget-schema.test.js.map +1 -0
- package/dist/config/runtime.d.ts.map +1 -1
- package/dist/config/runtime.js +20 -11
- package/dist/config/runtime.js.map +1 -1
- package/dist/manager/run/persistence.d.ts +2 -1
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +3 -1
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/provider/__tests__/errors.test.d.ts +2 -0
- package/dist/provider/__tests__/errors.test.d.ts.map +1 -0
- package/dist/provider/__tests__/errors.test.js +61 -0
- package/dist/provider/__tests__/errors.test.js.map +1 -0
- package/dist/provider/__tests__/registry.test.d.ts +5 -0
- package/dist/provider/__tests__/registry.test.d.ts.map +1 -1
- package/dist/provider/__tests__/registry.test.js +186 -1
- package/dist/provider/__tests__/registry.test.js.map +1 -1
- package/dist/provider/errors.d.ts +108 -0
- package/dist/provider/errors.d.ts.map +1 -0
- package/dist/provider/errors.js +311 -0
- package/dist/provider/errors.js.map +1 -0
- package/dist/provider/index.d.ts +3 -1
- package/dist/provider/index.d.ts.map +1 -1
- package/dist/provider/index.js +2 -1
- package/dist/provider/index.js.map +1 -1
- package/dist/provider/registry.d.ts +81 -1
- package/dist/provider/registry.d.ts.map +1 -1
- package/dist/provider/registry.js +173 -7
- package/dist/provider/registry.js.map +1 -1
- package/dist/public-runtime.d.ts +11 -11
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +11 -11
- package/dist/public-runtime.js.map +1 -1
- package/dist/registry/tool/execute.d.ts.map +1 -1
- package/dist/registry/tool/execute.js +5 -2
- package/dist/registry/tool/execute.js.map +1 -1
- package/dist/registry/tool/execute.test.js +11 -0
- package/dist/registry/tool/execute.test.js.map +1 -1
- package/dist/runtime/query/__tests__/stream-recovery.test.js +54 -1
- package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts +32 -0
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js +152 -0
- package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js.map +1 -0
- package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
- package/dist/runtime/query/iteration/phases/compaction.js +52 -12
- package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
- package/dist/runtime/query/iteration/stream-turn.js +11 -5
- package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
- package/dist/runtime/query/result.d.ts.map +1 -1
- package/dist/runtime/query/result.js +11 -1
- package/dist/runtime/query/result.js.map +1 -1
- package/dist/tools/builtins/__tests__/edit.test.js +54 -0
- package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
- package/dist/tools/builtins/edit.d.ts +1 -1
- package/dist/tools/builtins/edit.d.ts.map +1 -1
- package/dist/tools/builtins/edit.js +11 -13
- package/dist/tools/builtins/edit.js.map +1 -1
- package/dist/tools/coordinator/__tests__/task-list.test.js +14 -0
- package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
- package/dist/tools/coordinator/agent.d.ts +6 -8
- package/dist/tools/coordinator/agent.d.ts.map +1 -1
- package/dist/tools/coordinator/agent.js.map +1 -1
- package/dist/tools/coordinator/index.d.ts.map +1 -1
- package/dist/tools/coordinator/index.js +11 -32
- package/dist/tools/coordinator/index.js.map +1 -1
- package/dist/tools/defineTool.d.ts +1 -0
- package/dist/tools/defineTool.d.ts.map +1 -1
- package/dist/tools/defineTool.js +1 -0
- package/dist/tools/defineTool.js.map +1 -1
- package/dist/types/provider/config.d.ts +33 -0
- package/dist/types/provider/config.d.ts.map +1 -1
- package/dist/types/provider/error.d.ts +22 -0
- package/dist/types/provider/error.d.ts.map +1 -0
- package/dist/types/provider/error.js +2 -0
- package/dist/types/provider/error.js.map +1 -0
- package/dist/types/provider/index.d.ts +2 -1
- package/dist/types/provider/index.d.ts.map +1 -1
- package/dist/types/run/entity.d.ts +2 -0
- package/dist/types/run/entity.d.ts.map +1 -1
- package/dist/types/run/events.d.ts +2 -0
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/dist/types/tool/index.d.ts +6 -0
- package/dist/types/tool/index.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/compaction/__tests__/verifier-empty-reply.test.ts +96 -0
- package/src/compaction/verifier.ts +10 -1
- package/src/config/__tests__/compaction-budget-schema.test.ts +114 -0
- package/src/config/runtime.ts +20 -11
- package/src/manager/run/persistence.ts +3 -1
- package/src/provider/__tests__/errors.test.ts +85 -0
- package/src/provider/__tests__/registry.test.ts +266 -1
- package/src/provider/errors.ts +344 -0
- package/src/provider/index.ts +22 -1
- package/src/provider/registry.ts +202 -7
- package/src/public-runtime.ts +55 -10
- package/src/registry/tool/execute.test.ts +14 -0
- package/src/registry/tool/execute.ts +5 -2
- package/src/runtime/query/__tests__/stream-recovery.test.ts +64 -1
- package/src/runtime/query/iteration/phases/compaction-safe-cut.test.ts +186 -0
- package/src/runtime/query/iteration/phases/compaction.ts +54 -12
- package/src/runtime/query/iteration/stream-turn.ts +11 -6
- package/src/runtime/query/result.ts +11 -1
- package/src/tools/builtins/__tests__/edit.test.ts +76 -0
- package/src/tools/builtins/edit.ts +12 -12
- package/src/tools/coordinator/__tests__/task-list.test.ts +16 -0
- package/src/tools/coordinator/agent.ts +6 -8
- package/src/tools/coordinator/index.ts +11 -34
- package/src/tools/defineTool.ts +2 -0
- package/src/types/provider/config.ts +36 -0
- package/src/types/provider/error.ts +29 -0
- package/src/types/provider/index.ts +8 -0
- package/src/types/run/entity.ts +2 -0
- package/src/types/run/events.ts +7 -1
- package/src/types/tool/index.ts +6 -0
|
@@ -0,0 +1,344 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One classified error for every provider failure.
|
|
3
|
+
*
|
|
4
|
+
* Before this, a failure from any of the seven drivers reached the caller as an
|
|
5
|
+
* opaque string, so nothing downstream could decide whether it was worth
|
|
6
|
+
* retrying, whether it was the caller's fault, or whether the context window had
|
|
7
|
+
* simply run out. Two drivers built that string by interpolating the response
|
|
8
|
+
* body; three more inherited a vendor SDK error whose message IS the response
|
|
9
|
+
* body. Either way, a credential the upstream echoed back landed in a message
|
|
10
|
+
* that gets logged.
|
|
11
|
+
*
|
|
12
|
+
* So the contract here is deliberately narrow:
|
|
13
|
+
*
|
|
14
|
+
* - the message is built from the STATUS LINE and the classified `kind`. The
|
|
15
|
+
* response body is read to classify and then dropped. It is never
|
|
16
|
+
* interpolated, never re-thrown, and never attached as `cause` — a `cause`
|
|
17
|
+
* survives every logger that serializes an error chain, which defeats the
|
|
18
|
+
* point.
|
|
19
|
+
* - `retryAfterMs` is DATA. Nothing in this module sleeps, backs off or
|
|
20
|
+
* retries. A retry loop inside a driver burns the run's wall clock and hides
|
|
21
|
+
* the failure from the layer that should decide.
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
import type { ProviderErrorKind, ProviderRequestErrorInit } from '../types/provider/error.js'
|
|
25
|
+
export type {
|
|
26
|
+
ProviderErrorInfo,
|
|
27
|
+
ProviderErrorKind,
|
|
28
|
+
ProviderRequestErrorInit,
|
|
29
|
+
} from '../types/provider/error.js'
|
|
30
|
+
|
|
31
|
+
const PROVIDER_ERROR_KINDS: readonly ProviderErrorKind[] = [
|
|
32
|
+
'throttle',
|
|
33
|
+
'network',
|
|
34
|
+
'auth',
|
|
35
|
+
'context_overflow',
|
|
36
|
+
'bad_request',
|
|
37
|
+
'server',
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A provider request that failed, classified.
|
|
42
|
+
*
|
|
43
|
+
* `name` is set explicitly rather than inherited, because the classifier is
|
|
44
|
+
* matched structurally across a package boundary (a driver in one package throws
|
|
45
|
+
* it; the runtime in another reads it) and `instanceof` is unreliable when two
|
|
46
|
+
* copies of the SDK end up in one process.
|
|
47
|
+
*/
|
|
48
|
+
export class ProviderRequestError extends Error {
|
|
49
|
+
public readonly kind: ProviderErrorKind
|
|
50
|
+
public readonly providerId: string
|
|
51
|
+
public readonly status?: number
|
|
52
|
+
public readonly retryAfterMs?: number
|
|
53
|
+
|
|
54
|
+
constructor(init: ProviderRequestErrorInit) {
|
|
55
|
+
super(buildProviderErrorMessage(init))
|
|
56
|
+
this.name = 'ProviderRequestError'
|
|
57
|
+
this.kind = init.kind
|
|
58
|
+
this.providerId = init.providerId
|
|
59
|
+
if (init.status !== undefined) this.status = init.status
|
|
60
|
+
if (init.retryAfterMs !== undefined) this.retryAfterMs = init.retryAfterMs
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Is this a classified provider failure, whichever SDK copy threw it? */
|
|
65
|
+
export function isProviderRequestError(err: unknown): err is ProviderRequestError {
|
|
66
|
+
return (
|
|
67
|
+
err instanceof Error &&
|
|
68
|
+
err.name === 'ProviderRequestError' &&
|
|
69
|
+
typeof (err as { providerId?: unknown }).providerId === 'string' &&
|
|
70
|
+
PROVIDER_ERROR_KINDS.includes((err as { kind?: ProviderErrorKind }).kind as ProviderErrorKind)
|
|
71
|
+
)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Did the caller's own AbortSignal terminate this request?
|
|
76
|
+
*
|
|
77
|
+
* Provider SDKs do not agree on the object they reject with: some preserve
|
|
78
|
+
* `signal.reason`, while others replace it with `AbortError` or
|
|
79
|
+
* `APIUserAbortError`. Reclassifying any of those as a network failure breaks
|
|
80
|
+
* the runtime's Stop/cancel path, which depends on the abort escaping the
|
|
81
|
+
* provider boundary.
|
|
82
|
+
*
|
|
83
|
+
* The signal must itself be aborted. That condition distinguishes a caller Stop
|
|
84
|
+
* from an SDK timeout which may also use an `AbortError`-shaped rejection.
|
|
85
|
+
*/
|
|
86
|
+
export function isCallerAbortError(error: unknown, signal?: AbortSignal): boolean {
|
|
87
|
+
if (!signal?.aborted) return false
|
|
88
|
+
if (error === signal.reason) return true
|
|
89
|
+
if (!(error instanceof Error)) return false
|
|
90
|
+
return error.name === 'AbortError' || error.name === 'APIUserAbortError'
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const KIND_SENTENCE: Record<ProviderErrorKind, string> = {
|
|
94
|
+
throttle: 'rate limited by the provider',
|
|
95
|
+
network: 'could not reach the provider',
|
|
96
|
+
auth: 'the provider rejected the request credentials',
|
|
97
|
+
context_overflow: 'the request exceeded the model context window',
|
|
98
|
+
bad_request: 'the provider rejected the request as invalid',
|
|
99
|
+
server: 'the provider failed to complete the request',
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function buildProviderErrorMessage(init: ProviderRequestErrorInit): string {
|
|
103
|
+
const status = init.status !== undefined ? ` (HTTP ${init.status})` : ''
|
|
104
|
+
const detail = init.detail ? `: ${init.detail}` : ''
|
|
105
|
+
return `${init.providerId}${status} — ${KIND_SENTENCE[init.kind]}${detail}`
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Body fragments that mean "the request did not fit", across the vendors we
|
|
110
|
+
* drive. Matched case-insensitively against the raw body, which is then
|
|
111
|
+
* discarded.
|
|
112
|
+
*
|
|
113
|
+
* A 400 is otherwise `bad_request`: an overflow is the one 400 a caller can act
|
|
114
|
+
* on automatically (compact and retry), and mistaking a genuine schema error for
|
|
115
|
+
* an overflow would send the run into a pointless compaction loop.
|
|
116
|
+
*/
|
|
117
|
+
const OVERFLOW_BODY_PATTERNS: readonly RegExp[] = [
|
|
118
|
+
/prompt is too long/i,
|
|
119
|
+
/context[_ ]length[_ ]exceeded/i,
|
|
120
|
+
/maximum context length/i,
|
|
121
|
+
/too long for requested model/i,
|
|
122
|
+
/input is too long/i,
|
|
123
|
+
/(?:input|prompt|context).{0,80}exceeds the maximum/i,
|
|
124
|
+
/reduce the (?:input|prompt|context) length/i,
|
|
125
|
+
// Ollama phrases it the other way round — "input length exceeds context
|
|
126
|
+
// length" — so a pattern anchored on "exceeded" misses it entirely.
|
|
127
|
+
/exceeds?\s+(?:the\s+)?(?:model'?s?\s+)?context\s+(?:length|window|size)/i,
|
|
128
|
+
/context\s+(?:length|window|size)\s+exceed/i,
|
|
129
|
+
]
|
|
130
|
+
|
|
131
|
+
/** Does this response body say the request did not fit the window? */
|
|
132
|
+
export function bodySaysContextOverflow(body: string | undefined | null): boolean {
|
|
133
|
+
if (!body) return false
|
|
134
|
+
return OVERFLOW_BODY_PATTERNS.some((re) => re.test(body))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* `Retry-After` in milliseconds. The header is either delta-seconds or an
|
|
139
|
+
* HTTP-date; both are specified, and vendors use both.
|
|
140
|
+
*
|
|
141
|
+
* Returns undefined for anything unparseable or for a date already in the past —
|
|
142
|
+
* a negative delay is worse than none, because a caller would treat it as
|
|
143
|
+
* "retry immediately" against a provider that just asked it to wait.
|
|
144
|
+
*/
|
|
145
|
+
export function parseRetryAfterMs(
|
|
146
|
+
headerValue: string | null | undefined,
|
|
147
|
+
now: number = Date.now(),
|
|
148
|
+
): number | undefined {
|
|
149
|
+
if (!headerValue) return undefined
|
|
150
|
+
const trimmed = headerValue.trim()
|
|
151
|
+
if (trimmed === '') return undefined
|
|
152
|
+
|
|
153
|
+
if (/^\d+(\.\d+)?$/.test(trimmed)) {
|
|
154
|
+
const seconds = Number(trimmed)
|
|
155
|
+
if (!Number.isFinite(seconds) || seconds < 0) return undefined
|
|
156
|
+
return Math.round(seconds * 1000)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const at = Date.parse(trimmed)
|
|
160
|
+
if (Number.isNaN(at)) return undefined
|
|
161
|
+
const delta = at - now
|
|
162
|
+
return delta > 0 ? delta : undefined
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Classify an HTTP failure. `body` is used ONLY to separate a context overflow
|
|
167
|
+
* from an ordinary bad request, and is not retained.
|
|
168
|
+
*/
|
|
169
|
+
export function classifyProviderHttpStatus(
|
|
170
|
+
status: number,
|
|
171
|
+
body?: string | null,
|
|
172
|
+
): ProviderErrorKind {
|
|
173
|
+
if (status === 401 || status === 403) return 'auth'
|
|
174
|
+
if (status === 429) return 'throttle'
|
|
175
|
+
if (status === 408 || status === 425) return 'network'
|
|
176
|
+
if (status >= 500) return 'server'
|
|
177
|
+
if (status === 413) {
|
|
178
|
+
return bodySaysContextOverflow(body) ? 'context_overflow' : 'bad_request'
|
|
179
|
+
}
|
|
180
|
+
if (status >= 400) {
|
|
181
|
+
return bodySaysContextOverflow(body) ? 'context_overflow' : 'bad_request'
|
|
182
|
+
}
|
|
183
|
+
// A non-failure status reaching here is a caller bug, not a provider one.
|
|
184
|
+
return 'server'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/**
|
|
188
|
+
* Vendor error TYPES, as the vendors name them in their own payloads. This is a
|
|
189
|
+
* defined vocabulary, not prose, which is what makes it safe to classify on:
|
|
190
|
+
* Anthropic sends `{"error":{"type":"overloaded_error"}}` and OpenAI sends
|
|
191
|
+
* `{"error":{"code":"context_length_exceeded"}}`.
|
|
192
|
+
*
|
|
193
|
+
* It matters for MID-STREAM failures above all. Those arrive after a 200, so
|
|
194
|
+
* there is no status to classify from, and without this an upstream overload
|
|
195
|
+
* would be filed as `network` — "we could not reach the provider" — when the
|
|
196
|
+
* provider answered and then gave up. The distinction is the whole point of the
|
|
197
|
+
* taxonomy: one is worth retrying elsewhere, the other is worth retrying here.
|
|
198
|
+
*/
|
|
199
|
+
const VENDOR_TYPE_KINDS: ReadonlyArray<readonly [RegExp, ProviderErrorKind]> = [
|
|
200
|
+
[/overloaded_error|api_error|service_unavailable/i, 'server'],
|
|
201
|
+
[/rate_limit_error|rate_limit_exceeded/i, 'throttle'],
|
|
202
|
+
[/authentication_error|permission_error|invalid_api_key|unauthorized/i, 'auth'],
|
|
203
|
+
[/timeout_error|connection_error/i, 'network'],
|
|
204
|
+
]
|
|
205
|
+
|
|
206
|
+
/** The kind a vendor's own error-type vocabulary implies, if it says one. */
|
|
207
|
+
function vendorTypeKind(message: string): ProviderErrorKind | undefined {
|
|
208
|
+
for (const [re, kind] of VENDOR_TYPE_KINDS) {
|
|
209
|
+
if (re.test(message)) return kind
|
|
210
|
+
}
|
|
211
|
+
return undefined
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Status code off a vendor SDK's own error object, whatever it calls the field.
|
|
216
|
+
* Anthropic and OpenAI use `status`; the ollama client uses `status_code`; AWS
|
|
217
|
+
* puts it under `$metadata.httpStatusCode`.
|
|
218
|
+
*/
|
|
219
|
+
function vendorErrorStatus(err: unknown): number | undefined {
|
|
220
|
+
if (typeof err !== 'object' || err === null) return undefined
|
|
221
|
+
const e = err as {
|
|
222
|
+
status?: unknown
|
|
223
|
+
statusCode?: unknown
|
|
224
|
+
status_code?: unknown
|
|
225
|
+
$metadata?: { httpStatusCode?: unknown }
|
|
226
|
+
}
|
|
227
|
+
for (const candidate of [e.status, e.statusCode, e.status_code, e.$metadata?.httpStatusCode]) {
|
|
228
|
+
if (typeof candidate === 'number' && Number.isFinite(candidate)) return candidate
|
|
229
|
+
}
|
|
230
|
+
return undefined
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* `retry-after` off a vendor SDK error's captured response headers. The
|
|
235
|
+
* Anthropic and OpenAI clients both attach them (as a `Headers` instance or a
|
|
236
|
+
* plain object depending on version); the ollama client throws them away, which
|
|
237
|
+
* is why `retryAfterMs` is genuinely unavailable on that driver.
|
|
238
|
+
*/
|
|
239
|
+
function vendorRetryAfter(err: unknown): string | undefined {
|
|
240
|
+
if (typeof err !== 'object' || err === null) return undefined
|
|
241
|
+
// AWS keeps the response one level down, on `$response`; the Anthropic and
|
|
242
|
+
// OpenAI clients attach `headers` directly.
|
|
243
|
+
const headers =
|
|
244
|
+
(err as { headers?: unknown }).headers ??
|
|
245
|
+
(err as { $response?: { headers?: unknown } }).$response?.headers
|
|
246
|
+
if (!headers) return undefined
|
|
247
|
+
if (typeof (headers as Headers).get === 'function') {
|
|
248
|
+
return (headers as Headers).get('retry-after') ?? undefined
|
|
249
|
+
}
|
|
250
|
+
const record = headers as Record<string, unknown>
|
|
251
|
+
for (const key of ['retry-after', 'Retry-After', 'retryAfter']) {
|
|
252
|
+
const value = record[key]
|
|
253
|
+
if (typeof value === 'string') return value
|
|
254
|
+
}
|
|
255
|
+
return undefined
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
/**
|
|
259
|
+
* Classify an error thrown by a vendor SDK and replace it.
|
|
260
|
+
*
|
|
261
|
+
* This exists because wrapping our OWN `!response.ok` throws is not enough. The
|
|
262
|
+
* Anthropic, OpenAI and ollama clients each build their error message FROM the
|
|
263
|
+
* response body, so a credential the upstream echoed back is already inside
|
|
264
|
+
* `err.message` before our code sees it. Proven with a planted fake token on all
|
|
265
|
+
* three.
|
|
266
|
+
*
|
|
267
|
+
* So the vendor error is read for its status and scanned for an overflow
|
|
268
|
+
* signature, and then **dropped entirely** — not re-thrown, not wrapped, and not
|
|
269
|
+
* attached as `cause`. A `cause` is exactly what a structured logger walks, so
|
|
270
|
+
* keeping one for debuggability would reintroduce the leak it is meant to close.
|
|
271
|
+
*
|
|
272
|
+
* `name` is checked too, because AWS models its failures as distinct classes
|
|
273
|
+
* (`ThrottlingException`, `ValidationException`, `AccessDeniedException`) rather
|
|
274
|
+
* than as status codes.
|
|
275
|
+
*/
|
|
276
|
+
export function providerVendorError(input: {
|
|
277
|
+
readonly providerId: string
|
|
278
|
+
readonly error: unknown
|
|
279
|
+
readonly retryAfter?: string | null
|
|
280
|
+
readonly now?: number
|
|
281
|
+
}): ProviderRequestError {
|
|
282
|
+
const { error } = input
|
|
283
|
+
const status = vendorErrorStatus(error)
|
|
284
|
+
const name = error instanceof Error ? error.name : ''
|
|
285
|
+
const message = error instanceof Error ? error.message : ''
|
|
286
|
+
|
|
287
|
+
let kind: ProviderErrorKind
|
|
288
|
+
if (/ThrottlingException|TooManyRequests|ServiceQuotaExceeded/i.test(name)) {
|
|
289
|
+
kind = 'throttle'
|
|
290
|
+
} else if (/AccessDenied|Unauthorized|Forbidden|Authentication/i.test(name)) {
|
|
291
|
+
kind = 'auth'
|
|
292
|
+
} else if (/ValidationException/i.test(name)) {
|
|
293
|
+
kind = bodySaysContextOverflow(message) ? 'context_overflow' : 'bad_request'
|
|
294
|
+
} else if (
|
|
295
|
+
/ServiceUnavailable|InternalServer|ModelTimeout|ModelStreamError|ModelError|ModelNotReady/i.test(
|
|
296
|
+
name,
|
|
297
|
+
)
|
|
298
|
+
) {
|
|
299
|
+
kind = 'server'
|
|
300
|
+
} else if (status !== undefined) {
|
|
301
|
+
kind = classifyProviderHttpStatus(status, message)
|
|
302
|
+
} else if (bodySaysContextOverflow(message)) {
|
|
303
|
+
kind = 'context_overflow'
|
|
304
|
+
} else {
|
|
305
|
+
// No status: a mid-stream failure, or a transport error. The vendor's own
|
|
306
|
+
// error-type vocabulary is the only signal left; absent that, the request
|
|
307
|
+
// genuinely did not get an answer.
|
|
308
|
+
kind = vendorTypeKind(message) ?? 'network'
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const retryAfterMs = parseRetryAfterMs(
|
|
312
|
+
input.retryAfter ?? vendorRetryAfter(error),
|
|
313
|
+
input.now ?? Date.now(),
|
|
314
|
+
)
|
|
315
|
+
return new ProviderRequestError({
|
|
316
|
+
kind,
|
|
317
|
+
providerId: input.providerId,
|
|
318
|
+
...(status !== undefined ? { status } : {}),
|
|
319
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
320
|
+
})
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Build a classified error from a failed HTTP response.
|
|
325
|
+
*
|
|
326
|
+
* Callers pass the body they already read for classification; this function
|
|
327
|
+
* does not return it, store it, or put it in the message.
|
|
328
|
+
*/
|
|
329
|
+
export function providerHttpError(input: {
|
|
330
|
+
readonly providerId: string
|
|
331
|
+
readonly status: number
|
|
332
|
+
readonly body?: string | null
|
|
333
|
+
readonly retryAfter?: string | null
|
|
334
|
+
readonly now?: number
|
|
335
|
+
}): ProviderRequestError {
|
|
336
|
+
const kind = classifyProviderHttpStatus(input.status, input.body)
|
|
337
|
+
const retryAfterMs = parseRetryAfterMs(input.retryAfter, input.now ?? Date.now())
|
|
338
|
+
return new ProviderRequestError({
|
|
339
|
+
kind,
|
|
340
|
+
providerId: input.providerId,
|
|
341
|
+
status: input.status,
|
|
342
|
+
...(retryAfterMs !== undefined ? { retryAfterMs } : {}),
|
|
343
|
+
})
|
|
344
|
+
}
|
package/src/provider/index.ts
CHANGED
|
@@ -1,4 +1,25 @@
|
|
|
1
|
-
export {
|
|
1
|
+
export {
|
|
2
|
+
ProviderRequestError,
|
|
3
|
+
isCallerAbortError,
|
|
4
|
+
isProviderRequestError,
|
|
5
|
+
classifyProviderHttpStatus,
|
|
6
|
+
bodySaysContextOverflow,
|
|
7
|
+
parseRetryAfterMs,
|
|
8
|
+
providerHttpError,
|
|
9
|
+
providerVendorError,
|
|
10
|
+
} from './errors.js'
|
|
11
|
+
export type {
|
|
12
|
+
ProviderErrorInfo,
|
|
13
|
+
ProviderErrorKind,
|
|
14
|
+
ProviderRequestErrorInit,
|
|
15
|
+
} from './errors.js'
|
|
16
|
+
export {
|
|
17
|
+
ProviderRegistry,
|
|
18
|
+
UnknownProviderError,
|
|
19
|
+
DuplicateProviderError,
|
|
20
|
+
LazyProviderLoadError,
|
|
21
|
+
LazyProviderSyncCreateError,
|
|
22
|
+
} from './registry.js'
|
|
2
23
|
export { MockLLMProvider } from './mock.js'
|
|
3
24
|
export { registerMock, MOCK_CAPABILITIES } from './mock-register.js'
|
|
4
25
|
export {
|
package/src/provider/registry.ts
CHANGED
|
@@ -1,13 +1,17 @@
|
|
|
1
1
|
import type {
|
|
2
2
|
LLMProvider,
|
|
3
3
|
LLMProviderConstructor,
|
|
4
|
+
LazyProviderLoader,
|
|
5
|
+
LazyProviderModule,
|
|
4
6
|
ProviderCapabilities,
|
|
5
7
|
ProviderConfigRegistry,
|
|
6
8
|
ProviderFactoryConfig,
|
|
7
9
|
ProviderFactoryResult,
|
|
8
10
|
ProviderType,
|
|
11
|
+
RegisterLazyOptions,
|
|
9
12
|
RegisterOptions,
|
|
10
13
|
} from '../types/provider/index.js'
|
|
14
|
+
import { PERMISSIVE_PROVIDER_CAPABILITIES } from './capabilities.js'
|
|
11
15
|
|
|
12
16
|
export class UnknownProviderError extends Error {
|
|
13
17
|
readonly providerType: string
|
|
@@ -31,9 +35,99 @@ export class DuplicateProviderError extends Error {
|
|
|
31
35
|
}
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The loader passed to `ProviderRegistry.registerLazy()` rejected (or
|
|
40
|
+
* resolved to something without a `create(config)` function). Wraps the
|
|
41
|
+
* original failure as `cause`. The failed load is NOT cached — the next
|
|
42
|
+
* `createAsync()` for the type re-invokes the loader, so a transient
|
|
43
|
+
* failure (network hiccup during a dynamic import) does not permanently
|
|
44
|
+
* poison the type.
|
|
45
|
+
*/
|
|
46
|
+
export class LazyProviderLoadError extends Error {
|
|
47
|
+
readonly providerType: string
|
|
48
|
+
|
|
49
|
+
constructor(providerType: string, cause: unknown) {
|
|
50
|
+
const detail = cause instanceof Error ? cause.message : String(cause)
|
|
51
|
+
super(`Failed to load lazy provider "${providerType}": ${detail}`, { cause })
|
|
52
|
+
this.name = 'LazyProviderLoadError'
|
|
53
|
+
this.providerType = providerType
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* A synchronous `create()`/`createProvider()` was called for a type
|
|
59
|
+
* registered via `registerLazy()`. Lazy types are only constructible
|
|
60
|
+
* through the async path — deterministically, even after the loader has
|
|
61
|
+
* resolved, so calling code never depends on load-order timing.
|
|
62
|
+
*/
|
|
63
|
+
export class LazyProviderSyncCreateError extends Error {
|
|
64
|
+
readonly providerType: string
|
|
65
|
+
|
|
66
|
+
constructor(providerType: string) {
|
|
67
|
+
super(
|
|
68
|
+
`Provider type "${providerType}" is registered lazily; synchronous create()/createProvider() cannot load it. Use ProviderRegistry.createAsync() or createProviderAsync().`,
|
|
69
|
+
)
|
|
70
|
+
this.name = 'LazyProviderSyncCreateError'
|
|
71
|
+
this.providerType = providerType
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface LazyProviderEntry {
|
|
76
|
+
loader: LazyProviderLoader<unknown>
|
|
77
|
+
/** In-flight load shared by concurrent first-creates (dedupe). */
|
|
78
|
+
loading?: Promise<LazyProviderModule<unknown>>
|
|
79
|
+
/** Cached module — set only on SUCCESS, so failures retry. */
|
|
80
|
+
module?: LazyProviderModule<unknown>
|
|
81
|
+
}
|
|
82
|
+
|
|
34
83
|
// Module-private state. Only the exported functions below can read/mutate.
|
|
35
84
|
const providers = new Map<string, LLMProviderConstructor<unknown>>()
|
|
36
85
|
const capabilities = new Map<string, ProviderCapabilities>()
|
|
86
|
+
const lazyProviders = new Map<string, LazyProviderEntry>()
|
|
87
|
+
|
|
88
|
+
async function loadLazyModule(
|
|
89
|
+
type: string,
|
|
90
|
+
entry: LazyProviderEntry,
|
|
91
|
+
): Promise<LazyProviderModule<unknown>> {
|
|
92
|
+
if (entry.module) {
|
|
93
|
+
return entry.module
|
|
94
|
+
}
|
|
95
|
+
let inflight = entry.loading
|
|
96
|
+
if (!inflight) {
|
|
97
|
+
// Promise.resolve().then(...) also catches loaders that throw synchronously.
|
|
98
|
+
inflight = Promise.resolve()
|
|
99
|
+
.then(() => entry.loader())
|
|
100
|
+
.then((mod) => {
|
|
101
|
+
if (typeof mod?.create !== 'function') {
|
|
102
|
+
throw new TypeError(
|
|
103
|
+
'loader resolved to a value without a create(config) function — map your dynamic import to { create: (config) => new Provider(config) }',
|
|
104
|
+
)
|
|
105
|
+
}
|
|
106
|
+
return mod
|
|
107
|
+
})
|
|
108
|
+
entry.loading = inflight
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
const mod = await inflight
|
|
112
|
+
entry.module = mod
|
|
113
|
+
if (entry.loading === inflight) {
|
|
114
|
+
entry.loading = undefined
|
|
115
|
+
}
|
|
116
|
+
// The loaded module's declaration is authoritative at the type level:
|
|
117
|
+
// it replaces any registration-time hint for future getCapabilities().
|
|
118
|
+
if (mod.capabilities) {
|
|
119
|
+
capabilities.set(type, mod.capabilities)
|
|
120
|
+
}
|
|
121
|
+
return mod
|
|
122
|
+
} catch (cause) {
|
|
123
|
+
// Clear only OUR in-flight promise; a retry started by another caller
|
|
124
|
+
// after an earlier failure must not be dropped.
|
|
125
|
+
if (entry.loading === inflight) {
|
|
126
|
+
entry.loading = undefined
|
|
127
|
+
}
|
|
128
|
+
throw new LazyProviderLoadError(type, cause)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
37
131
|
|
|
38
132
|
/**
|
|
39
133
|
* Central registry for LLM providers.
|
|
@@ -56,6 +150,23 @@ const capabilities = new Map<string, ProviderCapabilities>()
|
|
|
56
150
|
* region: 'us-east-1',
|
|
57
151
|
* })
|
|
58
152
|
* ```
|
|
153
|
+
*
|
|
154
|
+
* Hosts that must not eagerly bundle every provider client register a
|
|
155
|
+
* LOADER instead and construct through the async path:
|
|
156
|
+
*
|
|
157
|
+
* @example
|
|
158
|
+
* ```ts
|
|
159
|
+
* ProviderRegistry.registerLazy(
|
|
160
|
+
* 'anthropic',
|
|
161
|
+
* async () => {
|
|
162
|
+
* const m = await import('@namzu/anthropic')
|
|
163
|
+
* return { create: (c) => new m.AnthropicProvider(c), capabilities: m.ANTHROPIC_CAPABILITIES }
|
|
164
|
+
* },
|
|
165
|
+
* { capabilities: { supportsTools: true, supportsStreaming: true, supportsFunctionCalling: true } },
|
|
166
|
+
* )
|
|
167
|
+
*
|
|
168
|
+
* const { provider } = await ProviderRegistry.createAsync({ type: 'anthropic', apiKey })
|
|
169
|
+
* ```
|
|
59
170
|
*/
|
|
60
171
|
export class ProviderRegistry {
|
|
61
172
|
static register<K extends ProviderType>(
|
|
@@ -64,46 +175,129 @@ export class ProviderRegistry {
|
|
|
64
175
|
caps: ProviderCapabilities,
|
|
65
176
|
options?: RegisterOptions,
|
|
66
177
|
): void {
|
|
67
|
-
if (providers.has(type) && !options?.replace) {
|
|
178
|
+
if ((providers.has(type) || lazyProviders.has(type)) && !options?.replace) {
|
|
68
179
|
throw new DuplicateProviderError(type)
|
|
69
180
|
}
|
|
181
|
+
lazyProviders.delete(type)
|
|
70
182
|
providers.set(type, ctor as LLMProviderConstructor<unknown>)
|
|
71
183
|
capabilities.set(type, caps)
|
|
72
184
|
}
|
|
73
185
|
|
|
186
|
+
/**
|
|
187
|
+
* Register a provider type WITHOUT importing its implementation. The
|
|
188
|
+
* loader is not invoked here; the first `createAsync()` for the type
|
|
189
|
+
* awaits it, validates the resolved `{ create }` module, and caches it.
|
|
190
|
+
* Subsequent creates reuse the cached factory. Only SUCCESS is cached:
|
|
191
|
+
* a rejected load surfaces as `LazyProviderLoadError` and the next
|
|
192
|
+
* `createAsync()` retries the loader. Concurrent first-creates share a
|
|
193
|
+
* single in-flight load.
|
|
194
|
+
*
|
|
195
|
+
* Capability precedence (weakest first):
|
|
196
|
+
* 1. `options.capabilities` — pre-load HINT so `getCapabilities(type)`
|
|
197
|
+
* answers without loading (absent hint ⇒ permissive default, matching
|
|
198
|
+
* `resolveProviderCapabilities`'s treatment of undeclared providers).
|
|
199
|
+
* 2. the loaded module's `capabilities` — replaces the hint on load.
|
|
200
|
+
* 3. the constructed instance's own `LLMProvider.capabilities` — the
|
|
201
|
+
* query runtime negotiates against the INSTANCE
|
|
202
|
+
* (`resolveProviderCapabilities(provider)`), so if it differs from
|
|
203
|
+
* both of the above, the instance wins where it matters.
|
|
204
|
+
*
|
|
205
|
+
* Lazy types are deliberately NOT constructible via the sync
|
|
206
|
+
* `create()`/`createProvider()` (throws `LazyProviderSyncCreateError`),
|
|
207
|
+
* even after the loader has resolved — sync behavior must not depend on
|
|
208
|
+
* whether some earlier call happened to load the module.
|
|
209
|
+
*/
|
|
210
|
+
static registerLazy<K extends ProviderType>(
|
|
211
|
+
type: K,
|
|
212
|
+
loader: LazyProviderLoader<ProviderConfigRegistry[K]>,
|
|
213
|
+
options?: RegisterLazyOptions,
|
|
214
|
+
): void {
|
|
215
|
+
if ((providers.has(type) || lazyProviders.has(type)) && !options?.replace) {
|
|
216
|
+
throw new DuplicateProviderError(type)
|
|
217
|
+
}
|
|
218
|
+
providers.delete(type)
|
|
219
|
+
capabilities.delete(type)
|
|
220
|
+
lazyProviders.set(type, { loader: loader as LazyProviderLoader<unknown> })
|
|
221
|
+
if (options?.capabilities) {
|
|
222
|
+
capabilities.set(type, options.capabilities)
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
74
226
|
static create(config: ProviderFactoryConfig): ProviderFactoryResult {
|
|
75
227
|
const provider = ProviderRegistry.createProvider(config)
|
|
76
228
|
const caps = ProviderRegistry.getCapabilities(config.type)
|
|
77
229
|
return { provider, capabilities: caps }
|
|
78
230
|
}
|
|
79
231
|
|
|
232
|
+
/**
|
|
233
|
+
* Async twin of `create()`. Works for BOTH eager and lazy registrations,
|
|
234
|
+
* so hosts can use one code path; for lazy types it performs the
|
|
235
|
+
* load-on-first-use described on `registerLazy()`.
|
|
236
|
+
*/
|
|
237
|
+
static async createAsync(config: ProviderFactoryConfig): Promise<ProviderFactoryResult> {
|
|
238
|
+
const provider = await ProviderRegistry.createProviderAsync(config)
|
|
239
|
+
// Read capabilities AFTER construction so a lazily-loaded module's
|
|
240
|
+
// authoritative declaration (set during load) is what gets returned.
|
|
241
|
+
const caps = ProviderRegistry.getCapabilities(config.type)
|
|
242
|
+
return { provider, capabilities: caps }
|
|
243
|
+
}
|
|
244
|
+
|
|
80
245
|
static createProvider(config: ProviderFactoryConfig): LLMProvider {
|
|
81
246
|
const Ctor = providers.get(config.type)
|
|
82
247
|
if (!Ctor) {
|
|
248
|
+
if (lazyProviders.has(config.type)) {
|
|
249
|
+
throw new LazyProviderSyncCreateError(config.type)
|
|
250
|
+
}
|
|
83
251
|
throw new UnknownProviderError(config.type)
|
|
84
252
|
}
|
|
85
253
|
return new Ctor(config)
|
|
86
254
|
}
|
|
87
255
|
|
|
256
|
+
static async createProviderAsync(config: ProviderFactoryConfig): Promise<LLMProvider> {
|
|
257
|
+
const Ctor = providers.get(config.type)
|
|
258
|
+
if (Ctor) {
|
|
259
|
+
return new Ctor(config)
|
|
260
|
+
}
|
|
261
|
+
const entry = lazyProviders.get(config.type)
|
|
262
|
+
if (!entry) {
|
|
263
|
+
throw new UnknownProviderError(config.type)
|
|
264
|
+
}
|
|
265
|
+
const mod = await loadLazyModule(config.type, entry)
|
|
266
|
+
return mod.create(config)
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Type-level capabilities. For a lazily-registered type this answers
|
|
271
|
+
* WITHOUT invoking the loader: the registration hint if one was given,
|
|
272
|
+
* otherwise the permissive default (assume everything — consistent with
|
|
273
|
+
* how `resolveProviderCapabilities` treats an undeclared provider). Once
|
|
274
|
+
* loaded, a module-shipped declaration replaces the hint. Note the query
|
|
275
|
+
* runtime negotiates against the constructed INSTANCE's own
|
|
276
|
+
* `capabilities`, which wins over anything stored here.
|
|
277
|
+
*/
|
|
88
278
|
static getCapabilities(type: string): ProviderCapabilities {
|
|
89
279
|
const caps = capabilities.get(type)
|
|
90
|
-
if (
|
|
91
|
-
|
|
280
|
+
if (caps) {
|
|
281
|
+
return caps
|
|
282
|
+
}
|
|
283
|
+
if (lazyProviders.has(type)) {
|
|
284
|
+
return PERMISSIVE_PROVIDER_CAPABILITIES
|
|
92
285
|
}
|
|
93
|
-
|
|
286
|
+
throw new UnknownProviderError(type)
|
|
94
287
|
}
|
|
95
288
|
|
|
96
289
|
static isSupported(type: string): type is ProviderType {
|
|
97
|
-
return providers.has(type)
|
|
290
|
+
return providers.has(type) || lazyProviders.has(type)
|
|
98
291
|
}
|
|
99
292
|
|
|
100
293
|
static unregister(type: ProviderType): boolean {
|
|
101
294
|
capabilities.delete(type)
|
|
102
|
-
|
|
295
|
+
const hadLazy = lazyProviders.delete(type)
|
|
296
|
+
return providers.delete(type) || hadLazy
|
|
103
297
|
}
|
|
104
298
|
|
|
105
299
|
static listTypes(): ProviderType[] {
|
|
106
|
-
return Array.from(providers.keys()) as ProviderType[]
|
|
300
|
+
return Array.from(new Set([...providers.keys(), ...lazyProviders.keys()])) as ProviderType[]
|
|
107
301
|
}
|
|
108
302
|
}
|
|
109
303
|
|
|
@@ -115,4 +309,5 @@ export class ProviderRegistry {
|
|
|
115
309
|
export function __resetProviderRegistryInternal(): void {
|
|
116
310
|
providers.clear()
|
|
117
311
|
capabilities.clear()
|
|
312
|
+
lazyProviders.clear()
|
|
118
313
|
}
|