@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.
Files changed (127) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts +12 -0
  3. package/dist/compaction/__tests__/verifier-empty-reply.test.d.ts.map +1 -0
  4. package/dist/compaction/__tests__/verifier-empty-reply.test.js +73 -0
  5. package/dist/compaction/__tests__/verifier-empty-reply.test.js.map +1 -0
  6. package/dist/compaction/verifier.d.ts.map +1 -1
  7. package/dist/compaction/verifier.js +10 -1
  8. package/dist/compaction/verifier.js.map +1 -1
  9. package/dist/config/__tests__/compaction-budget-schema.test.d.ts +18 -0
  10. package/dist/config/__tests__/compaction-budget-schema.test.d.ts.map +1 -0
  11. package/dist/config/__tests__/compaction-budget-schema.test.js +96 -0
  12. package/dist/config/__tests__/compaction-budget-schema.test.js.map +1 -0
  13. package/dist/config/runtime.d.ts.map +1 -1
  14. package/dist/config/runtime.js +20 -11
  15. package/dist/config/runtime.js.map +1 -1
  16. package/dist/manager/run/persistence.d.ts +2 -1
  17. package/dist/manager/run/persistence.d.ts.map +1 -1
  18. package/dist/manager/run/persistence.js +3 -1
  19. package/dist/manager/run/persistence.js.map +1 -1
  20. package/dist/provider/__tests__/errors.test.d.ts +2 -0
  21. package/dist/provider/__tests__/errors.test.d.ts.map +1 -0
  22. package/dist/provider/__tests__/errors.test.js +61 -0
  23. package/dist/provider/__tests__/errors.test.js.map +1 -0
  24. package/dist/provider/__tests__/registry.test.d.ts +5 -0
  25. package/dist/provider/__tests__/registry.test.d.ts.map +1 -1
  26. package/dist/provider/__tests__/registry.test.js +186 -1
  27. package/dist/provider/__tests__/registry.test.js.map +1 -1
  28. package/dist/provider/errors.d.ts +108 -0
  29. package/dist/provider/errors.d.ts.map +1 -0
  30. package/dist/provider/errors.js +311 -0
  31. package/dist/provider/errors.js.map +1 -0
  32. package/dist/provider/index.d.ts +3 -1
  33. package/dist/provider/index.d.ts.map +1 -1
  34. package/dist/provider/index.js +2 -1
  35. package/dist/provider/index.js.map +1 -1
  36. package/dist/provider/registry.d.ts +81 -1
  37. package/dist/provider/registry.d.ts.map +1 -1
  38. package/dist/provider/registry.js +173 -7
  39. package/dist/provider/registry.js.map +1 -1
  40. package/dist/public-runtime.d.ts +11 -11
  41. package/dist/public-runtime.d.ts.map +1 -1
  42. package/dist/public-runtime.js +11 -11
  43. package/dist/public-runtime.js.map +1 -1
  44. package/dist/registry/tool/execute.d.ts.map +1 -1
  45. package/dist/registry/tool/execute.js +5 -2
  46. package/dist/registry/tool/execute.js.map +1 -1
  47. package/dist/registry/tool/execute.test.js +11 -0
  48. package/dist/registry/tool/execute.test.js.map +1 -1
  49. package/dist/runtime/query/__tests__/stream-recovery.test.js +54 -1
  50. package/dist/runtime/query/__tests__/stream-recovery.test.js.map +1 -1
  51. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts +32 -0
  52. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.d.ts.map +1 -0
  53. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js +152 -0
  54. package/dist/runtime/query/iteration/phases/compaction-safe-cut.test.js.map +1 -0
  55. package/dist/runtime/query/iteration/phases/compaction.d.ts.map +1 -1
  56. package/dist/runtime/query/iteration/phases/compaction.js +52 -12
  57. package/dist/runtime/query/iteration/phases/compaction.js.map +1 -1
  58. package/dist/runtime/query/iteration/stream-turn.d.ts.map +1 -1
  59. package/dist/runtime/query/iteration/stream-turn.js +11 -5
  60. package/dist/runtime/query/iteration/stream-turn.js.map +1 -1
  61. package/dist/runtime/query/result.d.ts.map +1 -1
  62. package/dist/runtime/query/result.js +11 -1
  63. package/dist/runtime/query/result.js.map +1 -1
  64. package/dist/tools/builtins/__tests__/edit.test.js +54 -0
  65. package/dist/tools/builtins/__tests__/edit.test.js.map +1 -1
  66. package/dist/tools/builtins/edit.d.ts +1 -1
  67. package/dist/tools/builtins/edit.d.ts.map +1 -1
  68. package/dist/tools/builtins/edit.js +11 -13
  69. package/dist/tools/builtins/edit.js.map +1 -1
  70. package/dist/tools/coordinator/__tests__/task-list.test.js +14 -0
  71. package/dist/tools/coordinator/__tests__/task-list.test.js.map +1 -1
  72. package/dist/tools/coordinator/agent.d.ts +6 -8
  73. package/dist/tools/coordinator/agent.d.ts.map +1 -1
  74. package/dist/tools/coordinator/agent.js.map +1 -1
  75. package/dist/tools/coordinator/index.d.ts.map +1 -1
  76. package/dist/tools/coordinator/index.js +11 -32
  77. package/dist/tools/coordinator/index.js.map +1 -1
  78. package/dist/tools/defineTool.d.ts +1 -0
  79. package/dist/tools/defineTool.d.ts.map +1 -1
  80. package/dist/tools/defineTool.js +1 -0
  81. package/dist/tools/defineTool.js.map +1 -1
  82. package/dist/types/provider/config.d.ts +33 -0
  83. package/dist/types/provider/config.d.ts.map +1 -1
  84. package/dist/types/provider/error.d.ts +22 -0
  85. package/dist/types/provider/error.d.ts.map +1 -0
  86. package/dist/types/provider/error.js +2 -0
  87. package/dist/types/provider/error.js.map +1 -0
  88. package/dist/types/provider/index.d.ts +2 -1
  89. package/dist/types/provider/index.d.ts.map +1 -1
  90. package/dist/types/run/entity.d.ts +2 -0
  91. package/dist/types/run/entity.d.ts.map +1 -1
  92. package/dist/types/run/events.d.ts +2 -0
  93. package/dist/types/run/events.d.ts.map +1 -1
  94. package/dist/types/run/events.js.map +1 -1
  95. package/dist/types/tool/index.d.ts +6 -0
  96. package/dist/types/tool/index.d.ts.map +1 -1
  97. package/package.json +1 -1
  98. package/src/compaction/__tests__/verifier-empty-reply.test.ts +96 -0
  99. package/src/compaction/verifier.ts +10 -1
  100. package/src/config/__tests__/compaction-budget-schema.test.ts +114 -0
  101. package/src/config/runtime.ts +20 -11
  102. package/src/manager/run/persistence.ts +3 -1
  103. package/src/provider/__tests__/errors.test.ts +85 -0
  104. package/src/provider/__tests__/registry.test.ts +266 -1
  105. package/src/provider/errors.ts +344 -0
  106. package/src/provider/index.ts +22 -1
  107. package/src/provider/registry.ts +202 -7
  108. package/src/public-runtime.ts +55 -10
  109. package/src/registry/tool/execute.test.ts +14 -0
  110. package/src/registry/tool/execute.ts +5 -2
  111. package/src/runtime/query/__tests__/stream-recovery.test.ts +64 -1
  112. package/src/runtime/query/iteration/phases/compaction-safe-cut.test.ts +186 -0
  113. package/src/runtime/query/iteration/phases/compaction.ts +54 -12
  114. package/src/runtime/query/iteration/stream-turn.ts +11 -6
  115. package/src/runtime/query/result.ts +11 -1
  116. package/src/tools/builtins/__tests__/edit.test.ts +76 -0
  117. package/src/tools/builtins/edit.ts +12 -12
  118. package/src/tools/coordinator/__tests__/task-list.test.ts +16 -0
  119. package/src/tools/coordinator/agent.ts +6 -8
  120. package/src/tools/coordinator/index.ts +11 -34
  121. package/src/tools/defineTool.ts +2 -0
  122. package/src/types/provider/config.ts +36 -0
  123. package/src/types/provider/error.ts +29 -0
  124. package/src/types/provider/index.ts +8 -0
  125. package/src/types/run/entity.ts +2 -0
  126. package/src/types/run/events.ts +7 -1
  127. 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
+ }
@@ -1,4 +1,25 @@
1
- export { ProviderRegistry, UnknownProviderError, DuplicateProviderError } from './registry.js'
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 {
@@ -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 (!caps) {
91
- throw new UnknownProviderError(type)
280
+ if (caps) {
281
+ return caps
282
+ }
283
+ if (lazyProviders.has(type)) {
284
+ return PERMISSIVE_PROVIDER_CAPABILITIES
92
285
  }
93
- return caps
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
- return providers.delete(type)
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
  }