openllmprovider 0.1.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 (91) hide show
  1. package/README.md +192 -0
  2. package/dist/auth/index.cjs +6 -0
  3. package/dist/auth/index.d.cts +3 -0
  4. package/dist/auth/index.d.mts +3 -0
  5. package/dist/auth/index.mjs +3 -0
  6. package/dist/auto-C2hXJY13.d.cts +33 -0
  7. package/dist/auto-C2hXJY13.d.cts.map +1 -0
  8. package/dist/auto-CBqNYBXs.mjs +48 -0
  9. package/dist/auto-CBqNYBXs.mjs.map +1 -0
  10. package/dist/auto-CInerwvs.d.mts +33 -0
  11. package/dist/auto-CInerwvs.d.mts.map +1 -0
  12. package/dist/auto-D77wgMqO.cjs +59 -0
  13. package/dist/auto-D77wgMqO.cjs.map +1 -0
  14. package/dist/file-DB-rxfzi.mjs +77 -0
  15. package/dist/file-DB-rxfzi.mjs.map +1 -0
  16. package/dist/file-DZ7FGcSW.cjs +73 -0
  17. package/dist/file-DZ7FGcSW.cjs.map +1 -0
  18. package/dist/index.cjs +1909 -0
  19. package/dist/index.cjs.map +1 -0
  20. package/dist/index.d.cts +1239 -0
  21. package/dist/index.d.cts.map +1 -0
  22. package/dist/index.d.mts +1241 -0
  23. package/dist/index.d.mts.map +1 -0
  24. package/dist/index.mjs +1891 -0
  25. package/dist/index.mjs.map +1 -0
  26. package/dist/logger-BsHpI_fH.mjs +11 -0
  27. package/dist/logger-BsHpI_fH.mjs.map +1 -0
  28. package/dist/logger-jRimlMFR.cjs +69 -0
  29. package/dist/logger-jRimlMFR.cjs.map +1 -0
  30. package/dist/plugin/index.cjs +29 -0
  31. package/dist/plugin/index.cjs.map +1 -0
  32. package/dist/plugin/index.d.cts +10 -0
  33. package/dist/plugin/index.d.cts.map +1 -0
  34. package/dist/plugin/index.d.mts +10 -0
  35. package/dist/plugin/index.d.mts.map +1 -0
  36. package/dist/plugin/index.mjs +25 -0
  37. package/dist/plugin/index.mjs.map +1 -0
  38. package/dist/plugin-BkeUu5LW.d.mts +46 -0
  39. package/dist/plugin-BkeUu5LW.d.mts.map +1 -0
  40. package/dist/plugin-wK7RmJhZ.d.cts +46 -0
  41. package/dist/plugin-wK7RmJhZ.d.cts.map +1 -0
  42. package/dist/resolver-BA7LWSJO.mjs +645 -0
  43. package/dist/resolver-BA7LWSJO.mjs.map +1 -0
  44. package/dist/resolver-BMTvzTt9.cjs +662 -0
  45. package/dist/resolver-BMTvzTt9.cjs.map +1 -0
  46. package/dist/resolver-MgJryMWG.d.cts +75 -0
  47. package/dist/resolver-MgJryMWG.d.cts.map +1 -0
  48. package/dist/resolver-_gfXzr_S.d.mts +76 -0
  49. package/dist/resolver-_gfXzr_S.d.mts.map +1 -0
  50. package/dist/storage/index.cjs +7 -0
  51. package/dist/storage/index.d.cts +12 -0
  52. package/dist/storage/index.d.cts.map +1 -0
  53. package/dist/storage/index.d.mts +12 -0
  54. package/dist/storage/index.d.mts.map +1 -0
  55. package/dist/storage/index.mjs +4 -0
  56. package/package.json +137 -0
  57. package/src/auth/.gitkeep +0 -0
  58. package/src/auth/index.ts +10 -0
  59. package/src/auth/resolver.ts +46 -0
  60. package/src/auth/scanners.ts +462 -0
  61. package/src/auth/store.ts +357 -0
  62. package/src/catalog/.gitkeep +0 -0
  63. package/src/catalog/catalog.ts +302 -0
  64. package/src/catalog/index.ts +17 -0
  65. package/src/catalog/mapper.ts +129 -0
  66. package/src/catalog/merger.ts +99 -0
  67. package/src/index.ts +37 -0
  68. package/src/logger.ts +7 -0
  69. package/src/plugin/.gitkeep +0 -0
  70. package/src/plugin/anthropic.test.ts +505 -0
  71. package/src/plugin/anthropic.ts +324 -0
  72. package/src/plugin/codex.ts +656 -0
  73. package/src/plugin/copilot.ts +161 -0
  74. package/src/plugin/google.ts +454 -0
  75. package/src/plugin/index.ts +30 -0
  76. package/src/provider/.gitkeep +0 -0
  77. package/src/provider/bundled.ts +59 -0
  78. package/src/provider/index.ts +249 -0
  79. package/src/provider/state.ts +163 -0
  80. package/src/storage/.gitkeep +0 -0
  81. package/src/storage/auto.ts +32 -0
  82. package/src/storage/file.ts +84 -0
  83. package/src/storage/index.ts +10 -0
  84. package/src/storage/memory.ts +23 -0
  85. package/src/types/.gitkeep +0 -0
  86. package/src/types/auth.ts +18 -0
  87. package/src/types/errors.ts +87 -0
  88. package/src/types/index.ts +26 -0
  89. package/src/types/model.ts +88 -0
  90. package/src/types/plugin.ts +49 -0
  91. package/src/types/provider.ts +48 -0
