f376ee23d1
Three defects surfaced while driving the Models page. A hand-declared model needed an explicit contextWindow and maxTokens, but a provider listing usually returns ids and nothing else — so the page happily wrote a profile the adapter then rejected, which took the whole namespace down silently. Capacities now fall back to the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields a deployment corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. That silent failure was the second defect. A schema-valid profile the adapter could not serve was stored and only rejected later, disabling every route in the namespace with nothing said. `dsh-settings` gains an optional `validate` on registration — a check for what a schema cannot express — and `llm-pi-ai` refuses an unserviceable section at the write that produced it. A stored section that fails keeps the namespace's last good value, as a schema failure already did, so an externally edited document still cannot strand the owner. The plugin's own last-good fallback goes with it: nothing reaching it can fail any more. Third, a model with no reasoning metadata advertised the single level `off`, which pi-ai translates to *omitting* the reasoning option — the same request naming no effort produces. Selecting it disabled nothing, so a provider whose default is to think kept thinking with `off` shown as selected. Such a model now reports no reasoning capability at all, which is the seam's way of saying the control is unavailable, and the per-model `reasoning` flag is gone: without a thinkingLevelMap to spell levels it could only invent them. The protocol table narrows to the three a hand-declared route reaches today, most-reached first so a surface offering a choice defaults to the one gateways actually speak.
543 lines
23 KiB
TypeScript
543 lines
23 KiB
TypeScript
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
|
|
import { tmpdir } from 'node:os'
|
|
import { join } from 'node:path'
|
|
import { afterEach, describe, expect, it } from 'vitest'
|
|
import { Context } from 'cordis'
|
|
import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm'
|
|
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
|
|
import SettingsLocal from '@deepseek-ai/dsh-settings-local'
|
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
|
|
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
|
|
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
|
|
import { resolveProfiles } from '../src/config.ts'
|
|
import { buildProvider, supportedProtocols } from '../src/provider.ts'
|
|
import { assemble } from './assemble.ts'
|
|
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
|
|
|
|
const homes: string[] = []
|
|
|
|
afterEach(async () => {
|
|
await closeMockServers()
|
|
await Promise.all(homes.splice(0).map(dir => rm(dir, { recursive: true, force: true })))
|
|
})
|
|
|
|
/** A throwaway $DSH_HOME with an empty settings document. */
|
|
async function home(): Promise<string> {
|
|
const dir = await mkdtemp(join(tmpdir(), 'dsh-pi-catalog-'))
|
|
homes.push(dir)
|
|
await writeFile(join(dir, 'settings.yaml'), '')
|
|
return dir
|
|
}
|
|
|
|
/** The dormant composition plus a real settings service, as the product mounts it. */
|
|
async function bootWithSettings(dir: string, config: LlmPiAi.Config): Promise<Context> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(SettingsLocal, { path: join(dir, 'settings.yaml'), watch: false })
|
|
await ctx.plugin(LlmPiAi, config)
|
|
return ctx
|
|
}
|
|
|
|
/** A complete hand-declared route: nothing about it exists in pi-ai's catalog. */
|
|
function gateway(baseURL: string, overrides: Record<string, unknown> = {}): LlmPiAi.Config {
|
|
return {
|
|
providers: {
|
|
'acme-gateway': {
|
|
apiKey: 'gw-key',
|
|
displayName: 'Acme Gateway',
|
|
api: 'openai-completions',
|
|
baseURL,
|
|
models: [{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }],
|
|
...overrides,
|
|
},
|
|
},
|
|
}
|
|
}
|
|
|
|
async function harness(config: LlmPiAi.Config): Promise<Context> {
|
|
const ctx = new Context()
|
|
await ctx.plugin(LlmService)
|
|
await ctx.plugin(LlmPiAi, config)
|
|
return ctx
|
|
}
|
|
|
|
describe('hand-declared providers', () => {
|
|
it('serves a route pi-ai has never heard of from its own declaration', async () => {
|
|
const server = await mockServer([{ events: textEvents }])
|
|
const ctx = await harness(gateway(`${server.url}/v1`))
|
|
|
|
const result = await assemble(ctx, {
|
|
provider: 'acme-gateway',
|
|
model: 'acme-large',
|
|
messages: [createUserMessage({
|
|
content: [{ type: 'text', text: 'hi' }],
|
|
source: { kind: 'plugin', plugin: 'test' },
|
|
})],
|
|
})
|
|
|
|
expect(result.message.content).toEqual([{ type: 'text', text: 'hello' }])
|
|
expect(result.finish).toEqual({ kind: 'stop' })
|
|
expect(server.paths).toEqual(['/v1/chat/completions'])
|
|
expect(server.headers[0]?.authorization).toBe('Bearer gw-key')
|
|
})
|
|
|
|
it('lists and resolves the declared models rather than a catalog', async () => {
|
|
const server = await mockServer([])
|
|
const ctx = await harness(gateway(`${server.url}/v1`))
|
|
|
|
expect(await ctx.llm.listModels('acme-gateway')).toEqual([
|
|
{ provider: 'acme-gateway', id: 'acme-large', name: 'Acme Large' },
|
|
])
|
|
const info = await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')
|
|
expect(info).toMatchObject({
|
|
provider: 'acme-gateway',
|
|
id: 'acme-large',
|
|
name: 'Acme Large',
|
|
context: { contextWindow: 65_536 },
|
|
defaultMaxTokens: 4096,
|
|
})
|
|
})
|
|
|
|
it('offers no reasoning control it could not honour', async () => {
|
|
const server = await mockServer([])
|
|
const ctx = await harness(gateway(`${server.url}/v1`))
|
|
|
|
// pi-ai reports a model with no reasoning metadata as supporting the single
|
|
// level `off`, but `off` is translated to *omitting* the reasoning option —
|
|
// byte-for-byte the same request as naming no effort — so a provider whose
|
|
// own default is to think would keep thinking with `off` selected. The
|
|
// capability is reported unavailable instead of offering that control.
|
|
expect((await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).reasoning).toBeUndefined()
|
|
|
|
// A catalog route is unaffected: its models carry the metadata that makes
|
|
// `off` actually disable thinking.
|
|
const withCatalog = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } })
|
|
const [catalogModel] = getBuiltinModels('deepseek')
|
|
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
|
expect((await withCatalog.llm.resolveModelInfo('deepseek', catalogModel.id)).reasoning?.efforts.map(e => e.id))
|
|
.toContain('off')
|
|
})
|
|
|
|
it('joins the configurable-provider directory so a settings surface can reach it', async () => {
|
|
const server = await mockServer([])
|
|
const ctx = await harness(gateway(`${server.url}/v1`))
|
|
|
|
expect(ctx.llm.listConfigurableProviders()).toContainEqual({
|
|
provider: 'acme-gateway',
|
|
displayName: 'Acme Gateway',
|
|
settingsNs: 'llm-pi-ai',
|
|
settingsPath: ['providers', 'acme-gateway'],
|
|
})
|
|
})
|
|
|
|
it('sizes a model the catalog cannot describe from the route\u2019s own fallbacks', () => {
|
|
const resolved = resolveProfiles({
|
|
'acme-gateway': {
|
|
api: 'openai-completions',
|
|
baseURL: 'https://acme.test',
|
|
// A listing endpoint that discloses nothing but ids still yields a
|
|
// serviceable route.
|
|
models: [{ id: 'bare' }, { id: 'sized', contextWindow: 8192, maxTokens: 512 }],
|
|
},
|
|
'tuned-gateway': {
|
|
api: 'openai-completions',
|
|
baseURL: 'https://tuned.test',
|
|
defaultContextWindow: 4096,
|
|
defaultMaxTokens: 256,
|
|
models: [{ id: 'bare' }],
|
|
},
|
|
})
|
|
const modelsOf = (route: string): readonly { id: string; contextWindow: number; maxTokens: number }[] =>
|
|
resolved.get(route)?.piProvider.getModels() ?? []
|
|
|
|
expect(modelsOf('acme-gateway')).toMatchObject([
|
|
{ id: 'bare', contextWindow: 262_144, maxTokens: 32_768 },
|
|
{ id: 'sized', contextWindow: 8192, maxTokens: 512 },
|
|
])
|
|
// The fallback is a guess, so a deployment whose gateway serves smaller
|
|
// models corrects it once for the whole route.
|
|
expect(modelsOf('tuned-gateway')).toMatchObject([{ id: 'bare', contextWindow: 4096, maxTokens: 256 }])
|
|
// Only an explicitly configured cap is a request default; a fallback is
|
|
// the model's capability and stops there.
|
|
expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('bare')).toBeUndefined()
|
|
expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('sized')).toBe(512)
|
|
})
|
|
|
|
it('rejects a model the route cannot identify', () => {
|
|
const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) =>
|
|
() => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } })
|
|
|
|
expect(declare({ id: '' })).toThrow(/empty id/)
|
|
expect(() => resolveProfiles({
|
|
'acme-gateway': {
|
|
api: 'openai-completions',
|
|
baseURL: 'https://acme.test',
|
|
models: [{ id: 'dup', contextWindow: 1, maxTokens: 1 }, { id: 'dup', contextWindow: 2, maxTokens: 2 }],
|
|
},
|
|
})).toThrow(/more than once/)
|
|
})
|
|
|
|
it('rejects a declaration that names no wire protocol or endpoint', () => {
|
|
expect(() => resolveProfiles({
|
|
'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
|
|
})).toThrow(/needs an api/)
|
|
expect(() => resolveProfiles({
|
|
'acme-gateway': { api: 'openai-completions', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
|
|
})).toThrow(/needs a baseURL/)
|
|
})
|
|
|
|
it.each(['bedrock-converse-stream', 'google-vertex', 'azure-openai-responses', 'openai-codex-responses'])(
|
|
'refuses %s, whose authentication a profile cannot express',
|
|
(api) => {
|
|
// These need SigV4 credentials and a region, a project plus ADC, provider
|
|
// environment and an api-version, or OAuth — none of which a key, an
|
|
// endpoint, and headers can carry, so a route naming one would be built
|
|
// unable to authenticate.
|
|
expect(supportedProtocols()).not.toContain(api)
|
|
expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [] }))
|
|
.toThrow(/cannot serve; supported protocols are/)
|
|
},
|
|
)
|
|
|
|
it('rejects a protocol this build cannot serve, and a route that names none', () => {
|
|
const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [] }
|
|
expect(() => buildProvider({ ...spec, api: 'quantum-telepathy' }))
|
|
.toThrow(/cannot serve; supported protocols are/)
|
|
expect(() => buildProvider(spec)).toThrow(/cannot serve; supported protocols are/)
|
|
})
|
|
|
|
it('leaves an unauthenticated route to its protocol rather than inventing a credential', async () => {
|
|
const server = await mockServer([{ events: textEvents }])
|
|
// Naming no credential is the deliberately unauthenticated posture — a
|
|
// named reference that resolved to nothing would have failed with
|
|
// MISSING_CREDENTIAL long before this point. The route resolves as
|
|
// configured and the protocol decides: pi-ai's OpenAI-compatible
|
|
// implementation wants a key or an Authorization header of its own, and
|
|
// says so instead of the harness guessing a placeholder.
|
|
const ctx = await harness({
|
|
providers: {
|
|
'local-llm': {
|
|
api: 'openai-completions',
|
|
baseURL: `${server.url}/v1`,
|
|
models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }],
|
|
},
|
|
},
|
|
})
|
|
|
|
const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] })
|
|
expect(result.finish).toMatchObject({
|
|
kind: 'error',
|
|
failure: { message: 'No API key for provider: local-llm' },
|
|
})
|
|
expect(server.requests).toHaveLength(0)
|
|
})
|
|
|
|
it('authenticates an unauthenticated route through a configured header', async () => {
|
|
const server = await mockServer([{ events: textEvents }])
|
|
const ctx = await harness({
|
|
providers: {
|
|
'local-llm': {
|
|
api: 'openai-completions',
|
|
baseURL: `${server.url}/v1`,
|
|
headers: { Authorization: 'Bearer local' },
|
|
models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }],
|
|
},
|
|
},
|
|
})
|
|
|
|
const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] })
|
|
expect(result.finish).toEqual({ kind: 'stop' })
|
|
expect(server.headers[0]?.authorization).toBe('Bearer local')
|
|
})
|
|
|
|
it('rejects a capacity that is not a positive integer', () => {
|
|
const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) =>
|
|
() => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } })
|
|
|
|
expect(declare({ id: 'm', contextWindow: 0, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/)
|
|
expect(declare({ id: 'm', contextWindow: 1.5, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/)
|
|
expect(declare({ id: 'm', contextWindow: 1, maxTokens: 0 })).toThrow(/maxTokens must be a positive integer/)
|
|
expect(declare({ id: 'm', contextWindow: 1, maxTokens: 1.5 })).toThrow(/maxTokens must be a positive integer/)
|
|
})
|
|
|
|
it('names the route key when no displayName is configured', () => {
|
|
const resolved = resolveProfiles({
|
|
'acme-gateway': {
|
|
api: 'openai-completions',
|
|
baseURL: 'https://acme.test',
|
|
models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
|
|
},
|
|
})
|
|
expect(resolved.get('acme-gateway')?.displayName).toBe('acme-gateway')
|
|
expect(() => resolveProfiles({ 'acme-gateway': { displayName: '' } })).toThrow(/empty displayName/)
|
|
})
|
|
})
|
|
|
|
describe('catalog routes with per-model configuration', () => {
|
|
it('serves the installed catalog untouched when the profile lists no models', async () => {
|
|
const server = await mockServer([])
|
|
const ctx = await harness({ providers: { deepseek: { apiKey: 'k', baseURL: server.url } } })
|
|
|
|
const listed = await ctx.llm.listModels('deepseek')
|
|
expect(listed.map(model => model.id).sort())
|
|
.toEqual(getBuiltinModels('deepseek').map(model => model.id).sort())
|
|
})
|
|
|
|
it('overrides one catalog model field and defaults the rest from the catalog', async () => {
|
|
const server = await mockServer([])
|
|
const [catalogModel] = getBuiltinModels('deepseek')
|
|
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
|
const ctx = await harness({
|
|
providers: {
|
|
deepseek: {
|
|
apiKey: 'k',
|
|
baseURL: server.url,
|
|
models: [{ id: catalogModel.id, contextWindow: 4096 }],
|
|
},
|
|
},
|
|
})
|
|
|
|
const info = await ctx.llm.resolveModelInfo('deepseek', catalogModel.id)
|
|
// The configured field wins and the name still comes from the catalog. The
|
|
// catalog's own output cap is the model's capability, not a cap anyone
|
|
// chose, so it must not arrive as the request default.
|
|
expect(info.context).toEqual({ contextWindow: 4096 })
|
|
expect(info.name).toBe(catalogModel.name)
|
|
expect(info.defaultMaxTokens).toBeUndefined()
|
|
// An explicit list replaces the catalog rather than adding to it.
|
|
expect((await ctx.llm.listModels('deepseek')).map(model => model.id)).toEqual([catalogModel.id])
|
|
})
|
|
|
|
it('materializes a request default only from a configured output cap', async () => {
|
|
const server = await mockServer([])
|
|
const [catalogModel] = getBuiltinModels('deepseek')
|
|
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
|
const ctx = await harness({
|
|
providers: {
|
|
deepseek: {
|
|
apiKey: 'k',
|
|
baseURL: server.url,
|
|
models: [{ id: catalogModel.id, maxTokens: 4096 }],
|
|
},
|
|
},
|
|
})
|
|
|
|
// Configuring the cap is the deployment choosing one, so it becomes the
|
|
// default the seam materializes into requests that name none.
|
|
expect((await ctx.llm.resolveModelInfo('deepseek', catalogModel.id)).defaultMaxTokens).toBe(4096)
|
|
})
|
|
|
|
it('adds a model the installed catalog does not describe to a catalog route', async () => {
|
|
const server = await mockServer([{ events: textEvents }])
|
|
const ctx = await harness({
|
|
providers: {
|
|
deepseek: {
|
|
apiKey: 'k',
|
|
baseURL: `${server.url}/v1`,
|
|
models: [{ id: 'deepseek-preview', contextWindow: 200_000, maxTokens: 8192 }],
|
|
},
|
|
},
|
|
})
|
|
|
|
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-preview', messages: [] })
|
|
expect(result.finish).toEqual({ kind: 'stop' })
|
|
// The catalog route keeps its catalog protocol, so the new model reaches
|
|
// the same endpoint shape the shipped models use.
|
|
expect(server.paths).toEqual(['/v1/chat/completions'])
|
|
})
|
|
|
|
it('fails an unconfigured model id before any provider request', async () => {
|
|
const server = await mockServer([])
|
|
const ctx = await harness({
|
|
providers: {
|
|
deepseek: { apiKey: 'k', baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
|
|
},
|
|
})
|
|
|
|
await expect(assemble(ctx, { provider: 'deepseek', model: 'not-configured', messages: [] }))
|
|
.rejects.toMatchObject({ code: 'UNKNOWN_MODEL' })
|
|
expect(server.requests).toHaveLength(0)
|
|
})
|
|
|
|
it('preserves catalog-only model metadata the profile cannot express', () => {
|
|
// Some catalog models carry provider-required request headers; overriding a
|
|
// capacity must not drop them, because configuration has no way to restate
|
|
// them.
|
|
const headered = (getBuiltinModels('nvidia') as { id: string; headers?: unknown }[])
|
|
.find(model => model.headers !== undefined)
|
|
if (headered === undefined) throw new Error('the installed catalog ships no nvidia model with headers')
|
|
|
|
const resolved = resolveProfiles({
|
|
nvidia: { models: [{ id: headered.id, contextWindow: 4096 }] },
|
|
})
|
|
const [model] = resolved.get('nvidia')?.piProvider.getModels() ?? []
|
|
expect(model?.headers).toEqual(headered.headers)
|
|
expect(model?.contextWindow).toBe(4096)
|
|
})
|
|
|
|
it('delegates both stream methods back to the reused catalog provider', async () => {
|
|
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
|
|
const resolved = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } })
|
|
const built = resolved.get('deepseek')?.piProvider
|
|
if (built === undefined) throw new Error('the deepseek route built no provider')
|
|
const [model] = built.getModels()
|
|
if (model === undefined) throw new Error('the deepseek route resolved no models')
|
|
const context = { messages: [{ role: 'user' as const, content: 'hi', timestamp: 0 }] }
|
|
|
|
// `stream` is interface-required and unused by the harness adapter, which
|
|
// only calls `streamSimple`; both must still reach the catalog provider.
|
|
for await (const _event of built.stream(model, context, { apiKey: 'k' })) { /* drain */ }
|
|
for await (const _event of built.streamSimple(model, context, { apiKey: 'k' })) { /* drain */ }
|
|
|
|
expect(server.paths).toEqual(['/v1/chat/completions', '/v1/chat/completions'])
|
|
})
|
|
|
|
it('keeps each model its own endpoint when the catalog route declares none', () => {
|
|
// `opencode` ships no provider-level endpoint: the address lives on every
|
|
// catalog model, so the route resolves without any configured baseURL.
|
|
const resolved = resolveProfiles({ opencode: {} })
|
|
const models = resolved.get('opencode')?.piProvider.getModels() ?? []
|
|
expect(models.length).toBeGreaterThan(0)
|
|
expect(models.every(model => model.baseUrl.length > 0)).toBe(true)
|
|
expect(resolved.get('opencode')?.piProvider.baseUrl).toBeUndefined()
|
|
})
|
|
|
|
it('repoints a catalog route at another wire protocol without restating its endpoint', () => {
|
|
const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
|
|
const models = resolved.get('openai')?.piProvider.getModels() ?? []
|
|
// The protocol changes for the whole route; each model keeps the catalog
|
|
// endpoint it already had.
|
|
expect(models.every(model => model.api === 'openai-completions')).toBe(true)
|
|
expect(models.every(model => model.baseUrl === 'https://api.openai.com/v1')).toBe(true)
|
|
})
|
|
|
|
it('repoints a catalog route at another wire protocol', async () => {
|
|
const server = await mockServer([{ events: textEvents }])
|
|
const ctx = await harness({
|
|
providers: {
|
|
// openai's catalog models speak the Responses API; naming the protocol
|
|
// explicitly moves the whole route onto Chat Completions.
|
|
openai: {
|
|
apiKey: 'k',
|
|
api: 'openai-completions',
|
|
baseURL: `${server.url}/v1`,
|
|
models: [{ id: 'gpt-4.1', contextWindow: 100_000, maxTokens: 4096 }],
|
|
},
|
|
},
|
|
})
|
|
|
|
await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
|
|
expect(server.paths).toEqual(['/v1/chat/completions'])
|
|
})
|
|
})
|
|
|
|
describe('resolution snapshots', () => {
|
|
it('finishes an in-flight request under the configuration it started with', async () => {
|
|
const server = await mockServer([{ events: textEvents }])
|
|
let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${server.url}/v1` } })
|
|
let release: () => void = () => {}
|
|
const held = new Promise<void>((resolve) => { release = resolve })
|
|
const adapter = new PiAiAdapter({
|
|
profiles: () => current,
|
|
// Credential resolution is the real await inside a stream call, and the
|
|
// window a configuration change has to land in.
|
|
resolveApiKey: async () => { await held; return 'k' },
|
|
})
|
|
|
|
const chunks: StreamChunk[] = []
|
|
const inFlight = (async () => {
|
|
for await (const chunk of adapter.stream({
|
|
provider: 'deepseek',
|
|
model: 'deepseek-v4-flash',
|
|
messages: [],
|
|
})) chunks.push(chunk)
|
|
})()
|
|
|
|
// The route set changes while the request waits, and something else reads
|
|
// the adapter meanwhile, which is what would rebuild a shared collection.
|
|
current = resolveProfiles({ openai: { apiKey: 'k', baseURL: `${server.url}/v1` } })
|
|
await expect(adapter.listModels('openai')).resolves.not.toHaveLength(0)
|
|
release()
|
|
await inFlight
|
|
|
|
// The in-flight request keeps its own snapshot: it reaches the endpoint it
|
|
// resolved against instead of failing on a provider that no longer exists.
|
|
expect(chunks.at(-1)).toMatchObject({ type: 'finish', reason: { kind: 'stop' } })
|
|
expect(server.paths).toEqual(['/v1/chat/completions'])
|
|
})
|
|
|
|
it('serves the next request from the new configuration', async () => {
|
|
const first = await mockServer([{ events: textEvents }])
|
|
const second = await mockServer([{ events: textEvents }])
|
|
let current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${first.url}/v1` } })
|
|
const adapter = new PiAiAdapter({ profiles: () => current, resolveApiKey: () => Promise.resolve('k') })
|
|
const drain = async (): Promise<void> => {
|
|
for await (const _chunk of adapter.stream({
|
|
provider: 'deepseek', model: 'deepseek-v4-flash', messages: [],
|
|
})) { /* drain */ }
|
|
}
|
|
|
|
await drain()
|
|
current = resolveProfiles({ deepseek: { apiKey: 'k', baseURL: `${second.url}/v1` } })
|
|
await drain()
|
|
|
|
expect(first.paths).toHaveLength(1)
|
|
expect(second.paths).toHaveLength(1)
|
|
})
|
|
})
|
|
|
|
describe('configurable-provider directory', () => {
|
|
it('keeps the previous directory when a route collides with another adapter family', async () => {
|
|
const dir = await home()
|
|
const ctx = await bootWithSettings(dir, {})
|
|
// Another adapter family owns this route id, exactly as llm-deepseek does.
|
|
ctx.llm.registerConfigurableProviders([
|
|
{ provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [] },
|
|
])
|
|
const before = ctx.llm.listConfigurableProviders().length
|
|
expect(before).toBeGreaterThan(30)
|
|
|
|
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
|
providers: {
|
|
'deepseek-official': {
|
|
apiKey: 'k',
|
|
api: 'openai-completions',
|
|
baseURL: 'https://acme.test/v1',
|
|
models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
|
|
},
|
|
},
|
|
})
|
|
|
|
// The refused swap costs a diagnostic, not the directory: every entry the
|
|
// page needs is still declared.
|
|
expect(ctx.llm.listConfigurableProviders()).toHaveLength(before)
|
|
expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'deepseek-official')?.settingsNs)
|
|
.toBe('llm-deepseek')
|
|
})
|
|
|
|
it('replaces its entries atomically as declared routes come and go', async () => {
|
|
const dir = await home()
|
|
const ctx = await bootWithSettings(dir, {})
|
|
const catalogOnly = ctx.llm.listConfigurableProviders().length
|
|
|
|
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
|
providers: {
|
|
'acme-gateway': {
|
|
apiKey: 'k',
|
|
displayName: 'Acme Gateway',
|
|
api: 'openai-completions',
|
|
baseURL: 'https://acme.test/v1',
|
|
models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
|
|
},
|
|
},
|
|
})
|
|
expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly + 1)
|
|
expect(ctx.llm.listConfigurableProviders().find(entry => entry.provider === 'acme-gateway')?.displayName)
|
|
.toBe('Acme Gateway')
|
|
|
|
await ctx.settings.replace(settingsNamespace('llm-pi-ai'), {})
|
|
expect(ctx.llm.listConfigurableProviders()).toHaveLength(catalogOnly)
|
|
})
|
|
})
|