deepseek-harness/packages/llm/llm-pi-ai/tests/catalog.spec.ts

586 lines
26 KiB
TypeScript
Raw Normal View History

fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
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'
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
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'
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
fix(llm): let a catalog route keep the auth its provider actually declares pi-ai resolves a request's apiKey override only through a provider that declares an api-key method: resolveProviderAuth short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery. A provider with no api-key method at all therefore resolves to nothing, and the request fails with "Provider is not configured" before any network I/O. Two routes hit that. openai-codex ships OAuth alone, so moving off the /compat dispatch broke a profile that names a key for it — the old path handed the token straight to the provider. And a catalog route naming an api was being rebuilt with the harness's own auth, so `openai: {api: openai-completions}` stopped reading OPENAI_API_KEY, contradicting the documented promise that omitting a credential keeps provider-native discovery. Auth is now one decision for both constructions. A catalog route keeps its installed provider's auth, through an api override too: which environment a provider reads belongs to the provider, not to the wire format its models speak. A catalog provider with no api-key method gets the harness method beside its own, but only when the profile names a credential — a keyless codex profile keeps the honest refusal, since this adapter holds no OAuth store to resolve through. Materialization now spreads the installed entry instead of enumerating the result, so a Model field this package does not model survives a pi-ai upgrade; headers went missing from an nvidia route exactly that way once already. providerInfo reports the configured displayName, which also joins the registration facts so a rename re-registers rather than leaving the old label in every selector. A refused registration swap gets its own diagnostic naming the route, matching the directory swap beside it. The README documented endpoint interrogation this layer does not implement, and still described unknown providers as kept-last-good after they became legal declarations refused at the write point. The Agent Note claimed per-model reasoning configurability the schema never had, required capacities the route now defaults, and stated an apiKey override that short-circuits unconditionally.
2026-08-04 16:01:20 +08:00
import { createModels } from '@earendil-works/pi-ai'
import type { Api, Model, Provider } from '@earendil-works/pi-ai'
import { resolveProfiles } from '../src/config.ts'
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
import { buildProvider, supportedProtocols } from '../src/provider.ts'
import { assemble } from './assemble.ts'
import { closeMockServers, mockServer, textEvents } from './mock-server.ts'
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
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,
})
})
fix(llm): size unknown models and refuse a section that cannot be served 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.
2026-08-04 13:32:56 +08:00
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`))
const directory = ctx.llm.listConfigurableProviders()
expect(directory).toContainEqual({
provider: 'acme-gateway',
displayName: 'Acme Gateway',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'acme-gateway'],
// Nothing in the installed catalog answers for this route, which is what
// configuration surfaces mark as a route this deployment declared.
declared: true,
})
// Membership of the catalog, not of the settings document: a shipped
// provider carries a stored profile the moment anyone corrects it.
expect(directory.filter(entry => entry.declared).map(entry => entry.provider))
.toEqual(['acme-gateway'])
expect(directory.find(entry => entry.provider === 'deepseek')?.declared).toBe(false)
})
fix(llm): size unknown models and refuse a section that cannot be served 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.
2026-08-04 13:32:56 +08:00
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] } })
fix(llm): size unknown models and refuse a section that cannot be served 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.
2026-08-04 13:32:56 +08:00
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/)
})
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
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)
fix(llm): let a catalog route keep the auth its provider actually declares pi-ai resolves a request's apiKey override only through a provider that declares an api-key method: resolveProviderAuth short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery. A provider with no api-key method at all therefore resolves to nothing, and the request fails with "Provider is not configured" before any network I/O. Two routes hit that. openai-codex ships OAuth alone, so moving off the /compat dispatch broke a profile that names a key for it — the old path handed the token straight to the provider. And a catalog route naming an api was being rebuilt with the harness's own auth, so `openai: {api: openai-completions}` stopped reading OPENAI_API_KEY, contradicting the documented promise that omitting a credential keeps provider-native discovery. Auth is now one decision for both constructions. A catalog route keeps its installed provider's auth, through an api override too: which environment a provider reads belongs to the provider, not to the wire format its models speak. A catalog provider with no api-key method gets the harness method beside its own, but only when the profile names a credential — a keyless codex profile keeps the honest refusal, since this adapter holds no OAuth store to resolve through. Materialization now spreads the installed entry instead of enumerating the result, so a Model field this package does not model survives a pi-ai upgrade; headers went missing from an nvidia route exactly that way once already. providerInfo reports the configured displayName, which also joins the registration facts so a rename re-registers rather than leaving the old label in every selector. A refused registration swap gets its own diagnostic naming the route, matching the directory swap beside it. The README documented endpoint interrogation this layer does not implement, and still described unknown providers as kept-last-good after they became legal declarations refused at the write point. The Agent Note claimed per-model reasoning configurability the schema never had, required capacities the route now defaults, and stated an apiKey override that short-circuits unconditionally.
2026-08-04 16:01:20 +08:00
expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [], namesCredential: true }))
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
.toThrow(/cannot serve; supported protocols are/)
},
)
it('rejects a protocol this build cannot serve, and a route that names none', () => {
fix(llm): let a catalog route keep the auth its provider actually declares pi-ai resolves a request's apiKey override only through a provider that declares an api-key method: resolveProviderAuth short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery. A provider with no api-key method at all therefore resolves to nothing, and the request fails with "Provider is not configured" before any network I/O. Two routes hit that. openai-codex ships OAuth alone, so moving off the /compat dispatch broke a profile that names a key for it — the old path handed the token straight to the provider. And a catalog route naming an api was being rebuilt with the harness's own auth, so `openai: {api: openai-completions}` stopped reading OPENAI_API_KEY, contradicting the documented promise that omitting a credential keeps provider-native discovery. Auth is now one decision for both constructions. A catalog route keeps its installed provider's auth, through an api override too: which environment a provider reads belongs to the provider, not to the wire format its models speak. A catalog provider with no api-key method gets the harness method beside its own, but only when the profile names a credential — a keyless codex profile keeps the honest refusal, since this adapter holds no OAuth store to resolve through. Materialization now spreads the installed entry instead of enumerating the result, so a Model field this package does not model survives a pi-ai upgrade; headers went missing from an nvidia route exactly that way once already. providerInfo reports the configured displayName, which also joins the registration facts so a rename re-registers rather than leaving the old label in every selector. A refused registration swap gets its own diagnostic naming the route, matching the directory swap beside it. The README documented endpoint interrogation this layer does not implement, and still described unknown providers as kept-last-good after they became legal declarations refused at the write point. The Agent Note claimed per-model reasoning configurability the schema never had, required capacities the route now defaults, and stated an apiKey override that short-circuits unconditionally.
2026-08-04 16:01:20 +08:00
const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [], namesCredential: true }
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)
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
// 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)
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
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])
})
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
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 }] },
},
})
const result = await assemble(ctx, { provider: 'deepseek', model: 'not-configured', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', failure: { 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)
})
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
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'])
})
fix(llm): let a catalog route keep the auth its provider actually declares pi-ai resolves a request's apiKey override only through a provider that declares an api-key method: resolveProviderAuth short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery. A provider with no api-key method at all therefore resolves to nothing, and the request fails with "Provider is not configured" before any network I/O. Two routes hit that. openai-codex ships OAuth alone, so moving off the /compat dispatch broke a profile that names a key for it — the old path handed the token straight to the provider. And a catalog route naming an api was being rebuilt with the harness's own auth, so `openai: {api: openai-completions}` stopped reading OPENAI_API_KEY, contradicting the documented promise that omitting a credential keeps provider-native discovery. Auth is now one decision for both constructions. A catalog route keeps its installed provider's auth, through an api override too: which environment a provider reads belongs to the provider, not to the wire format its models speak. A catalog provider with no api-key method gets the harness method beside its own, but only when the profile names a credential — a keyless codex profile keeps the honest refusal, since this adapter holds no OAuth store to resolve through. Materialization now spreads the installed entry instead of enumerating the result, so a Model field this package does not model survives a pi-ai upgrade; headers went missing from an nvidia route exactly that way once already. providerInfo reports the configured displayName, which also joins the registration facts so a rename re-registers rather than leaving the old label in every selector. A refused registration swap gets its own diagnostic naming the route, matching the directory swap beside it. The README documented endpoint interrogation this layer does not implement, and still described unknown providers as kept-last-good after they became legal declarations refused at the write point. The Agent Note claimed per-model reasoning configurability the schema never had, required capacities the route now defaults, and stated an apiKey override that short-circuits unconditionally.
2026-08-04 16:01:20 +08:00
it('keeps the catalog provider’s own auth when the route repoints its protocol', () => {
// Which environment a provider reads is a property of the provider, not of
// the wire format its models speak: naming an api must not cost a profile
// its provider-native discovery.
const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
expect(resolved.get('openai')?.piProvider.auth.apiKey?.name).toBe('OpenAI API key')
})
it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => {
// pi-ai honours a request's `apiKey` override only when the provider
// declares an api-key method. `openai-codex` ships OAuth alone, so without
// the harness method beside it the route refuses its own configured key as
// `Provider is not configured` before any request goes out.
const resolved = resolveProfiles({ 'openai-codex': { apiKey: 'codex-token' } })
const provider = resolved.get('openai-codex')?.piProvider
expect(provider?.auth.oauth).toBeDefined()
const models = createModels()
models.setProvider(provider as Provider)
const model = provider?.getModels()[0] as Model<Api>
const auth = await models.getAuth(model, { apiKey: 'codex-token' })
expect(auth?.auth.apiKey).toBe('codex-token')
})
it('leaves an OAuth-only catalog route unconfigured when its profile names no key', () => {
// Nothing to add: this adapter resolves credentials through its own seam
// and holds no OAuth store, so declaring the provider configured would
// trade a truthful refusal for an endpoint's 401.
const resolved = resolveProfiles({ 'openai-codex': {} })
expect(resolved.get('openai-codex')?.piProvider.auth.apiKey).toBeUndefined()
})
})
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
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)
})
})