feat(acp): advertise and switch llm models

This commit is contained in:
Yichen Jiang
2026-07-15 13:33:42 +08:00
parent 75331f03a6
commit f1d39921c9
94 changed files with 1657 additions and 191 deletions

View File

@@ -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).

View File

@@ -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 ?? {})

View File

@@ -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),
}))
}

View File

@@ -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([])
})
})

View File

@@ -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

View File

@@ -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')

View File

@@ -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' })

View File

@@ -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`)

View File

@@ -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))
})
}

View File

@@ -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

View File

@@ -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([])
})
})