@@ -0,0 +1,357 @@
1
+ import { createLogger } from '../logger.js'
2
+ import { type StorageAdapter, createDefaultStorage } from '../storage/index.js'
3
+ import type { AuthCredential } from '../types/plugin.js'
4
+ import type { DiskScanResult, DiskScanner, ScanContext } from './scanners.js'
5
+ import { DEFAULT_SCANNERS, createNodeScanContext, runDiskScanners } from './scanners.js'
6
+
7
+ const log = createLogger('auth')
8
+
9
+ export interface DiscoveredCredential {
10
+ providerId: string
11
+ source: 'env' | 'disk' | 'auth'
12
+ key?: string
13
+ credential?: AuthCredential
14
+ location?: string
15
+ }
16
+
17
+ export interface DiscoverOptions {
18
+ scanners?: DiskScanner[]
19
+ scanContext?: ScanContext
20
+ skipDiskScan?: boolean
21
+ skipEnvScan?: boolean
22
+ persist?: boolean
23
+ }
24
+
25
+ export interface AuthStoreOptions {
26
+ storage?: StorageAdapter
27
+ data?: Record<string, AuthCredential>
28
+ }
29
+
30
+ export interface AuthStore {
31
+ all(): Promise<Record<string, AuthCredential>>
32
+ get(providerId: string): Promise<AuthCredential | null>
33
+ set(providerId: string, credential: AuthCredential): Promise<void>
34
+ remove(providerId: string): Promise<void>
35
+ discover(options?: DiscoverOptions): Promise<DiscoveredCredential[]>
36
+ getPreferred?(providerId: string, prefer: 'api' | 'oauth'): Promise<AuthCredential | null>
37
+ }
38
+
39
+ function pickBestCredential(creds: AuthCredential[], prefer: 'api' | 'oauth' = 'api'): AuthCredential | undefined {
40
+ if (creds.length === 0) return undefined
41
+ const preferred = creds.find((c) => c.type === prefer && c.key !== undefined)
42
+ if (preferred !== undefined) return preferred
43
+ const withKey = creds.find((c) => c.key !== undefined)
44
+ if (withKey !== undefined) return withKey
45
+ return creds[0]
46
+ }
47
+
48
+ export function createAuthStore(options?: AuthStoreOptions): AuthStore {
49
+ const externalData = options?.data
50
+ let storagePromise: Promise<StorageAdapter> | undefined
51
+ const discoveredCredentials = new Map<string, AuthCredential[]>()
52
+
53
+ log('auth store created, external=%s', externalData !== undefined)
54
+
55
+ function getStorage(): Promise<StorageAdapter> {
56
+ if (storagePromise === undefined) {
57
+ storagePromise = options?.storage ? Promise.resolve(options.storage) : createDefaultStorage()
58
+ }
59
+ return storagePromise
60
+ }
61
+
62
+ async function readAuthState(): Promise<Record<string, AuthCredential>> {
63
+ if (externalData !== undefined) {
64
+ log('using external data, skipping file read')
65
+ return { ...externalData }
66
+ }
67
+
68
+ const storage = await getStorage()
69
+ const raw = await storage.get(AUTH_STORE_KEY)
70
+ if (raw === null) {
71
+ log('auth store not found, returning empty store')
72
+ return {}
73
+ }
74
+
75
+ const parsed: unknown = JSON.parse(raw)
76
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
77
+ log('auth store payload is malformed, returning empty store')
78
+ return {}
79
+ }
80
+
81
+ const normalized = normalizeAuthState(parsed as Record<string, unknown>)
82
+ log('read auth store with %d entries', Object.keys(normalized).length)
83
+ return normalized
84
+ }
85
+
86
+ async function writeAuthState(data: Record<string, AuthCredential>): Promise<void> {
87
+ if (externalData !== undefined) {
88
+ for (const [k, v] of Object.entries(data)) {
89
+ externalData[k] = v
90
+ }
91
+ for (const k of Object.keys(externalData)) {
92
+ if (!(k in data)) {
93
+ delete externalData[k]
94
+ }
95
+ }
96
+ log('updated external data, %d entries', Object.keys(externalData).length)
97
+ return
98
+ }
99
+
100
+ const content = JSON.stringify(data, null, 2)
101
+ const storage = await getStorage()
102
+ await storage.set(AUTH_STORE_KEY, content)
103
+
104
+ log('wrote auth store with %d entries', Object.keys(data).length)
105
+ }
106
+
107
+ function mergeWithDiscovered(fileData: Record<string, AuthCredential>): Record<string, AuthCredential> {
108
+ if (discoveredCredentials.size === 0) return fileData
109
+ const merged = { ...fileData }
110
+ for (const [pid, creds] of discoveredCredentials) {
111
+ if (merged[pid] === undefined || !merged[pid].key) {
112
+ const best = pickBestCredential(creds, 'api')
113
+ if (best !== undefined) {
114
+ merged[pid] = best
115
+ }
116
+ }
117
+ }
118
+ log(
119
+ 'merged %d discovered credentials with %d file entries',
120
+ discoveredCredentials.size,
121
+ Object.keys(fileData).length
122
+ )
123
+ return merged
124
+ }
125
+
126
+ function pushDiscoveredCredential(providerId: string, credential: AuthCredential): void {
127
+ const arr = discoveredCredentials.get(providerId) ?? []
128
+ arr.push(credential)
129
+ discoveredCredentials.set(providerId, arr)
130
+ }
131
+
132
+ function pushDiscoveredResultOnce(
133
+ seen: Set<string>,
134
+ results: DiscoveredCredential[],
135
+ result: DiscoveredCredential
136
+ ): boolean {
137
+ if (seen.has(result.providerId)) return false
138
+ seen.add(result.providerId)
139
+ results.push(result)
140
+ return true
141
+ }
142
+
143
+ function buildCredentialFromEnv(envVar: string, key: string): AuthCredential {
144
+ return buildCredential({ type: 'api', key, location: `env:${envVar}` })
145
+ }
146
+
147
+ function buildCredentialFromDisk(result: DiskScanResult): AuthCredential | undefined {
148
+ if (result.key === undefined) return undefined
149
+ return buildCredential({
150
+ type: result.credentialType ?? 'api',
151
+ key: result.key,
152
+ location: result.source,
153
+ refresh: result.refresh,
154
+ accountId: result.accountId,
155
+ expires: result.expires,
156
+ })
157
+ }
158
+
159
+ return {
160
+ async all(): Promise<Record<string, AuthCredential>> {
161
+ return mergeWithDiscovered(await readAuthState())
162
+ },
163
+
164
+ async get(providerId: string): Promise<AuthCredential | null> {
165
+ const store = mergeWithDiscovered(await readAuthState())
166
+ const credential = store[providerId]
167
+ if (credential === undefined) {
168
+ log('get(%s): not found', providerId)
169
+ return null
170
+ }
171
+ log('get(%s): found type=%s', providerId, credential.type)
172
+ return credential
173
+ },
174
+
175
+ async set(providerId: string, credential: AuthCredential): Promise<void> {
176
+ const store = await readAuthState()
177
+ store[providerId] = credential
178
+ await writeAuthState(store)
179
+ log('set(%s): type=%s', providerId, credential.type)
180
+ },
181
+
182
+ async remove(providerId: string): Promise<void> {
183
+ const store = await readAuthState()
184
+ if (!(providerId in store)) {
185
+ log('remove(%s): not found, no-op', providerId)
186
+ return
187
+ }
188
+ delete store[providerId]
189
+ await writeAuthState(store)
190
+ log('remove(%s): done', providerId)
191
+ },
192
+
193
+ async discover(discoverOptions?: DiscoverOptions): Promise<DiscoveredCredential[]> {
194
+ const results: DiscoveredCredential[] = []
195
+ const seen = new Set<string>()
196
+
197
+ if (!discoverOptions?.skipEnvScan) {
198
+ for (const [envVar, providerId] of ENV_HINTS) {
199
+ const value = process.env[envVar]
200
+ if (value) {
201
+ pushDiscoveredCredential(providerId, buildCredentialFromEnv(envVar, value))
202
+ pushDiscoveredResultOnce(seen, results, { providerId, source: 'env', key: value })
203
+ log('discover: %s [api] from env:%s', providerId, envVar)
204
+ }
205
+ }
206
+ }
207
+
208
+ if (!discoverOptions?.skipDiskScan) {
209
+ const scanners = discoverOptions?.scanners ?? DEFAULT_SCANNERS
210
+ const ctx = discoverOptions?.scanContext ?? createNodeScanContext()
211
+ const diskResults = await runDiskScanners(scanners, ctx)
212
+
213
+ for (const disk of diskResults) {
214
+ const credType = disk.credentialType ?? 'api'
215
+ pushDiscoveredResultOnce(seen, results, {
216
+ providerId: disk.providerId,
217
+ source: 'disk',
218
+ key: disk.key,
219
+ location: disk.source,
220
+ })
221
+ const cred = buildCredentialFromDisk(disk)
222
+ if (cred !== undefined) {
223
+ pushDiscoveredCredential(disk.providerId, cred)
224
+ log('discover: %s [%s] from %s', disk.providerId, credType, disk.source)
225
+ }
226
+ }
227
+ }
228
+
229
+ if (discoverOptions?.persist === true) {
230
+ const persistedCount = await persistDiscoveredCredentials()
231
+ log('discover: persisted %d providers to auth store', persistedCount)
232
+ }
233
+
234
+ let authData: Record<string, AuthCredential>
235
+ try {
236
+ authData = await readAuthState()
237
+ } catch (err: unknown) {
238
+ log('discover: failed to read auth store: %s', err instanceof Error ? err.message : String(err))
239
+ authData = {}
240
+ }
241
+
242
+ for (const [providerId, credential] of Object.entries(authData)) {
243
+ const added = pushDiscoveredResultOnce(seen, results, { providerId, source: 'auth', credential })
244
+ if (added) log('discover: %s via auth store', providerId)
245
+ }
246
+
247
+ log('discover: complete, %d providers found', results.length)
248
+ return results
249
+ },
250
+
251
+ async getPreferred(providerId: string, prefer: 'api' | 'oauth'): Promise<AuthCredential | null> {
252
+ const creds = discoveredCredentials.get(providerId)
253
+ if (creds !== undefined && creds.length > 0) {
254
+ const best = pickBestCredential(creds, prefer)
255
+ if (best !== undefined) return best
256
+ }
257
+ const store = mergeWithDiscovered(await readAuthState())
258
+ const credential = store[providerId]
259
+ return credential ?? null
260
+ },
261
+ }
262
+
263
+ async function persistDiscoveredCredentials(): Promise<number> {
264
+ if (discoveredCredentials.size === 0) return 0
265
+
266
+ const store = await readAuthState()
267
+ let changed = 0
268
+
269
+ for (const [providerId, creds] of discoveredCredentials) {
270
+ const existing = store[providerId]
271
+ if (existing?.key) {
272
+ continue
273
+ }
274
+
275
+ const best = pickBestCredential(creds, 'api')
276
+ if (best === undefined) {
277
+ continue
278
+ }
279
+
280
+ store[providerId] = best
281
+ changed += 1
282
+ }
283
+
284
+ if (changed > 0) {
285
+ await writeAuthState(store)
286
+ }
287
+
288
+ return changed
289
+ }
290
+ }
291
+
292
+ const AUTH_STORE_KEY = 'auth:store'
293
+
294
+ interface CredentialBuildInput {
295
+ type: 'api' | 'oauth' | 'wellknown'
296
+ key?: string
297
+ location?: string
298
+ refresh?: string
299
+ accountId?: string
300
+ expires?: number
301
+ }
302
+
303
+ function buildCredential(input: CredentialBuildInput): AuthCredential {
304
+ const credential: AuthCredential = {
305
+ type: input.type,
306
+ key: input.key,
307
+ location: input.location,
308
+ }
309
+
310
+ if (input.refresh !== undefined) credential.refresh = input.refresh
311
+ if (input.accountId !== undefined) credential.accountId = input.accountId
312
+ if (input.expires !== undefined) credential.expires = input.expires
313
+
314
+ return credential
315
+ }
316
+
317
+ function normalizeAuthState(input: Record<string, unknown>): Record<string, AuthCredential> {
318
+ const normalized: Record<string, AuthCredential> = {}
319
+
320
+ for (const [providerId, rawCredential] of Object.entries(input)) {
321
+ const credential = normalizeCredential(rawCredential)
322
+ if (credential !== undefined) {
323
+ normalized[providerId] = credential
324
+ }
325
+ }
326
+
327
+ return normalized
328
+ }
329
+
330
+ function normalizeCredential(rawCredential: unknown): AuthCredential | undefined {
331
+ if (typeof rawCredential !== 'object' || rawCredential === null || Array.isArray(rawCredential)) {
332
+ return undefined
333
+ }
334
+
335
+ const typed = rawCredential as Record<string, unknown>
336
+ const type =
337
+ typed.type === 'api' || typed.type === 'oauth' || typed.type === 'wellknown' ? typed.type : ('api' as const)
338
+
339
+ const key = typeof typed.key === 'string' && typed.key.length > 0 ? typed.key : undefined
340
+ return {
341
+ ...typed,
342
+ type,
343
+ key,
344
+ } as AuthCredential
345
+ }
346
+
347
+ const ENV_HINTS: Array<[string, string]> = [
348
+ ['ANTHROPIC_API_KEY', 'anthropic'],
349
+ ['OPENAI_API_KEY', 'openai'],
350
+ ['GOOGLE_GENERATIVE_AI_API_KEY', 'google'],
351
+ ['GOOGLE_API_KEY', 'google'],
352
+ ['AZURE_API_KEY', 'azure'],
353
+ ['XAI_API_KEY', 'xai'],
354
+ ['MISTRAL_API_KEY', 'mistral'],
355
+ ['GROQ_API_KEY', 'groq'],
356
+ ['OPENROUTER_API_KEY', 'openrouter'],
357
+ ]
File without changes
@@ -0,0 +1,302 @@
1
+ import { createLogger } from '../logger.js'
2
+ import type { ModelDefinition } from '../types/model.js'
3
+ import { mapModelsDevProvider, mapModelsDevProviderMetadata } from './mapper.js'
4
+ import { mergeCatalogData, mergeModelDefinitions } from './merger.js'
5
+
6
+ const DEFAULT_REMOTE_URL = 'https://models.dev/api.json'
7
+ const DEFAULT_TIMEOUT_MS = 10_000
8
+
9
+ const log = createLogger('catalog')
10
+
11
+ type CatalogDataSource = Record<string, unknown>
12
+ type ProviderModelMap = Map<string, Map<string, ModelDefinition>>
13
+ type FetchLike = (
14
+ url: string,
15
+ init?: { signal?: unknown }
16
+ ) => Promise<{ ok: boolean; status: number; json(): Promise<unknown> }>
17
+
18
+ export interface CatalogProvider {
19
+ id: string
20
+ name: string
21
+ env?: string[]
22
+ api?: string
23
+ doc?: string
24
+ bundledProvider?: string
25
+ baseURL?: string
26
+ headers?: Record<string, string>
27
+ options?: Record<string, unknown>
28
+ }
29
+
30
+ export interface ExtendModelConfig {
31
+ name?: string
32
+ modalities?: {
33
+ input: Array<'text' | 'image' | 'audio' | 'video' | 'pdf'>
34
+ output: Array<'text' | 'image' | 'audio'>
35
+ }
36
+ limit?: { context: number; output: number }
37
+ }
38
+
39
+ export interface ExtendProviderConfig {
40
+ name: string
41
+ env?: string[]
42
+ bundledProvider?: string
43
+ baseURL?: string
44
+ headers?: Record<string, string>
45
+ options?: Record<string, unknown>
46
+ models?: Record<string, ExtendModelConfig>
47
+ }
48
+
49
+ export interface ExtendConfig {
50
+ providers?: Record<string, ExtendProviderConfig>
51
+ }
52
+
53
+ export interface CatalogOptions {
54
+ snapshot?: Record<string, unknown>
55
+ remote?: {
56
+ url?: string
57
+ timeoutMs?: number
58
+ fetch?: FetchLike
59
+ }
60
+ }
61
+
62
+ export interface RefreshResult {
63
+ success: boolean
64
+ updatedProviders: string[]
65
+ newModels: number
66
+ error?: Error
67
+ }
68
+
69
+ export class Catalog {
70
+ private readonly remoteOptions: {
71
+ readonly url: string
72
+ readonly timeoutMs: number
73
+ readonly fetch?: FetchLike
74
+ }
75
+
76
+ private readonly snapshotData: CatalogDataSource
77
+ private remoteData: CatalogDataSource = {}
78
+ private readonly providers = new Map<string, CatalogProvider>()
79
+ private modelsByProvider: ProviderModelMap = new Map()
80
+ private refreshInFlight: Promise<RefreshResult> | null = null
81
+ private readonly extendedProviders = new Map<string, CatalogProvider>()
82
+ private readonly extendedModels = new Map<string, Map<string, Partial<ModelDefinition>>>()
83
+
84
+ constructor(options: CatalogOptions = {}) {
85
+ this.snapshotData = options.snapshot ?? {}
86
+ this.remoteOptions = {
87
+ url: options.remote?.url ?? DEFAULT_REMOTE_URL,
88
+ timeoutMs: options.remote?.timeoutMs ?? DEFAULT_TIMEOUT_MS,
89
+ fetch: options.remote?.fetch,
90
+ }
91
+ this.applyProviderMetadata(this.snapshotData, {})
92
+ log('initialized with %d providers from snapshot', this.providers.size)
93
+ }
94
+
95
+ getProvider(id: string): CatalogProvider | undefined {
96
+ return this.providers.get(id)
97
+ }
98
+
99
+ listProviders(): CatalogProvider[] {
100
+ return [...this.providers.values()]
101
+ }
102
+
103
+ getModel(providerId: string, modelId: string): ModelDefinition | undefined {
104
+ this.ensureProviderModelsLoaded(providerId)
105
+ return this.modelsByProvider.get(providerId)?.get(modelId)
106
+ }
107
+
108
+ listModels(providerId?: string): ModelDefinition[] {
109
+ if (providerId !== undefined) {
110
+ this.ensureProviderModelsLoaded(providerId)
111
+ return [...(this.modelsByProvider.get(providerId)?.values() ?? [])]
112
+ }
113
+
114
+ const results: ModelDefinition[] = []
115
+ for (const pid of this.providers.keys()) {
116
+ this.ensureProviderModelsLoaded(pid)
117
+ const models = this.modelsByProvider.get(pid)
118
+ if (models) {
119
+ results.push(...models.values())
120
+ }
121
+ }
122
+ return results
123
+ }
124
+
125
+ enrichModel(providerId: string, modelId: string, partial: Partial<ModelDefinition>): ModelDefinition {
126
+ const catalogModel = this.getModel(providerId, modelId)
127
+ const partialWithId = { ...partial, modelId }
128
+ if (!catalogModel) {
129
+ return partialWithId as ModelDefinition
130
+ }
131
+ return mergeModelDefinitions(catalogModel, partialWithId)
132
+ }
133
+
134
+ extend(config: ExtendConfig): void {
135
+ if (!config.providers) return
136
+
137
+ for (const [providerId, providerConfig] of Object.entries(config.providers)) {
138
+ const existingProvider = this.providers.get(providerId)
139
+ const provider: CatalogProvider = {
140
+ id: providerId,
141
+ name: providerConfig.name,
142
+ ...(providerConfig.env !== undefined ? { env: providerConfig.env } : {}),
143
+ ...(existingProvider?.api !== undefined ? { api: existingProvider.api } : {}),
144
+ ...(existingProvider?.doc !== undefined ? { doc: existingProvider.doc } : {}),
145
+ ...(providerConfig.bundledProvider !== undefined ? { bundledProvider: providerConfig.bundledProvider } : {}),
146
+ ...(providerConfig.baseURL !== undefined ? { baseURL: providerConfig.baseURL } : {}),
147
+ ...(providerConfig.headers !== undefined ? { headers: providerConfig.headers } : {}),
148
+ ...(providerConfig.options !== undefined ? { options: providerConfig.options } : {}),
149
+ }
150
+
151
+ this.extendedProviders.set(providerId, provider)
152
+ this.providers.set(providerId, provider)
153
+
154
+ if (providerConfig.models) {
155
+ const modelOverrides = this.extendedModels.get(providerId) ?? new Map<string, Partial<ModelDefinition>>()
156
+
157
+ for (const [modelId, modelConfig] of Object.entries(providerConfig.models)) {
158
+ const partial: Partial<ModelDefinition> = { modelId }
159
+ if (modelConfig.name !== undefined) partial.name = modelConfig.name
160
+ if (modelConfig.modalities !== undefined) partial.modalities = modelConfig.modalities
161
+ if (modelConfig.limit !== undefined) partial.limit = modelConfig.limit
162
+ modelOverrides.set(modelId, partial)
163
+ }
164
+
165
+ this.extendedModels.set(providerId, modelOverrides)
166
+ }
167
+
168
+ this.modelsByProvider.delete(providerId)
169
+ }
170
+ }
171
+
172
+ refresh(): Promise<RefreshResult> {
173
+ if (this.refreshInFlight) {
174
+ return this.refreshInFlight
175
+ }
176
+
177
+ const task = this.refreshInternal().finally(() => {
178
+ this.refreshInFlight = null
179
+ })
180
+
181
+ this.refreshInFlight = task
182
+ return task
183
+ }
184
+
185
+ private async refreshInternal(): Promise<RefreshResult> {
186
+ try {
187
+ log('refresh: fetching from %s', this.remoteOptions.url)
188
+ const remoteData = await this.fetchRemoteData()
189
+ this.remoteData = remoteData
190
+ const { updatedProviders } = this.applyProviderMetadata(this.snapshotData, this.remoteData)
191
+ this.modelsByProvider.clear()
192
+ log('refresh: updated %d providers', updatedProviders.length)
193
+ return { success: true, updatedProviders, newModels: 0 }
194
+ } catch (error) {
195
+ log('refresh: failed — %s', error instanceof Error ? error.message : String(error))
196
+ return {
197
+ success: false,
198
+ updatedProviders: [],
199
+ newModels: 0,
200
+ error: error instanceof Error ? error : new Error(String(error)),
201
+ }
202
+ }
203
+ }
204
+
205
+ private async fetchRemoteData(): Promise<CatalogDataSource> {
206
+ const fetchFn = this.remoteOptions.fetch ?? this.resolveGlobalFetch()
207
+ if (!fetchFn) throw new Error('No fetch implementation available')
208
+
209
+ const response = await fetchFn(this.remoteOptions.url)
210
+ if (!response.ok) throw new Error(`Failed to fetch remote catalog: HTTP ${response.status}`)
211
+
212
+ const payload = await response.json()
213
+ if (payload && typeof payload === 'object') return payload as CatalogDataSource
214
+ throw new Error('Invalid remote catalog payload')
215
+ }
216
+
217
+ private resolveGlobalFetch(): FetchLike | undefined {
218
+ const maybeFetch = (globalThis as Record<string, unknown>).fetch
219
+ return typeof maybeFetch === 'function' ? (maybeFetch as FetchLike) : undefined
220
+ }
221
+
222
+ private applyProviderMetadata(
223
+ snapshotRaw: CatalogDataSource,
224
+ remoteRaw: CatalogDataSource
225
+ ): { updatedProviders: string[] } {
226
+ const snapshotProviders = this.mapProviderMetadata(snapshotRaw)
227
+ const remoteProviders = this.mapProviderMetadata(remoteRaw)
228
+ const allProviderIds = new Set<string>([...snapshotProviders.keys(), ...remoteProviders.keys()])
229
+
230
+ this.providers.clear()
231
+ for (const providerId of allProviderIds) {
232
+ const provider = remoteProviders.get(providerId) ?? snapshotProviders.get(providerId)
233
+ if (provider) this.providers.set(providerId, provider)
234
+ }
235
+
236
+ for (const [providerId, provider] of this.extendedProviders.entries()) {
237
+ this.providers.set(providerId, provider)
238
+ }
239
+
240
+ return { updatedProviders: [...remoteProviders.keys()] }
241
+ }
242
+
243
+ private mapProviderMetadata(data: CatalogDataSource): Map<string, CatalogProvider> {
244
+ const result = new Map<string, CatalogProvider>()
245
+ for (const [providerId, rawProvider] of Object.entries(data)) {
246
+ if (providerId.startsWith('_')) continue
247
+ result.set(providerId, mapModelsDevProviderMetadata(providerId, rawProvider))
248
+ }
249
+ return result
250
+ }
251
+
252
+ private providerRaw(data: CatalogDataSource, providerId: string): unknown {
253
+ const value = data[providerId]
254
+ return value && typeof value === 'object' ? value : undefined
255
+ }
256
+
257
+ private mapProviderModelsFromRaw(
258
+ providerId: string,
259
+ raw: unknown,
260
+ provenance: 'snapshot' | 'remote'
261
+ ): Map<string, ModelDefinition> {
262
+ if (!raw || typeof raw !== 'object') return new Map<string, ModelDefinition>()
263
+ const mapped = mapModelsDevProvider(providerId, raw, provenance)
264
+ const byId = new Map<string, ModelDefinition>()
265
+ for (const model of mapped.models) {
266
+ byId.set(model.modelId, model)
267
+ }
268
+ return byId
269
+ }
270
+
271
+ private ensureProviderModelsLoaded(providerId: string): void {
272
+ if (this.modelsByProvider.has(providerId)) return
273
+
274
+ const snapshotModels = this.mapProviderModelsFromRaw(
275
+ providerId,
276
+ this.providerRaw(this.snapshotData, providerId),
277
+ 'snapshot'
278
+ )
279
+ const remoteModels = this.mapProviderModelsFromRaw(
280
+ providerId,
281
+ this.providerRaw(this.remoteData, providerId),
282
+ 'remote'
283
+ )
284
+ const extendedOverrides = this.extendedModels.get(providerId) ?? new Map<string, Partial<ModelDefinition>>()
285
+
286
+ const merged = mergeCatalogData(snapshotModels, remoteModels, extendedOverrides)
287
+
288
+ for (const [modelId, partial] of extendedOverrides.entries()) {
289
+ if (!merged.has(modelId)) {
290
+ const base: ModelDefinition = {
291
+ modelId,
292
+ modalities: { input: ['text'], output: ['text'] },
293
+ limit: { context: 0, output: 0 },
294
+ provenance: 'user-override',
295
+ }
296
+ merged.set(modelId, mergeModelDefinitions(base, partial))
297
+ }
298
+ }
299
+
300
+ this.modelsByProvider.set(providerId, merged)
301
+ }
302
+ }
@@ -0,0 +1,17 @@
1
+ import type { CatalogOptions } from './catalog.js'
2
+ import { Catalog } from './catalog.js'
3
+
4
+ export type {
5
+ CatalogOptions,
6
+ CatalogProvider,
7
+ ExtendConfig,
8
+ ExtendModelConfig,
9
+ ExtendProviderConfig,
10
+ RefreshResult,
11
+ } from './catalog.js'
12
+
13
+ export { Catalog }
14
+
15
+ export function createCatalog(options: CatalogOptions = {}): Catalog {
16
+ return new Catalog(options)
17
+ }