@namzu/sdk 1.2.0 → 1.3.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.
@@ -1,9 +1,16 @@
1
1
  import { beforeEach, describe, expect, it } from 'vitest'
2
- import type { LLMProvider, ProviderCapabilities } from '../../types/provider/index.js'
2
+ import type {
3
+ LLMProvider,
4
+ LazyProviderModule,
5
+ ProviderCapabilities,
6
+ } from '../../types/provider/index.js'
7
+ import { PERMISSIVE_PROVIDER_CAPABILITIES, resolveProviderCapabilities } from '../capabilities.js'
3
8
  import { registerMock } from '../mock-register.js'
4
9
  import { MockLLMProvider } from '../mock.js'
5
10
  import {
6
11
  DuplicateProviderError,
12
+ LazyProviderLoadError,
13
+ LazyProviderSyncCreateError,
7
14
  ProviderRegistry,
8
15
  UnknownProviderError,
9
16
  __resetProviderRegistryInternal,
@@ -16,9 +23,15 @@ interface TestProviderConfig {
16
23
  value?: string
17
24
  }
18
25
 
26
+ interface LazyTestProviderConfig {
27
+ type: 'lazytest'
28
+ value?: string
29
+ }
30
+
19
31
  declare module '../../types/provider/config.js' {
20
32
  interface ProviderConfigRegistry {
21
33
  test: TestProviderConfig
34
+ lazytest: LazyTestProviderConfig
22
35
  }
23
36
  }
24
37
 
@@ -52,6 +65,41 @@ const TEST_CAPS: ProviderCapabilities = {
52
65
  supportsFunctionCalling: false,
53
66
  }
54
67
 
68
+ class LazyTestProvider implements LLMProvider {
69
+ readonly id = 'lazytest'
70
+ readonly name = 'Lazy Test'
71
+ constructor(
72
+ public readonly config: LazyTestProviderConfig,
73
+ readonly capabilities?: ProviderCapabilities,
74
+ ) {}
75
+ async *chatStream() {
76
+ yield { id: 'x', delta: { content: 'ok' } }
77
+ }
78
+ }
79
+
80
+ const HINT_CAPS: ProviderCapabilities = {
81
+ supportsTools: false,
82
+ supportsStreaming: false,
83
+ supportsFunctionCalling: false,
84
+ }
85
+
86
+ const MODULE_CAPS: ProviderCapabilities = {
87
+ supportsTools: true,
88
+ supportsStreaming: true,
89
+ supportsFunctionCalling: true,
90
+ supportsVision: false,
91
+ }
92
+
93
+ function deferred<T>() {
94
+ let resolve!: (value: T) => void
95
+ let reject!: (reason?: unknown) => void
96
+ const promise = new Promise<T>((res, rej) => {
97
+ resolve = res
98
+ reject = rej
99
+ })
100
+ return { promise, resolve, reject }
101
+ }
102
+
55
103
  describe('ProviderRegistry', () => {
56
104
  beforeEach(() => {
57
105
  __resetProviderRegistryInternal()
@@ -152,4 +200,221 @@ describe('ProviderRegistry', () => {
152
200
  expect(err.name).toBe('DuplicateProviderError')
153
201
  })
154
202
  })
203
+
204
+ describe('registerLazy', () => {
205
+ it('does not invoke the loader at registration time', () => {
206
+ let calls = 0
207
+ ProviderRegistry.registerLazy('lazytest', async () => {
208
+ calls++
209
+ return { create: (config) => new LazyTestProvider(config) }
210
+ })
211
+
212
+ expect(calls).toBe(0)
213
+ expect(ProviderRegistry.isSupported('lazytest')).toBe(true)
214
+ expect(ProviderRegistry.listTypes()).toContain('lazytest')
215
+ })
216
+
217
+ it('throws DuplicateProviderError over an existing eager registration', () => {
218
+ ProviderRegistry.register('test', TestProvider, TEST_CAPS)
219
+ expect(() =>
220
+ ProviderRegistry.registerLazy('test', async () => ({
221
+ create: (config) => new TestProvider(config),
222
+ })),
223
+ ).toThrowError(DuplicateProviderError)
224
+ })
225
+
226
+ it('throws DuplicateProviderError over an existing lazy registration', () => {
227
+ const loader = async (): Promise<LazyProviderModule<LazyTestProviderConfig>> => ({
228
+ create: (config) => new LazyTestProvider(config),
229
+ })
230
+ ProviderRegistry.registerLazy('lazytest', loader)
231
+ expect(() => ProviderRegistry.registerLazy('lazytest', loader)).toThrowError(
232
+ DuplicateProviderError,
233
+ )
234
+ })
235
+
236
+ it('replace: true swaps between eager and lazy registrations', async () => {
237
+ ProviderRegistry.register('lazytest', LazyTestProvider, TEST_CAPS)
238
+ ProviderRegistry.registerLazy(
239
+ 'lazytest',
240
+ async () => ({ create: (config) => new LazyTestProvider(config) }),
241
+ { replace: true },
242
+ )
243
+ expect(() => ProviderRegistry.createProvider({ type: 'lazytest' })).toThrowError(
244
+ LazyProviderSyncCreateError,
245
+ )
246
+
247
+ ProviderRegistry.register('lazytest', LazyTestProvider, TEST_CAPS, { replace: true })
248
+ expect(ProviderRegistry.createProvider({ type: 'lazytest' })).toBeInstanceOf(LazyTestProvider)
249
+ })
250
+
251
+ it('sync create()/createProvider() always throws for lazy types, even after load', async () => {
252
+ ProviderRegistry.registerLazy('lazytest', async () => ({
253
+ create: (config) => new LazyTestProvider(config),
254
+ }))
255
+
256
+ expect(() => ProviderRegistry.create({ type: 'lazytest' })).toThrowError(
257
+ LazyProviderSyncCreateError,
258
+ )
259
+
260
+ await ProviderRegistry.createAsync({ type: 'lazytest' })
261
+ expect(() => ProviderRegistry.createProvider({ type: 'lazytest' })).toThrowError(
262
+ LazyProviderSyncCreateError,
263
+ )
264
+ })
265
+
266
+ it('unregister removes a lazy registration', () => {
267
+ ProviderRegistry.registerLazy('lazytest', async () => ({
268
+ create: (config) => new LazyTestProvider(config),
269
+ }))
270
+ expect(ProviderRegistry.unregister('lazytest')).toBe(true)
271
+ expect(ProviderRegistry.isSupported('lazytest')).toBe(false)
272
+ })
273
+ })
274
+
275
+ describe('createAsync', () => {
276
+ it('first create invokes the loader once; subsequent creates reuse the cached factory', async () => {
277
+ let calls = 0
278
+ ProviderRegistry.registerLazy('lazytest', async () => {
279
+ calls++
280
+ return { create: (config) => new LazyTestProvider(config) }
281
+ })
282
+
283
+ const first = await ProviderRegistry.createAsync({ type: 'lazytest', value: 'a' })
284
+ const second = await ProviderRegistry.createAsync({ type: 'lazytest', value: 'b' })
285
+
286
+ expect(calls).toBe(1)
287
+ expect(first.provider).toBeInstanceOf(LazyTestProvider)
288
+ expect(second.provider).toBeInstanceOf(LazyTestProvider)
289
+ expect((second.provider as LazyTestProvider).config.value).toBe('b')
290
+ })
291
+
292
+ it('concurrent first-creates share a single loader invocation', async () => {
293
+ let calls = 0
294
+ const gate = deferred<void>()
295
+ ProviderRegistry.registerLazy('lazytest', async () => {
296
+ calls++
297
+ await gate.promise
298
+ return { create: (config) => new LazyTestProvider(config) }
299
+ })
300
+
301
+ const a = ProviderRegistry.createAsync({ type: 'lazytest' })
302
+ const b = ProviderRegistry.createAsync({ type: 'lazytest' })
303
+ gate.resolve()
304
+
305
+ const [ra, rb] = await Promise.all([a, b])
306
+ expect(calls).toBe(1)
307
+ expect(ra.provider).toBeInstanceOf(LazyTestProvider)
308
+ expect(rb.provider).toBeInstanceOf(LazyTestProvider)
309
+ })
310
+
311
+ it('loader rejection surfaces as LazyProviderLoadError and the next create retries', async () => {
312
+ let calls = 0
313
+ ProviderRegistry.registerLazy('lazytest', async () => {
314
+ calls++
315
+ if (calls === 1) {
316
+ throw new Error('transient network failure')
317
+ }
318
+ return { create: (config) => new LazyTestProvider(config) }
319
+ })
320
+
321
+ const failure = await ProviderRegistry.createAsync({ type: 'lazytest' }).catch(
322
+ (err: unknown) => err,
323
+ )
324
+ expect(failure).toBeInstanceOf(LazyProviderLoadError)
325
+ expect((failure as LazyProviderLoadError).providerType).toBe('lazytest')
326
+ expect(((failure as LazyProviderLoadError).cause as Error).message).toBe(
327
+ 'transient network failure',
328
+ )
329
+
330
+ const { provider } = await ProviderRegistry.createAsync({ type: 'lazytest' })
331
+ expect(calls).toBe(2)
332
+ expect(provider).toBeInstanceOf(LazyTestProvider)
333
+ })
334
+
335
+ it('rejects with LazyProviderLoadError when the loader resolves an invalid module shape', async () => {
336
+ ProviderRegistry.registerLazy(
337
+ 'lazytest',
338
+ async () => ({}) as unknown as LazyProviderModule<LazyTestProviderConfig>,
339
+ )
340
+ await expect(ProviderRegistry.createAsync({ type: 'lazytest' })).rejects.toThrowError(
341
+ LazyProviderLoadError,
342
+ )
343
+ })
344
+
345
+ it('works for eagerly registered types too', async () => {
346
+ const { provider, capabilities } = await ProviderRegistry.createAsync({
347
+ type: 'mock',
348
+ model: 'test-model',
349
+ })
350
+ expect(provider).toBeInstanceOf(MockLLMProvider)
351
+ expect(capabilities.supportsStreaming).toBe(true)
352
+ })
353
+
354
+ it('rejects with UnknownProviderError for unregistered types', async () => {
355
+ await expect(
356
+ ProviderRegistry.createAsync({ type: 'nonexistent' } as unknown as { type: 'mock' }),
357
+ ).rejects.toThrowError(UnknownProviderError)
358
+ })
359
+ })
360
+
361
+ describe('lazy capabilities', () => {
362
+ it('answers with the registration hint before load, without invoking the loader', () => {
363
+ let calls = 0
364
+ ProviderRegistry.registerLazy(
365
+ 'lazytest',
366
+ async () => {
367
+ calls++
368
+ return { create: (config) => new LazyTestProvider(config) }
369
+ },
370
+ { capabilities: HINT_CAPS },
371
+ )
372
+
373
+ expect(ProviderRegistry.getCapabilities('lazytest')).toEqual(HINT_CAPS)
374
+ expect(calls).toBe(0)
375
+ })
376
+
377
+ it('answers permissively before load when no hint was given', () => {
378
+ ProviderRegistry.registerLazy('lazytest', async () => ({
379
+ create: (config) => new LazyTestProvider(config),
380
+ }))
381
+ expect(ProviderRegistry.getCapabilities('lazytest')).toEqual(PERMISSIVE_PROVIDER_CAPABILITIES)
382
+ })
383
+
384
+ it('module capabilities replace the hint after load', async () => {
385
+ ProviderRegistry.registerLazy(
386
+ 'lazytest',
387
+ async () => ({
388
+ create: (config) => new LazyTestProvider(config),
389
+ capabilities: MODULE_CAPS,
390
+ }),
391
+ { capabilities: HINT_CAPS },
392
+ )
393
+
394
+ const { capabilities } = await ProviderRegistry.createAsync({ type: 'lazytest' })
395
+ expect(capabilities).toEqual(MODULE_CAPS)
396
+ expect(ProviderRegistry.getCapabilities('lazytest')).toEqual(MODULE_CAPS)
397
+ })
398
+
399
+ it('the constructed instance own capabilities win at run time over any hint', async () => {
400
+ const instanceCaps: ProviderCapabilities = {
401
+ supportsTools: true,
402
+ supportsStreaming: true,
403
+ supportsFunctionCalling: false,
404
+ supportsVision: false,
405
+ }
406
+ ProviderRegistry.registerLazy(
407
+ 'lazytest',
408
+ async () => ({ create: (config) => new LazyTestProvider(config, instanceCaps) }),
409
+ { capabilities: HINT_CAPS },
410
+ )
411
+
412
+ const { provider } = await ProviderRegistry.createAsync({ type: 'lazytest' })
413
+ // The query runtime negotiates against the instance, not the registry:
414
+ const resolved = resolveProviderCapabilities(provider)
415
+ expect(resolved.supportsTools).toBe(true)
416
+ expect(resolved.supportsFunctionCalling).toBe(false)
417
+ expect(resolved.supportsVision).toBe(false)
418
+ })
419
+ })
155
420
  })
@@ -1,4 +1,10 @@
1
- export { ProviderRegistry, UnknownProviderError, DuplicateProviderError } from './registry.js'
1
+ export {
2
+ ProviderRegistry,
3
+ UnknownProviderError,
4
+ DuplicateProviderError,
5
+ LazyProviderLoadError,
6
+ LazyProviderSyncCreateError,
7
+ } from './registry.js'
2
8
  export { MockLLMProvider } from './mock.js'
3
9
  export { registerMock, MOCK_CAPABILITIES } from './mock-register.js'
4
10
  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
  }
