1
/**2
* LLM service: adapter registry with a waterfall-interceptable streaming call3
* API. Exports the `LlmRuntime` default, the abstract `LlmAdapter` for4
* provider backends, and `BlockAssembler` for chunk assembly.5
*6
* @module @deepseek-ai/dsh-llm7
*/9
import { Context } from '@deepseek-ai/cordis'10
import { Remote, RemoteError, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol'11
import { deepFreeze } from '@deepseek-ai/dsh-util-values'12
import 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'30
import { freezeMessage } from './message.ts'31
import { resolveRetryPolicy } from './retry-policy.ts'32
import type { ResolvedRetryPolicy } from './retry-policy.ts'33
import type { ProviderRequestId } from './brand.ts'34
import { callConfigEquals } from './call-config.ts'35
import type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts'36
import { HarnessError, INVALID_CREDENTIAL_CODE } from './error.ts'37
import { normalizeLlmFailure } from './adapter-failure.ts'38
import { normalizeApiKey } from './api-key.ts'39
import {40
contentHasFile, contentHasImage, fileHandleText, projectFilesToText, projectImagesForTextModel, projectToolUpdates,41
} from './content.ts'42
import type { FileAttachmentRef } from '@deepseek-ai/dsh-attachment'44
export * from './attribution.ts'45
export * from './brand.ts'46
export * from './error.ts'47
export * from './api-key.ts'48
export * from './types.ts'49
export * from './content.ts'50
export * from './assistant-stream.ts'51
export * from './message.ts'52
export * from './retry-policy.ts'53
export { BlockAssembler } from './assembler.ts'54
export { callConfigEquals, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts'55
export type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts'57
declare module '@deepseek-ai/cordis' {58
interface Context {59
llm: LlmRuntime60
}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 resolved66
* adapter's stream, or yield your own chunks to short-circuit.67
* @param options - the full request. A LOOP-built request carries the68
* process-local {@link markAgentLoopRequest} identity and arrives deep-frozen69
* (mutation throws): its content is a pure function of the session log (the70
* reconstructability Agent Note), so listeners read it, never rewrite it.71
* Hand-built calls do not carry that marker; callers own their request72
* inputs and must keep them unchanged until the stream settles.73
* @mode waterfall74
*/75
'llm/stream'(this: LlmRuntime, options: GenerateOptions, next: () => AsyncIterable<StreamChunk>): AsyncIterable<StreamChunk>77
}78
}80
/** Structured provider facts and cause accepted by {@link LlmError}. */81
export interface LlmErrorOptions extends ErrorOptions {82
/** Valid HTTP status observed at the provider boundary. */83
status?: number84
/** Positive finite provider-requested delay in milliseconds. */85
providerRetryAfterMs?: number86
/** Non-empty opaque provider request id. */87
requestId?: ProviderRequestId88
/** Positive count of additional oldest retained image occurrences to offload; only with `IMAGE_OFFLOAD_REQUIRED`. */89
offloadImages?: number90
}92
/**93
* Typed error for LLM-related failures. Extends {@link HarnessError}, so the94
* `code` string (e.g. `AUTH`, `RATE_LIMIT`, `NO_ADAPTER`) is shared taxonomy.95
*/96
export class LlmError extends HarnessError {97
/** Serializable facts retained beside this live Error. */98
readonly failure: LlmFailure100
/**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 !== undefined109
&& (!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 !== undefined113
&& (!Number.isFinite(options.providerRetryAfterMs) || options.providerRetryAfterMs <= 0)) {114
throw new Error('LlmError providerRetryAfterMs must be a positive finite number')115
}116
if (options?.requestId !== undefined117
&& (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
}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 shell137
* export, all of which pick up surrounding whitespace, so trimming is silent.138
* Anything else fails here rather than inside `fetch`, whose ByteString139
* refusal names a UTF-16 code point instead of the setting to change. The key140
* never enters the message: `ref` names where to fix it, and echoing any part141
* 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 predicate144
* module stays dependency-free; both adapters share this one diagnosis instead145
* 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
*/151
export function assertUsableApiKey(raw: string, pkg: string, ref: string): string {152
const checked = normalizeApiKey(raw)153
if (checked.ok) return checked.value154
// 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 a156
// composition that mounts no credentials seam at all, where directing the157
// 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
}168
/** One model call whose config and adapter registration were resolved together. */169
export interface PreparedLlmCall {170
/** Detached, deep-frozen config with any adapter-owned default materialized. */171
readonly config: LlmCallConfig172
/** Immutable retry policy captured with the adapter registration. */173
readonly retryPolicy: ResolvedRetryPolicy174
/** Detached context metadata resolved with the registration-bound call. */175
readonly context?: LlmModelContext176
/** 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?: SystemPromptUpdate180
/** Exact model tool update mode captured with the adapter dispatch generation. */181
readonly toolUpdate?: ToolUpdate182
/** Config fields materialized by the captured adapter rather than proposed by the caller. */183
readonly adapterDefaults: LlmCallConfigAdapterDefaults184
/**185
* Dispatch this call once through the registration captured during186
* 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
}194
/** One adapter-owned model-resolution generation bound to its eventual stream call. */195
export interface PreparedAdapterCall {196
/** Exact model metadata from the same adapter generation as {@link stream}. */197
readonly model: LlmResolvedModelInfo198
/** Dispatch through that generation without re-reading dynamic connection facts. */199
stream(options: GenerateOptions): AsyncIterable<StreamChunk>200
}202
/**203
* Provider-wire adapter for the harness message and stream vocabulary. Register implementations204
* with `ctx.llm.registerAdapter(providers, adapter)`. Every provider HTTP request must include205
* `attributionHeaders()`; prove the headers are added in the wire request or library header hook. The direct-fetch206
* DeepSeek and library-backed pi-ai adapters meet this contract through different internals.207
*/208
export 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
}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 undefined225
}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 neutral230
* estimate. Implementations must answer synchronously without I/O; the231
* 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 undefined238
}240
/**241
* List models this adapter can currently advertise for one owned provider.242
* Core routing accepts unlisted model ids; catalog-driven entry points such243
* as the GUI may require membership. Adapters used there must advertise244
* 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
}252
/**253
* Resolve all metadata available for one exact model. This query is254
* 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; asynchronous258
* 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
}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 and272
* 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
}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
}293
/**294
* What {@link LlmRuntime.registerAdapter} returns: the disposer, plus an295
* atomic route replacement for the same adapter instance.296
*/297
export interface AdapterRegistrationHandle {298
/** Release every route this registration currently holds. */299
(): void300
/**301
* Replace this registration's routes with `providers`, keeping the same302
* adapter instance. The candidate set is validated in full first — a303
* conflict with another adapter, an invalid name, or bad provider metadata304
* throws and leaves the current routes untouched — and the swap itself is305
* one synchronous section, so no request can observe a gap. An empty array306
* is legal here (a settings section that emptied holds zero routes while307
* staying registered), unlike an empty initial registration.308
*309
* Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration310
* 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[]): void315
}317
/**318
* A live configurable-provider registration, disposable and atomically319
* replaceable — the directory counterpart of {@link AdapterRegistrationHandle}.320
*/321
export interface DirectoryRegistrationHandle {322
/** Withdraw every entry this registration currently holds. */323
(): void324
/**325
* Replace this registration's entries with `entries`. The candidate set is326
* validated in full first — an entry another registration already declares,327
* a duplicate within the set, or invalid metadata throws and leaves the328
* current entries untouched — and the swap is one synchronous section, so no329
* reader observes a gap. An empty array is legal here, unlike an empty330
* initial registration.331
*332
* Throws `LlmError` with code `REGISTRATION_DISPOSED` once the registration333
* has been disposed.334
*/335
replace(entries: readonly LlmConfigurableProvider[]): void336
}338
/**339
* The abstract `llm` service: an adapter registry plus a streaming model-call340
* API, interceptable via the `llm/stream` waterfall.341
*/342
export 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
>()350
constructor(ctx: Context) {351
super(ctx, 'llm')352
}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 later357
// listeners. Registry notifications are non-vetoing, so contain each358
// 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 rejection364
// 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
}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
}381
/**382
* Register an adapter for the given provider routes. Throws `LlmError` with code383
* `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, and391
// 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 = false396
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 = true401
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 is407
// synchronous fire-and-forget — discard the (always-resolved) promise.408
const handle = (() => void dispose()) as AdapterRegistrationHandle409
handle.replace = (next: string[]): void => {410
// Registering here would leak: the effect's disposer already ran, so411
// 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 handle418
}420
/**421
* Validate one candidate route set for `adapter`, treating routes this422
* registration already holds as available. Nothing is mutated: a rejected423
* 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 registrations447
}449
/**450
* Swap this registration's routes for the prepared ones in one synchronous451
* section, so no observer can see the registry between the release and the452
* re-registration. The route set's one mutation point is also where453
* `llm/adapters-updated` is published, so a `replace` announces itself454
* 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
}466
/**467
* Describe provider routes with a registered adapter.468
* @returns detached provider metadata in registration order.469
*/470
@Remote471
listProviders(): LlmProviderInfo[] {472
return [...this.adapters.values()].map(({ provider }) => ({ ...provider }))473
}475
/**476
* Declare provider routes an adapter plugin can activate through477
* configuration. Registration is all-or-nothing: an empty list, invalid478
* entry, or a provider already declared by any registration throws479
* `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 = false486
/**487
* Validate a candidate set in full against everything this registration488
* does not already hold, then publish it. Nothing is written until the489
* whole set passes, so a refused candidate leaves the current entries in490
* place — the property that makes `replace` a swap rather than a491
* 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 = detached512
this.emitAdaptersUpdated()513
}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 = true522
for (const entry of held) this.directory.delete(entry.provider)523
held = []524
this.emitAdaptersUpdated()525
}526
}.bind(this), 'llm.registerConfigurableProviders()')528
const handle = ((): void => void dispose()) as DirectoryRegistrationHandle529
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 handle536
}538
/**539
* List every declared configurable provider, registered or dormant.540
* @returns detached directory entries in declaration order.541
*/542
@Remote543
listConfigurableProviders(): LlmConfigurableProvider[] {544
return [...this.directory.values()].map(entry => ({ ...entry, settingsPath: [...entry.settingsPath] }))545
}547
/**548
* Offer to interrogate provider endpoints on behalf of the settings549
* namespace this plugin owns. The namespace is the key because that is what550
* a configuration surface already holds from the configurable-provider551
* 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
}579
/**580
* Interrogate one provider endpoint for the models it advertises. The581
* request describes a draft, not a stored route, so nothing here reads or582
* writes settings or credentials — the caller owns both, and the reply is583
* 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, or599
// 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 === undefined604
? 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)) continue610
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 models620
}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
}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).retryPolicy658
}660
/**661
* Resolve provider-side request-image pricing for one exact route, or662
* `undefined` when the provider is unregistered or declares none. Unknown663
* providers degrade to `undefined` rather than throwing because callers664
* 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
}673
/**674
* Resolve the exact text one durable file occurrence contributes to every675
* 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
}683
/** Detach typed adapter-owned modality metadata. */684
private detachedModalities(modalities: readonly ModelModality[] | undefined): ModelModality[] | undefined {685
return modalities === undefined ? undefined : [...modalities]686
}688
/**689
* Discover models advertised by one registered provider. Catalog membership690
* does not constrain core routing. Catalog-driven entry points may restrict691
* 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).adapter697
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 !== provider703
|| typeof model.id !== 'string'704
|| model.id.length === 0705
|| typeof model.name !== 'string'706
|| model.name.length === 0707
|| (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
}724
/**725
* Resolve and validate all metadata from the adapter that owns one exact726
* route. The result is detached from adapter-owned objects; catalog727
* 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
}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
}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.id757
if (758
typeof resolved.provider !== 'string'759
|| resolved.provider !== provider760
|| typeof resolved.id !== 'string'761
|| resolved.id !== model762
|| typeof resolved.name !== 'string'763
|| resolved.name.length === 0764
|| (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.context772
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 is779
// 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.systemPromptUpdate783
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.toolUpdate791
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.defaultMaxTokens798
if (defaultMaxTokens !== undefined799
&& (!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.reasoning817
if (reasoning === undefined) return info818
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 === 0829
|| typeof effort.name !== 'string'830
|| effort.name.length === 0831
|| (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
}861
/**862
* Validate a conversation call config against its exact model capability and863
* materialize adapter-configured defaults. Unsupported explicit efforts864
* reject before provider I/O; no clamping or aliasing is performed. This865
* 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)).config873
}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
}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 !== undefined890
? { ...config, maxTokens: info.defaultMaxTokens }891
: config892
const reasoning = info.reasoning893
const requested = defaulted.reasoningEffort894
let resolvedConfig = defaulted895
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.defaultEffort904
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
}921
/**922
* Resolve one call under its current adapter registration. The returned923
* 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 === undefined936
? undefined937
: deepFreeze(structuredClone(resolved.context))938
const adapterDefaults = deepFreeze<LlmCallConfigAdapterDefaults>({939
...config.reasoningEffort === undefined && resolvedConfig.reasoningEffort !== undefined940
? { reasoningEffort: true }941
: {},942
...config.maxTokens === undefined && resolvedConfig.maxTokens !== undefined943
? { maxTokens: true }944
: {},945
})946
let dispatched = false947
return Object.freeze({948
config: resolvedConfig,949
retryPolicy: registration.retryPolicy,950
adapterDefaults,951
...context === undefined ? {} : { context },952
...modelInfo.inputModalities === undefined953
? {}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 = true968
return this.streamWithRegistration(options, {969
registration,970
config: resolvedConfig,971
modelInfo,972
dispatch: options => adapterCall.stream(options),973
})974
},975
})976
}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 registration982
}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 message988
const source = message.source989
if (source.replayState === undefined) return message990
if (this.adapters.get(source.provider)?.adapter === adapter) return message991
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 options997
const filtered = { ...options, messages }998
return Object.isFrozen(options) ? deepFreeze(filtered) : filtered999
}1001
/**1002
* Resolve the current execution-world read path of one durable file1003
* reference through the mounted attachment and filesystem providers.1004
*/1005
private fileReadPath(ref: FileAttachmentRef): string | undefined {1006
let hostPath: string | undefined1007
try {1008
hostPath = this.ctx.get('attachments')?.fileHostPath(ref)1009
} catch {1010
// A malformed durable reference degrades this occurrence to the no-path1011
// handle instead of failing every later request over the same log.1012
return undefined1013
}1014
if (hostPath === undefined) return undefined1015
// Structural face: dsh-llm cannot depend on the filesystem package, and1016
// only this one mapping method is consumed.1017
const fs = this.ctx.get('fs') as { processPathFromHostPath(hostPath: string): string | undefined } | undefined1018
return fs?.processPathFromHostPath(hostPath)1019
}1021
/**1022
* Final adapter boundary. Adapter selection, dispatch, iterator construction,1023
* and iteration failures become one terminal failure chunk. Middleware and1024
* 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.adapter1034
let modelInfo: LlmResolvedModelInfo1035
let resolvedConfig: LlmCallConfig1036
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).config1041
dispatch = options => adapterCall.stream(options)1042
} else {1043
modelInfo = prepared.modelInfo1044
resolvedConfig = prepared.config1045
dispatch = prepared.dispatch1046
}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
? options1055
: 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.messages1060
if (projectedMessages.some(message => contentHasFile(message.content))) {1061
projectedMessages = projectFilesToText(projectedMessages, ref => this.fileReadPath(ref))1062
}1063
if (modelInfo.inputModalities !== undefined1064
&& !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.messages1071
let projectedOptions = resolvedOptions1072
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
return1085
}1087
let completed = false1088
try {1089
while (true) {1090
let item: { done: true } | { done: false; value: StreamChunk }1091
try {1092
const next = await iterator.next()1093
item = next.done1094
? { done: true }1095
: { done: false, value: next.value }1096
} catch (error: unknown) {1097
completed = true1098
yield adapterFailureChunk(error, options.signal)1099
return1100
}1101
if (item.done) {1102
completed = true1103
return1104
}1105
// End the adapter-owned try before yielding: consumer/middleware1106
// failures resumed into this generator must remain thrown.1107
yield item.value1108
}1109
} finally {1110
if (!completed) {1111
const close = iterator.return?.bind(iterator)1112
if (close) await close()1113
}1114
}1115
}1117
/**1118
* Stream one model call as raw chunks (token-level deltas). Replay state is1119
* retained only when the same adapter instance owns its historical provider1120
* and the target provider. Final adapter selection remains fixed through1121
* 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 failures1124
* 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
}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
}1145
/** Convert one adapter throw into the stream protocol's terminal outcome. */1146
function 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
}1156
interface AdapterRegistration {1157
readonly adapter: LlmAdapter1158
readonly provider: LlmProviderInfo1159
readonly retryPolicy: ResolvedRetryPolicy1160
}1162
interface PreparedDispatch {1163
readonly registration: AdapterRegistration1164
readonly config: LlmCallConfig1165
readonly modelInfo: LlmResolvedModelInfo1166
readonly dispatch: (options: GenerateOptions) => AsyncIterable<StreamChunk>1167
}1169
export default LlmRuntime