返回源码地图

packages/llm/llm/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 09 / 09;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * LLM service: adapter registry with a waterfall-interceptable streaming call
3 * API. Exports the `LlmRuntime` default, the abstract `LlmAdapter` for
4 * provider backends, and `BlockAssembler` for chunk assembly.
5 *
6 * @module @deepseek-ai/dsh-llm
7 */
8
9import { Context } from '@deepseek-ai/cordis'
10import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'
11import { deepFreeze } from '@deepseek-ai/dsh-util-values'
12import type {
13 GenerateOptions,
14 RequestMessage,
15 LlmConfigurableProvider,
16 LlmDiscoveredModel,
17 LlmFailure,
18 LlmImageRequestPricing,
19 LlmModelContext,
20 LlmModelDiscoveryRequest,
21 LlmModelInfo,
22 LlmResolvedModelInfo,
23 LlmProviderInfo,
24 ModelModality,
25 StreamChunk,
26 SystemPromptUpdate,
27 ToolSchema,
28 ToolUpdate,
29} from './types.ts'
30import { freezeMessage } from './message.ts'
31import { resolveRetryPolicy } from './retry-policy.ts'
32import type { ResolvedRetryPolicy } from './retry-policy.ts'
33import type { ProviderRequestId } from './brand.ts'
34import { callConfigEquals } from './call-config.ts'
35import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts'
36import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts'
37import { normalizeLlmFailure } from './adapter-failure.ts'
38import { normalizeApiKey } from './api-key.ts'
39import {
40 contentHasFile, contentHasImage, fileHandleText, projectFilesToText, projectImagesForTextModel, projectToolUpdates,
41} from './content.ts'
42import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'
43
44export * from './attribution.ts'
45export * from './brand.ts'
46export * from './error.ts'
47export * from './api-key.ts'
48export * from './types.ts'
49export * from './content.ts'
50export * from './assistant-stream.ts'
51export * from './message.ts'
52export * from './retry-policy.ts'
53export { BlockAssembler } from './assembler.ts'
54export { callConfigEquals, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts'
55export type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts'
56
57declare module '@deepseek-ai/cordis' {
58 interface Context {
59 llm: LlmRuntime
60 }
61
62 interface Events {
63 /**
64 * Waterfall around every streaming model call (retry, replay, routing).
65 * Bound to the {@link LlmRuntime}; call `next()` to reach the resolved
66 * adapter's stream, or yield your own chunks to short-circuit.
67 * @param options - the full request. A LOOP-built request carries the
68 * process-local {@link markAgentLoopRequest} identity and arrives deep-frozen
69 * (mutation throws): its content is a pure function of the session log (the
70 * reconstructability Agent Note), so listeners read it, never rewrite it.
71 * Hand-built calls do not carry that marker; callers own their request
72 * inputs and must keep them unchanged until the stream settles.
73 * @mode waterfall
74 */
75 'llm/stream'(this: LlmRuntime, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>
76
77 }
78}
79
80/** Structured provider facts and cause accepted by {@link LlmError}. */
81export interface LlmErrorOptions extends ErrorOptions {
82 /** Valid HTTP status observed at the provider boundary. */
83 status?: number
84 /** Positive finite provider-requested delay in milliseconds. */
85 providerRetryAfterMs?: number
86 /** Non-empty opaque provider request id. */
87 requestId?: ProviderRequestId
88 /** Positive count of additional oldest retained image occurrences to offload; only with `IMAGE_OFFLOAD_REQUIRED`. */
89 offloadImages?: number
90}
91
92/**
93 * Typed error for LLM-related failures. Extends {@link HarnessError}, so the
94 * `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy.
95 */
96export class LlmError extends HarnessError {
97 /** Serializable facts retained beside this live Error. */
98 readonly failure: LlmFailure
99
100 /**
101 * @param message - non-empty human-readable failure summary.
102 * @param code - non-empty stable provider-neutral machine code.
103 * @param options - optional cause and validated serializable provider facts.
104 */
105 constructor(message: string, code: string, options?: LlmErrorOptions) {
106 if (typeof message !== 'string' || message.length === 0) throw new Error('LlmError message must be a non-empty string')
107 if (typeof code !== 'string' || code.length === 0) throw new Error('LlmError code must be a non-empty string')
108 if (options?.status !== undefined
109 && (!Number.isInteger(options.status) || options.status < 100 || options.status > 599)) {
110 throw new Error('LlmError status must be an integer from 100 through 599')
111 }
112 if (options?.providerRetryAfterMs !== undefined
113 && (!Number.isFinite(options.providerRetryAfterMs) || options.providerRetryAfterMs <= 0)) {
114 throw new Error('LlmError providerRetryAfterMs must be a positive finite number')
115 }
116 if (options?.requestId !== undefined
117 && (typeof options.requestId !== 'string' || options.requestId.length === 0)) {
118 throw new Error('LlmError requestId must be a non-empty string')
119 }
120 super(message, code, options)
121 this.name = 'LlmError'
122 this.failure = Object.freeze({
123 message,
124 code,
125 ...options?.status === undefined ? {} : { status: options.status },
126 ...options?.providerRetryAfterMs === undefined ? {} : { providerRetryAfterMs: options.providerRetryAfterMs },
127 ...options?.requestId === undefined ? {} : { requestId: options.requestId },
128 ...options?.offloadImages === undefined ? {} : { offloadImages: options.offloadImages },
129 })
130 }
131}
132
133/**
134 * Accept one supplied credential, or refuse it as unusable.
135 *
136 * A stored key arrives from the credentials seam, a `.env` line, or a shell
137 * export, all of which pick up surrounding whitespace, so trimming is silent.
138 * Anything else fails here rather than inside `fetch`, whose ByteString
139 * refusal names a UTF-16 code point instead of the setting to change. The key
140 * never enters the message: `ref` names where to fix it, and echoing any part
141 * of a secret into a log or a UI is the failure this diagnosis avoids.
142 *
143 * Lives beside {@link LlmError} rather than in `./api-key.ts` so the predicate
144 * module stays dependency-free; both adapters share this one diagnosis instead
145 * of keeping near-identical local copies.
146 * @param raw - the credential exactly as supplied.
147 * @param pkg - the refusing package name, prefixed to the diagnostic.
148 * @param ref - the credential reference the value resolved through.
149 * @returns the trimmed, usable key.
150 */
151export function assertUsableApiKey(raw: string, pkg: string, ref: string): string {
152 const checked = normalizeApiKey(raw)
153 if (checked.ok) return checked.value
154 // The Models page is named as the writer it usually is, not as the only one:
155 // the same value can arrive from a hand-edited .env or a shell export in a
156 // composition that mounts no credentials seam at all, where directing the
157 // user to a page that deployment does not serve would be a dead end.
158 throw new LlmError(
159 checked.reason === 'empty'
160 ? `${pkg}: the API key resolved from ${ref} is blank; set ${ref} to the raw key`
161 + ' (the web Models page writes it) or export it in the launching environment'
162 : `${pkg}: the API key resolved from ${ref} contains characters no HTTP header can carry;`
163 + ` set ${ref} to the raw key alone (the web Models page writes it)`,
164 INVALID_CREDENTIAL_CODE,
165 )
166}
167
168/** One model call whose config and adapter registration were resolved together. */
169export interface PreparedLlmCall {
170 /** Detached, deep-frozen config with any adapter-owned default materialized. */
171 readonly config: LlmCallConfig
172 /** Immutable retry policy captured with the adapter registration. */
173 readonly retryPolicy: ResolvedRetryPolicy
174 /** Detached context metadata resolved with the registration-bound call. */
175 readonly context?: LlmModelContext
176 /** Exact model modalities captured with the adapter dispatch generation. */
177 readonly inputModalities?: readonly ModelModality[]
178 /** Exact model system prompt update mode captured with the adapter dispatch generation. */
179 readonly systemPromptUpdate?: SystemPromptUpdate
180 /** Exact model tool update mode captured with the adapter dispatch generation. */
181 readonly toolUpdate?: ToolUpdate
182 /** Config fields materialized by the captured adapter rather than proposed by the caller. */
183 readonly adapterDefaults: LlmCallConfigAdapterDefaults
184 /**
185 * Dispatch this call once through the registration captured during
186 * preparation. The request's call-config fields must match {@link config};
187 * reuse or mismatch fails with `INVALID_PREPARED_CALL`.
188 * @param options - fully assembled request carrying the prepared config.
189 * @returns the chunk stream, including the `llm/stream` waterfall.
190 */
191 stream(options: GenerateOptions): AsyncIterable<StreamChunk>
192}
193
194/** One adapter-owned model-resolution generation bound to its eventual stream call. */
195export interface PreparedAdapterCall {
196 /** Exact model metadata from the same adapter generation as {@link stream}. */
197 readonly model: LlmResolvedModelInfo
198 /** Dispatch through that generation without re-reading dynamic connection facts. */
199 stream(options: GenerateOptions): AsyncIterable<StreamChunk>
200}
201
202/**
203 * Provider-wire adapter for the harness message and stream vocabulary. Register implementations
204 * with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include
205 * `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch
206 * DeepSeek and library-backed pi-ai adapters meet this contract through different internals.
207 */
208export abstract class LlmAdapter {
209 /**
210 * Describe one provider route owned by this adapter.
211 * @param provider - a route passed to `registerAdapter()` for this instance.
212 * @returns detached display metadata whose id must equal `provider`.
213 */
214 providerInfo(provider: string): LlmProviderInfo {
215 return { id: provider, name: provider }
216 }
217
218 /**
219 * Return the provider-owned retry policy captured with this route.
220 * @param _provider - a route passed to `registerAdapter()` for this instance.
221 * @returns a resolved policy, or `undefined` to use the normal defaults.
222 */
223 providerRetryPolicy(_provider: string): ResolvedRetryPolicy | undefined {
224 return undefined
225 }
226
227 /**
228 * Resolve provider-side request-image pricing for one exact model route.
229 * The default declares none, so consumers fall back to their own neutral
230 * estimate. Implementations must answer synchronously without I/O; the
231 * token meter resolves this per measurement.
232 * @param _provider - a route passed to `registerAdapter()` for this instance.
233 * @param _model - exact model id passed to {@link GenerateOptions.model}.
234 * @returns route-owned image pricing, or `undefined` when the route declares none.
235 */
236 imageRequestPricing(_provider: string, _model: string): LlmImageRequestPricing | undefined {
237 return undefined
238 }
239
240 /**
241 * List models this adapter can currently advertise for one owned provider.
242 * Core routing accepts unlisted model ids; catalog-driven entry points such
243 * as the GUI may require membership. Adapters used there must advertise
244 * their available models; the base empty catalog offers no GUI selection.
245 * @param _provider - one provider route owned by this adapter.
246 * @returns discoverable models in adapter-preferred order.
247 */
248 listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
249 return Promise.resolve([])
250 }
251
252 /**
253 * Resolve all metadata available for one exact model. This query is
254 * independent of the advisory catalog and does not validate request routing.
255 * @param provider - one provider route owned by this adapter.
256 * @param model - exact model id passed to {@link GenerateOptions.model}.
257 * @param _signal - cancellation for this exact-model lookup; asynchronous
258 * implementations must settle promptly after it aborts.
259 * @returns provider/model identity plus any context, call-default, and reasoning metadata.
260 */
261 resolveModel(
262 provider: string,
263 model: string,
264 _signal?: AbortSignal,
265 ): Promise<LlmResolvedModelInfo> {
266 return Promise.resolve({ provider, id: model, name: model })
267 }
268
269 /**
270 * Bind exact model metadata and the eventual request dispatch to one adapter generation.
271 * Dynamic adapters override this so settings changes between preparation and
272 * dispatch cannot combine one generation's capabilities with another's endpoint.
273 * @param provider - registered provider route.
274 * @param model - exact model id.
275 * @param signal - cancellation for model resolution.
276 * @returns model metadata and a one-generation stream entry point.
277 */
278 async prepareCall(provider: string, model: string, signal?: AbortSignal): Promise<PreparedAdapterCall> {
279 return {
280 model: await this.resolveModel(provider, model, signal),
281 stream: options => this.stream(options),
282 }
283 }
284
285 /**
286 * Stream one model call as raw chunks. The only required method.
287 * @param options - the fully-assembled request; implementations must honor `options.signal`.
288 * @returns the chunk stream, obeying the adapter contract documented on `StreamChunk`.
289 */
290 abstract stream(options: GenerateOptions): AsyncIterable<StreamChunk>
291}
292
293/**
294 * What {@link LlmRuntime.registerAdapter} returns: the disposer, plus an
295 * atomic route replacement for the same adapter instance.
296 */
297export interface AdapterRegistrationHandle {
298 /** Release every route this registration currently holds. */
299 (): void
300 /**
301 * Replace this registration's routes with `providers`, keeping the same
302 * adapter instance. The candidate set is validated in full first — a
303 * conflict with another adapter, an invalid name, or bad provider metadata
304 * throws and leaves the current routes untouched — and the swap itself is
305 * one synchronous section, so no request can observe a gap. An empty array
306 * is legal here (a settings section that emptied holds zero routes while
307 * staying registered), unlike an empty initial registration.
308 *
309 * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration
310 * has been released: its routes are gone and its disposer has already run,
311 * so anything registered afterwards would have no owner left to release it.
312 * @param providers - the complete next route set for this registration.
313 */
314 replace(providers: string[]): void
315}
316
317/**
318 * A live configurable-provider registration, disposable and atomically
319 * replaceable — the directory counterpart of {@link AdapterRegistrationHandle}.
320 */
321export interface DirectoryRegistrationHandle {
322 /** Withdraw every entry this registration currently holds. */
323 (): void
324 /**
325 * Replace this registration's entries with `entries`. The candidate set is
326 * validated in full first — an entry another registration already declares,
327 * a duplicate within the set, or invalid metadata throws and leaves the
328 * current entries untouched — and the swap is one synchronous section, so no
329 * reader observes a gap. An empty array is legal here, unlike an empty
330 * initial registration.
331 *
332 * Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration
333 * has been disposed.
334 */
335 replace(entries: readonly LlmConfigurableProvider[]): void
336}
337
338/**
339 * The abstract `llm` service: an adapter registry plus a streaming model-call
340 * API, interceptable via the `llm/stream` waterfall.
341 */
342export class LlmRuntime extends TypertRemoteService {
343 private adapters = new Map<string, AdapterRegistration>()
344 private directory = new Map<string, LlmConfigurableProvider>()
345 private discoveries = new Map<
346 string,
347 (request: LlmModelDiscoveryRequest, signal?: AbortSignal) => Promise<readonly LlmDiscoveredModel[]>
348 >()
349
350 constructor(ctx: Context) {
351 super(ctx, 'llm')
352 }
353
354 /** Notify topology observers without letting one broken listener veto the commit. */
355 private emitAdaptersUpdated(): void {
356 // Cordis emit uses Array.map: one synchronous throw starves later
357 // listeners. Registry notifications are non-vetoing, so contain each
358 // callback independently.
359 for (const listener of this.ctx.events.dispatch('emit', ['llm/adapters-updated']) as Array<() => unknown>) {
360 try {
361 const returned = listener()
362 if (returned != null && typeof (returned as PromiseLike<unknown>).then === 'function') {
363 // An emit listener may still be an async function; its rejection
364 // is contained here instead of becoming an unhandled rejection.
365 void Promise.resolve(returned as PromiseLike<unknown>).then(undefined, (error: unknown) => {
366 this.warnAdaptersListenerFailure(error)
367 })
368 }
369 } catch (error) {
370 this.warnAdaptersListenerFailure(error)
371 }
372 }
373 }
374
375 /** Contained-listener diagnostic shared by the sync and async failure paths. */
376 private warnAdaptersListenerFailure(error: unknown): void {
377 this.ctx.logger.warn('llm: an llm/adapters-updated listener failed')
378 this.ctx.logger.warn(error)
379 }
380
381 /**
382 * Register an adapter for the given provider routes. Throws `LlmError` with code
383 * `DUPLICATE_ADAPTER` if any provider already has an adapter (all-or-nothing).
384 * Disposed with the fiber.
385 * @param providers - every provider route this adapter should serve.
386 * @param adapter - the adapter that streams calls for those providers.
387 * @returns the disposer, carrying {@link AdapterRegistrationHandle.replace}.
388 */
389 registerAdapter(providers: string[], adapter: LlmAdapter): AdapterRegistrationHandle {
390 // The routes this registration currently holds; `replace` rewrites it, and
391 // the disposer releases whatever it holds at disposal time.
392 const owned = new Set<string>()
393 // The disposer has run: `owned` being empty cannot say so on its own,
394 // because `replace([])` legally leaves a live registration holding none.
395 let released = false
396 const dispose = this.ctx.effect(function* (this: LlmRuntime) {
397 if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
398 this.commitRoutes(owned, this.prepareRoutes(providers, adapter, owned))
399 yield () => {
400 released = true
401 for (const provider of owned) this.adapters.delete(provider)
402 owned.clear()
403 this.emitAdaptersUpdated()
404 }
405 }.bind(this), 'llm.registerAdapter()')
406 // ctx.effect's disposer returns Promise<void>; our disposer API is
407 // synchronous fire-and-forget — discard the (always-resolved) promise.
408 const handle = (() => void dispose()) as AdapterRegistrationHandle
409 handle.replace = (next: string[]): void => {
410 // Registering here would leak: the effect's disposer already ran, so
411 // nothing remains to release whatever this call would put in the map.
412 if (released) {
413 throw new LlmError('a disposed adapter registration cannot replace its routes', 'REGISTRATION_DISPOSED')
414 }
415 this.commitRoutes(owned, this.prepareRoutes(next, adapter, owned))
416 }
417 return handle
418 }
419
420 /**
421 * Validate one candidate route set for `adapter`, treating routes this
422 * registration already holds as available. Nothing is mutated: a rejected
423 * candidate leaves the registry exactly as it was.
424 */
425 private prepareRoutes(providers: string[], adapter: LlmAdapter, owned: ReadonlySet<string>): AdapterRegistration[] {
426 const unique = new Set<string>()
427 const registrations: AdapterRegistration[] = []
428 for (const provider of providers) {
429 if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
430 if (unique.has(provider) || (this.adapters.has(provider) && !owned.has(provider))) {
431 throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER')
432 }
433 const info = adapter.providerInfo(provider)
434 if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) {
435 throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER')
436 }
437 unique.add(provider)
438 const retryPolicy = adapter.providerRetryPolicy(provider)
439 ?? resolveRetryPolicy(undefined, `llm: provider "${provider}" retryPolicy`)
440 registrations.push({
441 adapter,
442 provider: { id: info.id, name: info.name },
443 retryPolicy,
444 })
445 }
446 return registrations
447 }
448
449 /**
450 * Swap this registration's routes for the prepared ones in one synchronous
451 * section, so no observer can see the registry between the release and the
452 * re-registration. The route set's one mutation point is also where
453 * `llm/adapters-updated` is published, so a `replace` announces itself
454 * exactly like a first registration.
455 */
456 private commitRoutes(owned: Set<string>, registrations: readonly AdapterRegistration[]): void {
457 for (const provider of owned) this.adapters.delete(provider)
458 owned.clear()
459 for (const registration of registrations) {
460 this.adapters.set(registration.provider.id, registration)
461 owned.add(registration.provider.id)
462 }
463 this.emitAdaptersUpdated()
464 }
465
466 /**
467 * Describe provider routes with a registered adapter.
468 * @returns detached provider metadata in registration order.
469 */
470 @Remote
471 listProviders(): LlmProviderInfo[] {
472 return [...this.adapters.values()].map(({ provider }) => ({ ...provider }))
473 }
474
475 /**
476 * Declare provider routes an adapter plugin can activate through
477 * configuration. Registration is all-or-nothing: an empty list, invalid
478 * entry, or a provider already declared by any registration throws
479 * `LlmError` without registering the rest. Disposed with the fiber.
480 * @param entries - every configurable provider this plugin owns.
481 * @returns a handle that withdraws all of them, and can atomically replace them.
482 */
483 registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle {
484 let held: LlmConfigurableProvider[] = []
485 let disposed = false
486 /**
487 * Validate a candidate set in full against everything this registration
488 * does not already hold, then publish it. Nothing is written until the
489 * whole set passes, so a refused candidate leaves the current entries in
490 * place — the property that makes `replace` a swap rather than a
491 * delete-then-add that can strand the directory empty.
492 */
493 const commit = (candidates: readonly LlmConfigurableProvider[]): void => {
494 const detached: LlmConfigurableProvider[] = []
495 const own = new Set(held.map(entry => entry.provider))
496 for (const entry of candidates) {
497 if (entry.provider.length === 0 || entry.displayName.length === 0 || entry.settingsNs.length === 0) {
498 throw new LlmError('configurable providers need a non-empty provider, displayName, and settingsNs', 'INVALID_DIRECTORY')
499 }
500 if (entry.settingsPath.some(segment => segment.length === 0)) {
501 throw new LlmError(`configurable provider "${entry.provider}" has an empty settingsPath segment`, 'INVALID_DIRECTORY')
502 }
503 if ((this.directory.has(entry.provider) && !own.has(entry.provider))
504 || detached.some(seen => seen.provider === entry.provider)) {
505 throw new LlmError(`configurable provider "${entry.provider}" is already declared`, 'DUPLICATE_DIRECTORY')
506 }
507 detached.push({ ...entry, settingsPath: [...entry.settingsPath] })
508 }
509 for (const entry of held) this.directory.delete(entry.provider)
510 for (const entry of detached) this.directory.set(entry.provider, entry)
511 held = detached
512 this.emitAdaptersUpdated()
513 }
514
515 const dispose = this.ctx.effect(function* (this: LlmRuntime) {
516 if (entries.length === 0) {
517 throw new LlmError('a configurable-provider registration must declare at least one provider', 'INVALID_DIRECTORY')
518 }
519 commit(entries)
520 yield () => {
521 disposed = true
522 for (const entry of held) this.directory.delete(entry.provider)
523 held = []
524 this.emitAdaptersUpdated()
525 }
526 }.bind(this), 'llm.registerConfigurableProviders()')
527
528 const handle = ((): void => void dispose()) as DirectoryRegistrationHandle
529 handle.replace = (next: readonly LlmConfigurableProvider[]): void => {
530 if (disposed) {
531 throw new LlmError('this configurable-provider registration was disposed', 'REGISTRATION_DISPOSED')
532 }
533 commit(next)
534 }
535 return handle
536 }
537
538 /**
539 * List every declared configurable provider, registered or dormant.
540 * @returns detached directory entries in declaration order.
541 */
542 @Remote
543 listConfigurableProviders(): LlmConfigurableProvider[] {
544 return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] }))
545 }
546
547 /**
548 * Offer to interrogate provider endpoints on behalf of the settings
549 * namespace this plugin owns. The namespace is the key because that is what
550 * a configuration surface already holds from the configurable-provider
551 * directory, and because a provider being *added* has no route to name yet.
552 * Disposed with the fiber.
553 * @param settingsNs - the namespace whose profiles this discovery serves.
554 * @param discover - interrogates one endpoint and must honor the supplied signal.
555 * @returns the disposer that withdraws the offer.
556 */
557 registerModelDiscovery(
558 settingsNs: string,
559 discover: (
560 request: LlmModelDiscoveryRequest,
561 signal?: AbortSignal,
562 ) => Promise<readonly LlmDiscoveredModel[]>,
563 ): () => void {
564 const dispose = this.ctx.effect(function* (this: LlmRuntime) {
565 if (settingsNs.length === 0) {
566 throw new LlmError('model discovery needs a non-empty settings namespace', 'INVALID_DISCOVERY')
567 }
568 if (this.discoveries.has(settingsNs)) {
569 throw new LlmError(`model discovery for "${settingsNs}" is already registered`, 'DUPLICATE_DISCOVERY')
570 }
571 this.discoveries.set(settingsNs, discover)
572 yield () => {
573 this.discoveries.delete(settingsNs)
574 }
575 }.bind(this), 'llm.registerModelDiscovery()')
576 return () => void dispose()
577 }
578
579 /**
580 * Interrogate one provider endpoint for the models it advertises. The
581 * request describes a draft, not a stored route, so nothing here reads or
582 * writes settings or credentials — the caller owns both, and the reply is
583 * candidate metadata a surface may offer for adoption.
584 * @param settingsNs - namespace whose registered discovery serves this draft.
585 * @param request - the endpoint, protocol, and one-shot credential to use.
586 * @param signal - caller cancellation.
587 * @returns the advertised models, deduplicated in endpoint order.
588 */
589 async discoverModels(
590 settingsNs: string,
591 request: LlmModelDiscoveryRequest,
592 signal?: AbortSignal,
593 ): Promise<LlmDiscoveredModel[]> {
594 const discover = this.discoveries.get(settingsNs)
595 if (discover === undefined) {
596 throw new LlmError(`no model discovery is registered for "${settingsNs}"`, 'NO_DISCOVERY')
597 }
598 // One of the two identifies what to describe: a route the adapter knows, or
599 // an endpoint to ask. Neither leaves nothing to answer about.
600 if ((request.provider ?? '').length === 0 && (request.baseURL ?? '').length === 0) {
601 throw new LlmError('model discovery needs a provider route or a baseURL', 'INVALID_DISCOVERY')
602 }
603 const discovered = signal === undefined
604 ? await discover(request)
605 : await discover(request, signal)
606 const seen = new Set<string>()
607 const models: LlmDiscoveredModel[] = []
608 for (const model of discovered) {
609 if (typeof model.id !== 'string' || model.id.length === 0 || seen.has(model.id)) continue
610 seen.add(model.id)
611 models.push({
612 id: model.id,
613 ...model.name === undefined ? {} : { name: model.name },
614 ...model.contextWindow === undefined ? {} : { contextWindow: model.contextWindow },
615 ...model.maxTokens === undefined ? {} : { maxTokens: model.maxTokens },
616 ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] },
617 })
618 }
619 return models
620 }
621
622 /**
623 * Remote adapter for one draft provider interrogation.
624 * @param settingsNs - namespace whose registered discovery serves this draft.
625 * @param request - endpoint, protocol, and one-shot credential to use.
626 * @param signal - caller cancellation supplied by the Remote carrier.
627 * @returns advertised models in endpoint order.
628 * @throws RemoteError with `llm/model-discovery-rejected` when discovery refuses or fails.
629 */
630 @Remote('discoverModels')
631 async remoteDiscoverModels(
632 settingsNs: string,
633 request: LlmModelDiscoveryRequest,
634 signal: AbortSignal,
635 ): Promise<LlmDiscoveredModel[]> {
636 try {
637 return await this.discoverModels(settingsNs, request, signal)
638 } catch (error: unknown) {
639 throw new RemoteError(
640 'llm/model-discovery-rejected',
641 error instanceof Error ? error.message : String(error),
642 {
643 settingsNs,
644 ...request.baseURL === undefined ? {} : { baseURL: request.baseURL },
645 },
646 { cause: error },
647 )
648 }
649 }
650
651 /**
652 * Resolve the retry policy captured when one provider route was registered.
653 * @param provider - registered provider route to inspect.
654 * @returns the provider-owned policy, with normal defaults already resolved.
655 */
656 providerRetryPolicy(provider: string): ResolvedRetryPolicy {
657 return this.registration(provider).retryPolicy
658 }
659
660 /**
661 * Resolve provider-side request-image pricing for one exact route, or
662 * `undefined` when the provider is unregistered or declares none. Unknown
663 * providers degrade to `undefined` rather than throwing because callers
664 * price durable history whose route may no longer be mounted.
665 * @param provider - provider route named by a request header.
666 * @param model - exact model id named by the same header.
667 * @returns the owning adapter's image pricing for the route, when declared.
668 */
669 imageRequestPricing(provider: string, model: string): LlmImageRequestPricing | undefined {
670 return this.adapters.get(provider)?.adapter.imageRequestPricing(provider, model)
671 }
672
673 /**
674 * Resolve the exact text one durable file occurrence contributes to every
675 * provider request in the current execution environment.
676 * @param ref - durable verbatim file reference from model history.
677 * @returns the same deterministic handle text used at adapter dispatch.
678 */
679 fileRequestText(ref: FileAttachmentRef): string {
680 return fileHandleText(ref, this.fileReadPath(ref))
681 }
682
683 /** Detach typed adapter-owned modality metadata. */
684 private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined {
685 return modalities === undefined ? undefined : [...modalities]
686 }
687
688 /**
689 * Discover models advertised by one registered provider. Catalog membership
690 * does not constrain core routing. Catalog-driven entry points may restrict
691 * selection and submission to the advertised models.
692 * @param provider - registered provider route to inspect.
693 * @returns detached model metadata in adapter-preferred order.
694 */
695 async listModels(provider: string): Promise<LlmModelInfo[]> {
696 const adapter = this.registration(provider).adapter
697 const models = await adapter.listModels(provider)
698 const seen = new Set<string>()
699 return models.map((model) => {
700 if (
701 typeof model.provider !== 'string'
702 || model.provider !== provider
703 || typeof model.id !== 'string'
704 || model.id.length === 0
705 || typeof model.name !== 'string'
706 || model.name.length === 0
707 || (model.description !== undefined && typeof model.description !== 'string')
708 || seen.has(model.id)
709 ) {
710 throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG')
711 }
712 seen.add(model.id)
713 const inputModalities = this.detachedModalities(model.inputModalities)
714 return {
715 provider: model.provider,
716 id: model.id,
717 name: model.name,
718 ...model.description === undefined ? {} : { description: model.description },
719 ...inputModalities === undefined ? {} : { inputModalities },
720 }
721 })
722 }
723
724 /**
725 * Resolve and validate all metadata from the adapter that owns one exact
726 * route. The result is detached from adapter-owned objects; catalog
727 * membership remains advisory and does not control request routing.
728 * @param provider - registered provider route to inspect.
729 * @param model - exact model id passed to the adapter.
730 * @param signal - optional cancellation for adapter-owned asynchronous lookup.
731 * @returns exact model identity plus available context and reasoning metadata.
732 */
733 async resolveModelInfo(
734 provider: string,
735 model: string,
736 signal?: AbortSignal,
737 ): Promise<LlmResolvedModelInfo> {
738 return this.resolveModelInfoFor(this.registration(provider), model, signal)
739 }
740
741 private async resolveModelInfoFor(
742 registration: AdapterRegistration,
743 model: string,
744 signal?: AbortSignal,
745 ): Promise<LlmResolvedModelInfo> {
746 const resolved = await registration.adapter.resolveModel(registration.provider.id, model, signal)
747 return this.normalizeModelInfo(registration, model, resolved)
748 }
749
750 /** Validate and detach one adapter-returned exact model result. */
751 private normalizeModelInfo(
752 registration: AdapterRegistration,
753 model: string,
754 resolved: LlmResolvedModelInfo,
755 ): LlmResolvedModelInfo {
756 const provider = registration.provider.id
757 if (
758 typeof resolved.provider !== 'string'
759 || resolved.provider !== provider
760 || typeof resolved.id !== 'string'
761 || resolved.id !== model
762 || typeof resolved.name !== 'string'
763 || resolved.name.length === 0
764 || (resolved.description !== undefined && typeof resolved.description !== 'string')
765 ) {
766 throw new LlmError(
767 `adapter returned invalid exact model metadata for provider "${provider}" model "${model}"`,
768 'INVALID_MODEL_INFO',
769 )
770 }
771 const context = resolved.context
772 if (context !== undefined && (!Number.isInteger(context.contextWindow) || context.contextWindow <= 0)) {
773 throw new LlmError(
774 `adapter returned invalid context metadata for provider "${provider}" model "${model}"`,
775 'INVALID_MODEL_CONTEXT',
776 )
777 }
778 // Capability metadata rides through: an explicit modality omission is
779 // negative capability downstream preflights act on (image admission).
780 const inputModalities = this.detachedModalities(resolved.inputModalities)
781 // Widened: adapters derive this mode from catalog config, so the value is checked as a string.
782 const systemPromptUpdate: string | undefined = resolved.systemPromptUpdate
783 if (systemPromptUpdate !== undefined && systemPromptUpdate !== 'in-history') {
784 throw new LlmError(
785 `adapter returned invalid system prompt update mode for provider "${provider}" model "${model}"`,
786 'INVALID_MODEL_INFO',
787 )
788 }
789 // Widened for the same reason: catalog config supplies the tool update mode as text.
790 const toolUpdate: string | undefined = resolved.toolUpdate
791 if (toolUpdate !== undefined && toolUpdate !== 'in-history' && toolUpdate !== 'addition-only') {
792 throw new LlmError(
793 `adapter returned invalid tool update mode for provider "${provider}" model "${model}"`,
794 'INVALID_MODEL_INFO',
795 )
796 }
797 const defaultMaxTokens = resolved.defaultMaxTokens
798 if (defaultMaxTokens !== undefined
799 && (!Number.isSafeInteger(defaultMaxTokens) || defaultMaxTokens <= 0)) {
800 throw new LlmError(
801 `adapter returned invalid default maxTokens for provider "${provider}" model "${model}"`,
802 'INVALID_MODEL_MAX_TOKENS',
803 )
804 }
805 const info: LlmResolvedModelInfo = {
806 provider,
807 id: model,
808 name: resolved.name,
809 ...resolved.description === undefined ? {} : { description: resolved.description },
810 ...inputModalities === undefined ? {} : { inputModalities },
811 ...context === undefined ? {} : { context: { contextWindow: context.contextWindow } },
812 ...defaultMaxTokens === undefined ? {} : { defaultMaxTokens },
813 ...resolved.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: resolved.systemPromptUpdate },
814 ...resolved.toolUpdate === undefined ? {} : { toolUpdate: resolved.toolUpdate },
815 }
816 const reasoning = resolved.reasoning
817 if (reasoning === undefined) return info
818 if (reasoning.efforts.length === 0) {
819 throw new LlmError(
820 `adapter returned invalid reasoning metadata for provider "${provider}" model "${model}"`,
821 'INVALID_MODEL_REASONING',
822 )
823 }
824 const seen = new Set<string>()
825 const efforts = reasoning.efforts.map((effort) => {
826 if (
827 typeof effort.id !== 'string'
828 || effort.id.length === 0
829 || typeof effort.name !== 'string'
830 || effort.name.length === 0
831 || (effort.description !== undefined && typeof effort.description !== 'string')
832 || seen.has(effort.id)
833 ) {
834 throw new LlmError(
835 `adapter returned invalid or duplicate reasoning effort metadata for provider "${provider}" model "${model}"`,
836 'INVALID_MODEL_REASONING',
837 )
838 }
839 seen.add(effort.id)
840 return {
841 id: effort.id,
842 name: effort.name,
843 ...effort.description === undefined ? {} : { description: effort.description },
844 }
845 })
846 if (reasoning.defaultEffort !== undefined && !seen.has(reasoning.defaultEffort)) {
847 throw new LlmError(
848 `adapter returned an unknown default reasoning effort for provider "${provider}" model "${model}"`,
849 'INVALID_MODEL_REASONING',
850 )
851 }
852 return {
853 ...info,
854 reasoning: {
855 efforts,
856 ...reasoning.defaultEffort === undefined ? {} : { defaultEffort: reasoning.defaultEffort },
857 },
858 }
859 }
860
861 /**
862 * Validate a conversation call config against its exact model capability and
863 * materialize adapter-configured defaults. Unsupported explicit efforts
864 * reject before provider I/O; no clamping or aliasing is performed. This
865 * standalone query does not bind a later dispatch; use {@link prepareCall}
866 * when logging and streaming must share one adapter registration.
867 * @param config - provider/model route and optional request controls.
868 * @param signal - optional cancellation for adapter-owned capability lookup.
869 * @returns a detached config only when a default must be materialized.
870 */
871 async resolveCallConfig(config: LlmCallConfig, signal?: AbortSignal): Promise<LlmCallConfig> {
872 return (await this.resolveCallFor(this.registration(config.provider), config, signal)).config
873 }
874
875 private async resolveCallFor(
876 registration: AdapterRegistration,
877 config: LlmCallConfig,
878 signal?: AbortSignal,
879 ): Promise<{ config: LlmCallConfig; context?: LlmModelContext; modelInfo: LlmResolvedModelInfo }> {
880 const info = await this.resolveModelInfoFor(registration, config.model, signal)
881 return this.resolveCallWithInfo(config, info)
882 }
883
884 /** Validate request controls against one already-bound exact model result. */
885 private resolveCallWithInfo(
886 config: LlmCallConfig,
887 info: LlmResolvedModelInfo,
888 ): { config: LlmCallConfig; context?: LlmModelContext; modelInfo: LlmResolvedModelInfo } {
889 const defaulted = config.maxTokens === undefined && info.defaultMaxTokens !== undefined
890 ? { ...config, maxTokens: info.defaultMaxTokens }
891 : config
892 const reasoning = info.reasoning
893 const requested = defaulted.reasoningEffort
894 let resolvedConfig = defaulted
895 if (reasoning === undefined) {
896 if (requested !== undefined) {
897 throw new LlmError(
898 `provider "${config.provider}" model "${config.model}" does not support reasoning effort "${requested}"`,
899 'UNSUPPORTED_REASONING_EFFORT',
900 )
901 }
902 } else {
903 const effective = requested ?? reasoning.defaultEffort
904 if (effective !== undefined) {
905 if (!reasoning.efforts.some(effort => effort.id === effective)) {
906 throw new LlmError(
907 `provider "${config.provider}" model "${config.model}" does not support reasoning effort "${effective}"`,
908 'UNSUPPORTED_REASONING_EFFORT',
909 )
910 }
911 if (requested !== effective) resolvedConfig = { ...defaulted, reasoningEffort: effective }
912 }
913 }
914 return {
915 config: resolvedConfig,
916 ...info.context === undefined ? {} : { context: info.context },
917 modelInfo: info,
918 }
919 }
920
921 /**
922 * Resolve one call under its current adapter registration. The returned
923 * one-shot handle keeps that registration across header logging and dispatch,
924 * so HMR cannot combine one adapter's capability result with another adapter.
925 * @param config - provider/model route and optional request controls.
926 * @param signal - optional cancellation for adapter-owned capability lookup.
927 * @returns a prepared config and its registration-bound stream entry point.
928 */
929 async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<PreparedLlmCall> {
930 const registration = this.registration(config.provider)
931 const adapterCall = await registration.adapter.prepareCall(config.provider, config.model, signal)
932 const modelInfo = this.normalizeModelInfo(registration, config.model, adapterCall.model)
933 const resolved = this.resolveCallWithInfo(config, modelInfo)
934 const resolvedConfig = deepFreeze(structuredClone(resolved.config))
935 const context = resolved.context === undefined
936 ? undefined
937 : deepFreeze(structuredClone(resolved.context))
938 const adapterDefaults = deepFreeze<LlmCallConfigAdapterDefaults>({
939 ...config.reasoningEffort === undefined && resolvedConfig.reasoningEffort !== undefined
940 ? { reasoningEffort: true }
941 : {},
942 ...config.maxTokens === undefined && resolvedConfig.maxTokens !== undefined
943 ? { maxTokens: true }
944 : {},
945 })
946 let dispatched = false
947 return Object.freeze({
948 config: resolvedConfig,
949 retryPolicy: registration.retryPolicy,
950 adapterDefaults,
951 ...context === undefined ? {} : { context },
952 ...modelInfo.inputModalities === undefined
953 ? {}
954 : { inputModalities: Object.freeze([...modelInfo.inputModalities]) },
955 ...modelInfo.systemPromptUpdate === undefined ? {} : { systemPromptUpdate: modelInfo.systemPromptUpdate },
956 ...modelInfo.toolUpdate === undefined ? {} : { toolUpdate: modelInfo.toolUpdate },
957 stream: (options: GenerateOptions): AsyncIterable<StreamChunk> => {
958 if (dispatched) {
959 throw new LlmError('a prepared LLM call can only be dispatched once', 'INVALID_PREPARED_CALL')
960 }
961 if (!callConfigEquals(options, resolvedConfig)) {
962 throw new LlmError(
963 'prepared LLM call config changed before adapter dispatch',
964 'INVALID_PREPARED_CALL',
965 )
966 }
967 dispatched = true
968 return this.streamWithRegistration(options, {
969 registration,
970 config: resolvedConfig,
971 modelInfo,
972 dispatch: options => adapterCall.stream(options),
973 })
974 },
975 })
976 }
977
978 private registration(provider: string): AdapterRegistration {
979 const registration = this.adapters.get(provider)
980 if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER')
981 return registration
982 }
983
984 /** Remove replay state whose historical route is owned by another adapter. */
985 private forAdapter(options: GenerateOptions, adapter: LlmAdapter): GenerateOptions {
986 const messages: RequestMessage[] = options.messages.map((message) => {
987 if (message.role !== 'assistant') return message
988 const source = message.source
989 if (source.replayState === undefined) return message
990 if (this.adapters.get(source.provider)?.adapter === adapter) return message
991 return freezeMessage({
992 ...message,
993 source: { kind: 'model', provider: source.provider, model: source.model },
994 })
995 })
996 if (messages.every((message, index) => message === options.messages[index])) return options
997 const filtered = { ...options, messages }
998 return Object.isFrozen(options) ? deepFreeze(filtered) : filtered
999 }
1000
1001 /**
1002 * Resolve the current execution-world read path of one durable file
1003 * reference through the mounted attachment and filesystem providers.
1004 */
1005 private fileReadPath(ref: FileAttachmentRef): string | undefined {
1006 let hostPath: string | undefined
1007 try {
1008 hostPath = this.ctx.get('attachments')?.fileHostPath(ref)
1009 } catch {
1010 // A malformed durable reference degrades this occurrence to the no-path
1011 // handle instead of failing every later request over the same log.
1012 return undefined
1013 }
1014 if (hostPath === undefined) return undefined
1015 // Structural face: dsh-llm cannot depend on the filesystem package, and
1016 // only this one mapping method is consumed.
1017 const fs = this.ctx.get('fs') as { processPathFromHostPath(hostPath: string): string | undefined } | undefined
1018 return fs?.processPathFromHostPath(hostPath)
1019 }
1020
1021 /**
1022 * Final adapter boundary. Adapter selection, dispatch, iterator construction,
1023 * and iteration failures become one terminal failure chunk. Middleware and
1024 * downstream consumer failures remain thrown plugin or consumer errors.
1025 */
1026 private async * adapterStream(
1027 options: GenerateOptions,
1028 prepared?: PreparedDispatch,
1029 ): AsyncGenerator<StreamChunk> {
1030 let iterator: AsyncIterator<StreamChunk>
1031 try {
1032 const registration = prepared?.registration ?? this.registration(options.provider)
1033 const adapter = registration.adapter
1034 let modelInfo: LlmResolvedModelInfo
1035 let resolvedConfig: LlmCallConfig
1036 let dispatch: (options: GenerateOptions) => AsyncIterable<StreamChunk>
1037 if (prepared === undefined) {
1038 const adapterCall = await adapter.prepareCall(options.provider, options.model, options.signal)
1039 modelInfo = this.normalizeModelInfo(registration, options.model, adapterCall.model)
1040 resolvedConfig = this.resolveCallWithInfo(options, modelInfo).config
1041 dispatch = options => adapterCall.stream(options)
1042 } else {
1043 modelInfo = prepared.modelInfo
1044 resolvedConfig = prepared.config
1045 dispatch = prepared.dispatch
1046 }
1047 if (prepared !== undefined && !callConfigEquals(options, resolvedConfig)) {
1048 throw new LlmError(
1049 'prepared LLM call config changed before adapter dispatch',
1050 'INVALID_PREPARED_CALL',
1051 )
1052 }
1053 const resolvedOptions = callConfigEquals(options, resolvedConfig)
1054 ? options
1055 : Object.isFrozen(options)
1056 ? deepFreeze({ ...options, ...resolvedConfig })
1057 : { ...options, ...resolvedConfig }
1058 // Files are never dispatched natively: every route receives handle text.
1059 let projectedMessages: readonly RequestMessage[] = resolvedOptions.messages
1060 if (projectedMessages.some(message => contentHasFile(message.content))) {
1061 projectedMessages = projectFilesToText(projectedMessages, ref => this.fileReadPath(ref))
1062 }
1063 if (modelInfo.inputModalities !== undefined
1064 && !modelInfo.inputModalities.includes('image')
1065 && projectedMessages.some(message => contentHasImage(message.content))) {
1066 projectedMessages = projectImagesForTextModel(projectedMessages)
1067 }
1068 // Tool changes are logged on every route; the route's declared mode selects what it receives.
1069 const projectedTools = projectToolUpdates(projectedMessages, resolvedOptions.tools, modelInfo.toolUpdate, resolvedOptions.toolHistory)
1070 projectedMessages = projectedTools.messages
1071 let projectedOptions = resolvedOptions
1072 if (projectedMessages !== resolvedOptions.messages || projectedTools.tools !== resolvedOptions.tools) {
1073 projectedOptions = {
1074 ...resolvedOptions,
1075 messages: projectedMessages as RequestMessage[],
1076 ...projectedTools.tools === undefined ? {} : { tools: projectedTools.tools as ToolSchema[] },
1077 }
1078 if (Object.isFrozen(resolvedOptions)) deepFreeze(projectedOptions)
1079 }
1080 const stream = dispatch(this.forAdapter(projectedOptions, adapter))
1081 iterator = stream[Symbol.asyncIterator]()
1082 } catch (error: unknown) {
1083 yield adapterFailureChunk(error, options.signal)
1084 return
1085 }
1086
1087 let completed = false
1088 try {
1089 while (true) {
1090 let item: { done: true } | { done: false; value: StreamChunk }
1091 try {
1092 const next = await iterator.next()
1093 item = next.done
1094 ? { done: true }
1095 : { done: false, value: next.value }
1096 } catch (error: unknown) {
1097 completed = true
1098 yield adapterFailureChunk(error, options.signal)
1099 return
1100 }
1101 if (item.done) {
1102 completed = true
1103 return
1104 }
1105 // End the adapter-owned try before yielding: consumer/middleware
1106 // failures resumed into this generator must remain thrown.
1107 yield item.value
1108 }
1109 } finally {
1110 if (!completed) {
1111 const close = iterator.return?.bind(iterator)
1112 if (close) await close()
1113 }
1114 }
1115 }
1116
1117 /**
1118 * Stream one model call as raw chunks (token-level deltas). Replay state is
1119 * retained only when the same adapter instance owns its historical provider
1120 * and the target provider. Final adapter selection remains fixed through
1121 * asynchronous exact-model resolution and dispatch. Adapter selection,
1122 * dispatch, and iteration failures become terminal `error` or `aborted`
1123 * finish chunks; middleware, nested-call, cleanup, and consumer failures
1124 * remain thrown.
1125 * @param options - the full request; `options.provider` selects the adapter.
1126 * @returns the chunk stream, possibly wrapped by `llm/stream` listeners.
1127 */
1128 stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
1129 return this.streamWithRegistration(options)
1130 }
1131
1132 private streamWithRegistration(
1133 options: GenerateOptions,
1134 prepared?: PreparedDispatch,
1135 ): AsyncIterable<StreamChunk> {
1136 return this.ctx.waterfall(
1137 this,
1138 'llm/stream',
1139 options,
1140 () => this.adapterStream(options, prepared),
1141 )
1142 }
1143}
1144
1145/** Convert one adapter throw into the stream protocol's terminal outcome. */
1146function adapterFailureChunk(error: unknown, signal?: AbortSignal): StreamChunk {
1147 const failure = normalizeLlmFailure(error)
1148 return {
1149 type: 'finish',
1150 reason: signal?.aborted || failure.code === 'ABORTED'
1151 ? { kind: 'aborted', failure }
1152 : { kind: 'error', failure },
1153 }
1154}
1155
1156interface AdapterRegistration {
1157 readonly adapter: LlmAdapter
1158 readonly provider: LlmProviderInfo
1159 readonly retryPolicy: ResolvedRetryPolicy
1160}
1161
1162interface PreparedDispatch {
1163 readonly registration: AdapterRegistration
1164 readonly config: LlmCallConfig
1165 readonly modelInfo: LlmResolvedModelInfo
1166 readonly dispatch: (options: GenerateOptions) => AsyncIterable<StreamChunk>
1167}
1168
1169export default LlmRuntime