feat(acp): advertise and switch llm models
This commit is contained in:
@@ -14,9 +14,14 @@ A second, library-backed implementation of the same seam exists in `@deepseek-ai
|
||||
baseURL: !!js process.env.DEEPSEEK_BASE_URL # default: https://api.deepseek.com
|
||||
thinking: enabled # optional; provider default is enabled
|
||||
reasoningEffort: high # optional; high | max — omitted ⇒ not sent
|
||||
models: # optional; defaults to V4 Flash and V4 Pro
|
||||
- id: deepseek-v4-flash
|
||||
name: DeepSeek V4 Flash
|
||||
- id: private-reasoner
|
||||
description: Company-hosted reasoning model
|
||||
```
|
||||
|
||||
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
The plugin registers the single provider route `deepseek`. A request selects it with `provider: deepseek`; its `model` is passed through as the wire `model` string, so changing DeepSeek models does not require lifecycle-time registration. Omitting `models` advertises `deepseek-v4-flash` and `deepseek-v4-pro`; an explicit list replaces those defaults, while `models: []` advertises none. Catalog entries are exposed through `ctx.llm.listModels('deepseek')` for clients such as ACP editors, but remain advisory: unlisted model ids still pass through unchanged. An omitted entry name defaults to its id. Registering another adapter for `deepseek` throws `LlmError('DUPLICATE_ADAPTER')`.
|
||||
|
||||
`reasoningEffort` is **omitted by default** — when unset, the `reasoning_effort` wire field is not sent and the server applies its own default for the model. The only accepted values are `high` and `max` (DeepSeek's official effort levels). It is meaningful only with thinking enabled (the provider default).
|
||||
|
||||
|
||||
@@ -6,13 +6,23 @@
|
||||
*/
|
||||
|
||||
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import { serializeRequest } from './serialize.ts'
|
||||
import type { RequestDefaults } from './serialize.ts'
|
||||
import { parseSse } from './sse.ts'
|
||||
import { translate } from './translate.ts'
|
||||
import type { WireError } from './types.ts'
|
||||
|
||||
/** One optional model entry advertised by the hand-written adapter. */
|
||||
export interface DeepSeekCatalogModel {
|
||||
/** Wire model id accepted by the configured endpoint. */
|
||||
id: string
|
||||
/** Selector label; defaults to {@link id}. */
|
||||
name?: string
|
||||
/** Optional selector detail for deployments with similar model variants. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Constructor options for {@link DeepSeekAdapter}; the plugin's `apply` resolves them from Config + environment. */
|
||||
export interface DeepSeekAdapterOptions {
|
||||
/** Bearer token sent in the `authorization` header on every request. */
|
||||
@@ -21,6 +31,8 @@ export interface DeepSeekAdapterOptions {
|
||||
baseURL: string
|
||||
/** Request defaults applied to every call (thinking mode, effort). */
|
||||
defaults?: RequestDefaults
|
||||
/** Advisory models exposed to discovery consumers; requests remain unrestricted. */
|
||||
models?: readonly DeepSeekCatalogModel[]
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +61,19 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
super()
|
||||
}
|
||||
|
||||
override providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: 'DeepSeek' }
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve((this.options.models ?? []).map(model => ({
|
||||
provider,
|
||||
id: model.id,
|
||||
name: model.name ?? model.id,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
})))
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
const body = serializeRequest(options, this.options.defaults ?? {})
|
||||
|
||||
|
||||
@@ -9,9 +9,10 @@ import type { Context } from 'cordis'
|
||||
import z from 'schemastery'
|
||||
import type {} from '@deepseek-ai/dsh-llm'
|
||||
import { DeepSeekAdapter } from './adapter.ts'
|
||||
import type { DeepSeekCatalogModel } from './adapter.ts'
|
||||
|
||||
export { DeepSeekAdapter, httpErrorCode } from './adapter.ts'
|
||||
export type { DeepSeekAdapterOptions } from './adapter.ts'
|
||||
export type { DeepSeekAdapterOptions, DeepSeekCatalogModel } from './adapter.ts'
|
||||
export { serializeMessages, serializeRequest } from './serialize.ts'
|
||||
export type { RequestDefaults } from './serialize.ts'
|
||||
export { DONE, parseSse } from './sse.ts'
|
||||
@@ -21,6 +22,11 @@ export type * from './types.ts'
|
||||
export const name = 'llm-deepseek'
|
||||
export const inject = ['llm']
|
||||
|
||||
const DEFAULT_MODELS: DeepSeekCatalogModel[] = [
|
||||
{ id: 'deepseek-v4-flash' },
|
||||
{ id: 'deepseek-v4-pro' },
|
||||
]
|
||||
|
||||
/**
|
||||
* Plugin config, validated by the same-named schemastery schema. Every field
|
||||
* is optional in yml: credentials/endpoint fall back to the environment (a
|
||||
@@ -36,18 +42,45 @@ export interface Config {
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Thinking effort (only meaningful with thinking enabled). */
|
||||
reasoningEffort?: 'high' | 'max'
|
||||
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
|
||||
models?: DeepSeekCatalogModel[]
|
||||
}
|
||||
|
||||
const catalogModel: z<DeepSeekCatalogModel> = z.object({
|
||||
id: z.string().required(),
|
||||
name: z.string(),
|
||||
description: z.string(),
|
||||
})
|
||||
|
||||
export const Config: z<Config> = z.object({
|
||||
apiKey: z.string(),
|
||||
baseURL: z.string(),
|
||||
thinking: z.union(['enabled', 'disabled']),
|
||||
reasoningEffort: z.union(['high', 'max']),
|
||||
models: z.array(catalogModel).default(DEFAULT_MODELS),
|
||||
})
|
||||
|
||||
/** Public API default; the internal endpoint comes from $DEEPSEEK_BASE_URL. */
|
||||
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
/** Resolve, validate, and detach the advisory model catalog. */
|
||||
function resolveModels(models: readonly DeepSeekCatalogModel[] | undefined): DeepSeekCatalogModel[] {
|
||||
const seen = new Set<string>()
|
||||
return (models ?? DEFAULT_MODELS).map((model) => {
|
||||
if (model.id.length === 0) throw new Error('llm-deepseek: catalog model ids must be non-empty')
|
||||
if (model.name !== undefined && model.name.length === 0) {
|
||||
throw new Error(`llm-deepseek: catalog model "${model.id}" has an empty name`)
|
||||
}
|
||||
if (seen.has(model.id)) throw new Error(`llm-deepseek: duplicate catalog model "${model.id}"`)
|
||||
seen.add(model.id)
|
||||
return {
|
||||
id: model.id,
|
||||
...model.name === undefined ? {} : { name: model.name },
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const apiKey = config.apiKey ?? process.env.DEEPSEEK_API_KEY
|
||||
if (apiKey === undefined || apiKey.length === 0) {
|
||||
@@ -61,5 +94,6 @@ export function apply(ctx: Context, config: Config): void {
|
||||
thinking: config.thinking,
|
||||
reasoningEffort: config.reasoningEffort,
|
||||
},
|
||||
models: resolveModels(config.models),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -249,16 +249,73 @@ describe('plugin registration and config', () => {
|
||||
apiKey: 'k',
|
||||
baseURL: server.url,
|
||||
})
|
||||
expect(ctx.llm.providers()).toEqual(['deepseek'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('always owns the deepseek provider', async () => {
|
||||
it('owns the deepseek provider and advertises the default models', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
expect(ctx.llm.providers()).toEqual(['deepseek'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
||||
])
|
||||
})
|
||||
|
||||
it('uses the default model catalog when apply is called directly', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
LlmDeepSeek.apply(ctx, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-flash', name: 'deepseek-v4-flash' },
|
||||
{ provider: 'deepseek', id: 'deepseek-v4-pro', name: 'deepseek-v4-pro' },
|
||||
])
|
||||
})
|
||||
|
||||
it('advertises configured models without restricting arbitrary request ids', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [
|
||||
{ id: 'private-fast' },
|
||||
{ id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
|
||||
],
|
||||
})
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'private-fast', name: 'private-fast' },
|
||||
{ provider: 'deepseek', id: 'private-reasoner', name: 'Private Reasoner', description: 'Higher reasoning budget' },
|
||||
])
|
||||
})
|
||||
|
||||
it('allows an explicit empty model catalog', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [],
|
||||
})
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[[{ id: '' }], /ids must be non-empty/],
|
||||
[[{ id: 'm', name: '' }], /empty name/],
|
||||
[[{ id: 'm' }, { id: 'm' }], /duplicate catalog model/],
|
||||
] as const)('rejects invalid advisory model config', async (models, message) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {
|
||||
apiKey: 'k',
|
||||
baseURL: 'http://127.0.0.1:1',
|
||||
models: [...models],
|
||||
})).rejects.toThrow(message)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to DEEPSEEK_API_KEY and DEEPSEEK_BASE_URL env vars', async () => {
|
||||
@@ -267,7 +324,7 @@ describe('plugin registration and config', () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
expect(ctx.llm.providers()).toEqual(['deepseek'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
})
|
||||
|
||||
it('throws a clear error when no API key is available', async () => {
|
||||
@@ -276,7 +333,7 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
await expect(ctx.plugin(LlmDeepSeek, {}))
|
||||
.rejects.toThrow(/an API key is required/)
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('prefers explicit config over env for key and base URL', async () => {
|
||||
@@ -305,11 +362,12 @@ describe('plugin registration and config', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
// Registration succeeds; no call is made (would hit api.deepseek.com).
|
||||
await ctx.plugin(LlmDeepSeek, {})
|
||||
expect(ctx.llm.providers()).toEqual(['deepseek'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
})
|
||||
|
||||
it('adapter is constructible directly for embedding', () => {
|
||||
it('adapter is constructible directly for embedding', async () => {
|
||||
const adapter = new DeepSeekAdapter({ apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
expect(adapter).toBeInstanceOf(DeepSeekAdapter)
|
||||
await expect(adapter.listModels('deepseek')).resolves.toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -26,6 +26,8 @@ Configure credentials and deployment-specific transport settings per provider. O
|
||||
|
||||
Each provider name must exist in pi-ai's installed catalog and may appear only once in this plugin instance. Registration with `ctx.llm` is atomic: a collision with any provider route already owned by another adapter fails plugin loading without registering the remaining routes. Model ids are not lifecycle config; an unknown model fails before any provider request with `LlmError('UNKNOWN_MODEL')`.
|
||||
|
||||
The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry.
|
||||
|
||||
Supported profile fields are `provider`, `apiKey`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `maxRetries`, and `maxRetryDelayMs`. They map to pi-ai's common stream options. Harness app attribution wins a conflicting configured header name.
|
||||
|
||||
## Provider/model routing and replay
|
||||
|
||||
@@ -15,7 +15,7 @@ import type {
|
||||
SimpleStreamOptions,
|
||||
} from '@earendil-works/pi-ai'
|
||||
import { attributionHeaders, LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { GenerateOptions, LlmModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { PiAiProviderProfile } from './config.ts'
|
||||
import { toPiContext } from './context.ts'
|
||||
import { toStreamChunks } from './stream.ts'
|
||||
@@ -65,6 +65,18 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
this.profiles = new Map(options.profiles.map(profile => [profile.provider, profile]))
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
const profile = this.profiles.get(provider)
|
||||
if (profile === undefined) {
|
||||
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
|
||||
}
|
||||
return Promise.resolve(getModels(profile.provider as KnownProvider).map(model => ({
|
||||
provider,
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
})))
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
if (options.stop !== undefined) {
|
||||
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
|
||||
|
||||
@@ -186,9 +186,23 @@ describe('provider profile lifecycle', () => {
|
||||
const fiber = await ctx.plugin(LlmPiAi, {
|
||||
providers: [{ provider: 'openai' }, { provider: 'anthropic' }],
|
||||
})
|
||||
expect(ctx.llm.providers()).toEqual(['openai', 'anthropic'])
|
||||
expect(ctx.llm.listProviders()).toEqual([
|
||||
{ id: 'openai', name: 'openai' },
|
||||
{ id: 'anthropic', name: 'anthropic' },
|
||||
])
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('exposes the installed pi-ai model catalog through provider-neutral metadata', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
await ctx.plugin(LlmPiAi, { providers: [{ provider: 'openai' }] })
|
||||
const models = await ctx.llm.listModels('openai')
|
||||
expect(models.find(model => model.id === 'gpt-4.1')).toEqual({
|
||||
provider: 'openai', id: 'gpt-4.1', name: 'GPT-4.1',
|
||||
})
|
||||
expect(models.every(model => model.provider === 'openai')).toBe(true)
|
||||
})
|
||||
|
||||
it('accepts absent credentials for pi-ai ambient authentication', async () => {
|
||||
@@ -210,6 +224,7 @@ describe('provider profile lifecycle', () => {
|
||||
|
||||
it('constructs the adapter directly and rejects routes it does not own', async () => {
|
||||
const adapter = new PiAiAdapter({ profiles: [{ provider: 'openai' }] })
|
||||
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
await expect((async () => {
|
||||
for await (const _chunk of adapter.stream({ provider: 'anthropic', model: 'claude-sonnet-4', messages: [] })) { /* drain */ }
|
||||
})()).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
|
||||
@@ -9,9 +9,12 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
### Public API
|
||||
|
||||
- `ctx.llm.registerAdapter(providers: string[], adapter: LlmAdapter): () => void` Register one adapter instance for the given provider routes. Registration is all-or-nothing, and is disposed with the calling fiber.
|
||||
- `ctx.llm.providers(): string[]` — provider routes with a registered adapter.
|
||||
- `ctx.llm.listProviders(): LlmProviderInfo[]` Describe registered provider routes in registration order.
|
||||
- `ctx.llm.listModels(provider: string): Promise<LlmModelInfo[]>` Discover the models one registered provider currently advertises.
|
||||
- `ctx.llm.stream(options: GenerateOptions): AsyncIterable<StreamChunk>` Stream one model call as raw chunks (token-level deltas). Consumers assemble the chunks into blocks/messages with `BlockAssembler`.
|
||||
|
||||
Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
|
||||
|
||||
### Events
|
||||
|
||||
| Event | Mode | Purpose |
|
||||
@@ -20,7 +23,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
|
||||
|
||||
### Extension points
|
||||
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically.
|
||||
- Subclass `LlmAdapter` and call `ctx.llm.registerAdapter(providers, adapter)` to add one or more provider routes. `GenerateOptions.provider` selects the adapter; `GenerateOptions.model` is adapter-owned and may be resolved dynamically. Override `providerInfo()` and asynchronous `listModels()` to expose selector metadata; their defaults use the route id as its name and advertise no models.
|
||||
- Wrap `llm/stream` via `ctx.on()` waterfall listeners for caching, retry, logging, rate-limiting, etc.
|
||||
|
||||
### Content-block vocabulary (`types.ts`)
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
*/
|
||||
|
||||
import { Context, Service } from 'cordis'
|
||||
import type { GenerateOptions, Message, StreamChunk } from './types.ts'
|
||||
import type { GenerateOptions, LlmModelInfo, LlmProviderInfo, Message, StreamChunk } from './types.ts'
|
||||
import { HarnessError } from './error.ts'
|
||||
import { deepFreeze } from './call-config.ts'
|
||||
|
||||
@@ -61,6 +61,26 @@ export class LlmError extends HarnessError {
|
||||
* DeepSeek and pi-ai adapters intentionally exercise this contract through different internals.
|
||||
*/
|
||||
export abstract class LlmAdapter {
|
||||
/**
|
||||
* Describe one provider route owned by this adapter.
|
||||
* @param provider - a route passed to `registerAdapter()` for this instance.
|
||||
* @returns detached display metadata whose id must equal `provider`.
|
||||
*/
|
||||
providerInfo(provider: string): LlmProviderInfo {
|
||||
return { id: provider, name: provider }
|
||||
}
|
||||
|
||||
/**
|
||||
* List models this adapter can currently advertise for one owned provider.
|
||||
* The result is advisory: an adapter may accept unlisted model ids, and
|
||||
* consumers must not turn absence into request rejection.
|
||||
* @param _provider - one provider route owned by this adapter.
|
||||
* @returns discoverable models in adapter-preferred order.
|
||||
*/
|
||||
listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve([])
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream one model call as raw chunks. The only required method.
|
||||
* @param options - the fully-assembled request; implementations must honor `options.signal`.
|
||||
@@ -74,7 +94,7 @@ export abstract class LlmAdapter {
|
||||
* surface, interceptable via the `llm/stream` waterfall.
|
||||
*/
|
||||
export class LlmService extends Service {
|
||||
private adapters = new Map<string, LlmAdapter>()
|
||||
private adapters = new Map<string, { adapter: LlmAdapter; provider: LlmProviderInfo }>()
|
||||
|
||||
constructor(ctx: Context) {
|
||||
super(ctx, 'llm')
|
||||
@@ -92,14 +112,20 @@ export class LlmService extends Service {
|
||||
const dispose = this.ctx.effect(function* (this: LlmService) {
|
||||
if (providers.length === 0) throw new LlmError('an adapter must register at least one provider', 'INVALID_ADAPTER')
|
||||
const unique = new Set<string>()
|
||||
const registrations: { adapter: LlmAdapter; provider: LlmProviderInfo }[] = []
|
||||
for (const provider of providers) {
|
||||
if (provider.length === 0) throw new LlmError('adapter provider names must be non-empty', 'INVALID_ADAPTER')
|
||||
if (unique.has(provider) || this.adapters.has(provider)) {
|
||||
throw new LlmError(`an adapter for provider "${provider}" is already registered`, 'DUPLICATE_ADAPTER')
|
||||
}
|
||||
const info = adapter.providerInfo(provider)
|
||||
if (typeof info.id !== 'string' || info.id !== provider || typeof info.name !== 'string' || info.name.length === 0) {
|
||||
throw new LlmError(`adapter metadata for provider "${provider}" must preserve its id and have a non-empty name`, 'INVALID_ADAPTER')
|
||||
}
|
||||
unique.add(provider)
|
||||
registrations.push({ adapter, provider: { id: info.id, name: info.name } })
|
||||
}
|
||||
for (const provider of providers) this.adapters.set(provider, adapter)
|
||||
for (const registration of registrations) this.adapters.set(registration.provider.id, registration)
|
||||
yield () => {
|
||||
for (const provider of providers) this.adapters.delete(provider)
|
||||
}
|
||||
@@ -110,17 +136,50 @@ export class LlmService extends Service {
|
||||
}
|
||||
|
||||
/**
|
||||
* Provider routes with a registered adapter.
|
||||
* @returns the registered provider names, in registration order.
|
||||
* Describe provider routes with a registered adapter.
|
||||
* @returns detached provider metadata in registration order.
|
||||
*/
|
||||
providers(): string[] {
|
||||
return [...this.adapters.keys()]
|
||||
listProviders(): LlmProviderInfo[] {
|
||||
return [...this.adapters.values()].map(({ provider }) => ({ ...provider }))
|
||||
}
|
||||
|
||||
private adapter(provider: string): LlmAdapter {
|
||||
const adapter = this.adapters.get(provider)
|
||||
if (!adapter) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER')
|
||||
return adapter
|
||||
/**
|
||||
* Discover models advertised by one registered provider. Catalog membership
|
||||
* is advisory and never changes routing or request validation.
|
||||
* @param provider - registered provider route to inspect.
|
||||
* @returns detached model metadata in adapter-preferred order.
|
||||
*/
|
||||
async listModels(provider: string): Promise<LlmModelInfo[]> {
|
||||
const adapter = this.registration(provider).adapter
|
||||
const models = await adapter.listModels(provider)
|
||||
const seen = new Set<string>()
|
||||
return models.map((model) => {
|
||||
if (
|
||||
typeof model.provider !== 'string'
|
||||
|| model.provider !== provider
|
||||
|| typeof model.id !== 'string'
|
||||
|| model.id.length === 0
|
||||
|| typeof model.name !== 'string'
|
||||
|| model.name.length === 0
|
||||
|| (model.description !== undefined && typeof model.description !== 'string')
|
||||
|| seen.has(model.id)
|
||||
) {
|
||||
throw new LlmError(`adapter returned invalid or duplicate model metadata for provider "${provider}"`, 'INVALID_CATALOG')
|
||||
}
|
||||
seen.add(model.id)
|
||||
return {
|
||||
provider: model.provider,
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
...model.description === undefined ? {} : { description: model.description },
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private registration(provider: string): { adapter: LlmAdapter; provider: LlmProviderInfo } {
|
||||
const registration = this.adapters.get(provider)
|
||||
if (!registration) throw new LlmError(`no adapter registered for provider "${provider}"`, 'NO_ADAPTER')
|
||||
return registration
|
||||
}
|
||||
|
||||
/** Remove replay state whose historical route is owned by another adapter. */
|
||||
@@ -128,7 +187,7 @@ export class LlmService extends Service {
|
||||
const messages: Message[] = options.messages.map((message) => {
|
||||
const provenance = message.provenance
|
||||
if (message.role !== 'assistant' || provenance?.replayState === undefined) return message
|
||||
if (this.adapters.get(provenance.provider) === adapter) return message
|
||||
if (this.adapters.get(provenance.provider)?.adapter === adapter) return message
|
||||
return {
|
||||
...message,
|
||||
provenance: { provider: provenance.provider, model: provenance.model },
|
||||
@@ -150,7 +209,7 @@ export class LlmService extends Service {
|
||||
*/
|
||||
stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
return this.ctx.waterfall(this, 'llm/stream', options, () => {
|
||||
const adapter = this.adapter(options.provider)
|
||||
const adapter = this.registration(options.provider).adapter
|
||||
return adapter.stream(this.forAdapter(options, adapter))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -121,6 +121,26 @@ export interface TokenUsage {
|
||||
reasoningTokens?: number
|
||||
}
|
||||
|
||||
/** Display metadata for one registered provider route. */
|
||||
export interface LlmProviderInfo {
|
||||
/** Provider route key used by {@link GenerateOptions.provider}. */
|
||||
id: string
|
||||
/** Human-readable provider name for selectors and diagnostics. */
|
||||
name: string
|
||||
}
|
||||
|
||||
/** One adapter-discovered model; catalog membership is advisory, not request validation. */
|
||||
export interface LlmModelInfo {
|
||||
/** Provider route that owns this model entry. */
|
||||
provider: string
|
||||
/** Model id passed to {@link GenerateOptions.model}. */
|
||||
id: string
|
||||
/** Human-readable model name for selectors. */
|
||||
name: string
|
||||
/** Optional user-facing distinction from otherwise similar models. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Raw streaming protocol emitted by adapters.
|
||||
* Block indexes correlate interleaved deltas, and `block-end` carries the
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Context } from 'cordis'
|
||||
import LlmService, { GenerateOptions, LlmAdapter, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm'
|
||||
import type { LlmModelInfo, LlmProviderInfo } from '@deepseek-ai/dsh-llm'
|
||||
|
||||
class ScriptedAdapter extends LlmAdapter {
|
||||
constructor(private script: StreamChunk[]) {
|
||||
@@ -21,6 +22,23 @@ class RecordingAdapter extends ScriptedAdapter {
|
||||
}
|
||||
}
|
||||
|
||||
class CatalogAdapter extends ScriptedAdapter {
|
||||
constructor(
|
||||
private readonly provider: LlmProviderInfo,
|
||||
private readonly models: readonly LlmModelInfo[],
|
||||
) {
|
||||
super(SCRIPT)
|
||||
}
|
||||
|
||||
override providerInfo(_provider: string): LlmProviderInfo {
|
||||
return this.provider
|
||||
}
|
||||
|
||||
override listModels(_provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.models)
|
||||
}
|
||||
}
|
||||
|
||||
const SCRIPT: StreamChunk[] = [
|
||||
{ type: 'block-start', index: 0, blockType: 'text' },
|
||||
{ type: 'text-delta', index: 0, text: 'hi' },
|
||||
@@ -53,10 +71,80 @@ describe('LlmService', () => {
|
||||
const fiber = await ctx.plugin(Object.assign((inner: Context) => {
|
||||
inner.llm.registerAdapter(['scoped-model'], new ScriptedAdapter(SCRIPT))
|
||||
}, { inject: ['llm'] }))
|
||||
expect(ctx.llm.providers()).toEqual(['scoped-model'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'scoped-model', name: 'scoped-model' }])
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('discovers detached provider and advisory model metadata', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const provider = { id: 'catalog', name: 'Catalog Provider' }
|
||||
const model = { provider: 'catalog', id: 'fast', name: 'Fast', description: 'Low latency' }
|
||||
ctx.llm.registerAdapter(['catalog'], new CatalogAdapter(provider, [model]))
|
||||
|
||||
const providers = ctx.llm.listProviders()
|
||||
const models = await ctx.llm.listModels('catalog')
|
||||
expect(providers).toEqual([provider])
|
||||
expect(models).toEqual([model])
|
||||
|
||||
providers[0]!.name = 'mutated'
|
||||
models[0]!.name = 'mutated'
|
||||
provider.name = 'source mutated'
|
||||
model.name = 'source mutated'
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'catalog', name: 'Catalog Provider' }])
|
||||
await expect(ctx.llm.listModels('catalog')).resolves.toEqual([{
|
||||
provider: 'catalog', id: 'fast', name: 'source mutated', description: 'Low latency',
|
||||
}])
|
||||
})
|
||||
|
||||
it('defaults adapters to their route name and an empty advisory model list', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['plain'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'plain', name: 'plain' }])
|
||||
await expect(ctx.llm.listModels('plain')).resolves.toEqual([])
|
||||
await expect(ctx.llm.listModels('missing')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ id: 1, name: 'Name' }, 'non-string id'],
|
||||
[{ id: 'other', name: 'Name' }, 'mismatched id'],
|
||||
[{ id: 'route', name: 1 }, 'non-string name'],
|
||||
[{ id: 'route', name: '' }, 'empty name'],
|
||||
] as const)('rejects invalid provider metadata atomically (%s: %s)', async (metadata, _label) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const adapter = new CatalogAdapter(metadata as unknown as LlmProviderInfo, [])
|
||||
expect(() => ctx.llm.registerAdapter(['route'], adapter)).toThrow(expect.objectContaining({ code: 'INVALID_ADAPTER' }))
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[{ provider: 1, id: 'm', name: 'M' }, 'non-string provider'],
|
||||
[{ provider: 'other', id: 'm', name: 'M' }, 'mismatched provider'],
|
||||
[{ provider: 'route', id: 1, name: 'M' }, 'non-string id'],
|
||||
[{ provider: 'route', id: '', name: 'M' }, 'empty id'],
|
||||
[{ provider: 'route', id: 'm', name: 1 }, 'non-string name'],
|
||||
[{ provider: 'route', id: 'm', name: '' }, 'empty name'],
|
||||
[{ provider: 'route', id: 'm', name: 'M', description: 1 }, 'non-string description'],
|
||||
] as const)('rejects invalid model metadata (%s: %s)', async (metadata, _label) => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
ctx.llm.registerAdapter(['route'], new CatalogAdapter(
|
||||
{ id: 'route', name: 'Route' },
|
||||
[metadata as unknown as LlmModelInfo],
|
||||
))
|
||||
await expect(ctx.llm.listModels('route')).rejects.toMatchObject({ code: 'INVALID_CATALOG' })
|
||||
})
|
||||
|
||||
it('rejects duplicate model ids in one provider catalog', async () => {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmService)
|
||||
const model = { provider: 'route', id: 'same', name: 'Same' }
|
||||
ctx.llm.registerAdapter(['route'], new CatalogAdapter({ id: 'route', name: 'Route' }, [model, model]))
|
||||
await expect(ctx.llm.listModels('route')).rejects.toMatchObject({ code: 'INVALID_CATALOG' })
|
||||
})
|
||||
|
||||
it('lets llm/stream waterfall listeners wrap the underlying stream', async () => {
|
||||
@@ -192,9 +280,9 @@ describe('LlmService', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.providers()).toEqual(['m1'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'm1', name: 'm1' }])
|
||||
dispose()
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('rejects duplicate adapter registration with DUPLICATE_ADAPTER code', async () => {
|
||||
@@ -219,7 +307,7 @@ describe('LlmService', () => {
|
||||
expect(() => ctx.llm.registerAdapter([], adapter)).toThrow(expect.objectContaining({ code: 'INVALID_ADAPTER' }))
|
||||
expect(() => ctx.llm.registerAdapter([''], adapter)).toThrow(expect.objectContaining({ code: 'INVALID_ADAPTER' }))
|
||||
expect(() => ctx.llm.registerAdapter(['first', 'first'], adapter)).toThrow(expect.objectContaining({ code: 'DUPLICATE_ADAPTER' }))
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('re-registers a model after its prior registration is disposed', async () => {
|
||||
@@ -227,14 +315,14 @@ describe('LlmService', () => {
|
||||
await ctx.plugin(LlmService)
|
||||
|
||||
const dispose = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.providers()).toEqual(['m1'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'm1', name: 'm1' }])
|
||||
dispose()
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
|
||||
// The duplicate check is not wedged: the same model registers cleanly again.
|
||||
const disposeAgain = ctx.llm.registerAdapter(['m1'], new ScriptedAdapter(SCRIPT))
|
||||
expect(ctx.llm.providers()).toEqual(['m1'])
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'm1', name: 'm1' }])
|
||||
disposeAgain()
|
||||
expect(ctx.llm.providers()).toEqual([])
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user