@@ -145,6 +145,8 @@ export { LocalTaskGateway } from './gateway/local.js'
145
145
 
146
146
  export {
147
147
  DuplicateProviderError,
148
+ LazyProviderLoadError,
149
+ LazyProviderSyncCreateError,
148
150
  MOCK_CAPABILITIES,
149
151
  MockLLMProvider,
150
152
  PERMISSIVE_PROVIDER_CAPABILITIES,
@@ -70,4 +70,40 @@ export interface RegisterOptions {
70
70
  replace?: boolean
71
71
  }
72
72
 
73
+ /**
74
+ * What a lazy loader must resolve to: a factory that constructs the
75
+ * provider, plus (optionally) the provider package's authoritative
76
+ * capability declaration resolved at load time.
77
+ *
78
+ * Designed so a host can map a dynamic import in one line:
79
+ *
80
+ * ```ts
81
+ * ProviderRegistry.registerLazy('anthropic', async () => {
82
+ * const m = await import('@namzu/anthropic')
83
+ * return { create: (c) => new m.AnthropicProvider(c), capabilities: m.ANTHROPIC_CAPABILITIES }
84
+ * })
85
+ * ```
86
+ */
87
+ export interface LazyProviderModule<C = unknown> {
88
+ create: (config: C) => LLMProvider
89
+ /**
90
+ * Authoritative type-level capabilities shipped by the loaded module.
91
+ * When present, replaces any registration-time hint in the registry.
92
+ */
93
+ capabilities?: ProviderCapabilities
94
+ }
95
+
96
+ export type LazyProviderLoader<C = unknown> = () => Promise<LazyProviderModule<C>>
97
+
98
+ export interface RegisterLazyOptions extends RegisterOptions {
99
+ /**
100
+ * Pre-load capability HINT so `ProviderRegistry.getCapabilities(type)`
101
+ * can answer without triggering the loader. Precedence (weakest first):
102
+ * this hint → the loaded module's `capabilities` → the constructed
103
+ * instance's own `LLMProvider.capabilities` (what the query runtime
104
+ * resolves via `resolveProviderCapabilities` and actually respects).
105
+ */
106
+ capabilities?: ProviderCapabilities
107
+ }
108
+
73
109
  export type LLMProviderConstructor<C = unknown> = new (config: C) => LLMProvider
@@ -16,5 +16,8 @@ export type {
16
16
  ProviderCapabilities,
17
17
  ProviderFactoryResult,
18
18
  RegisterOptions,
19
+ LazyProviderModule,
20
+ LazyProviderLoader,
21
+ RegisterLazyOptions,
19
22
  LLMProviderConstructor,
20
23
  } from './config.js'