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'
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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'
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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'
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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'
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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'
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
|
|
|
|
|
|
/** 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')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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`))
|
2026-08-07 13:49:47 +08:00
|
|
|
|
const directory = ctx.llm.listConfigurableProviders()
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
|
2026-08-07 13:49:47 +08:00
|
|
|
|
expect(directory).toContainEqual({
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
provider: 'acme-gateway',
|
|
|
|
|
|
displayName: 'Acme Gateway',
|
|
|
|
|
|
settingsNs: 'llm-pi-ai',
|
|
|
|
|
|
settingsPath: ['providers', 'acme-gateway'],
|
2026-08-07 13:49:47 +08:00
|
|
|
|
// Nothing in the installed catalog answers for this route, which is what
|
|
|
|
|
|
// configuration surfaces mark as a route this deployment declared.
|
|
|
|
|
|
declared: true,
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
})
|
2026-08-07 13:49:47 +08:00
|
|
|
|
// 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)
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
})
|
|
|
|
|
|
|
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', () => {
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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/)
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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/)
|
|
|
|
|
|
},
|
|
|
|
|
|
)
|
|
|
|
|
|
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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 }
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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.
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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()
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
// 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)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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 }] },
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-06 10:47:36 +08:00
|
|
|
|
const result = await assemble(ctx, { provider: 'deepseek', model: 'not-configured', messages: [] })
|
|
|
|
|
|
|
|
|
|
|
|
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNKNOWN_MODEL' } })
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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'])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
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()
|
|
|
|
|
|
})
|
feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that
catalog's models verbatim, and could override only the endpoint. An
OpenAI-compatible gateway, a self-hosted server, or a model newer than
the pinned pi-ai release was therefore unreachable, and a stale context
window could not be corrected without upgrading the package.
A route is now a declaration whose defaults come from the installed
catalog. `catalog.ts` merges that catalog under the profile's own model
entries, `provider.ts` builds the pi-ai Provider (reusing the catalog
provider when the route keeps its protocol, so implementations this
package cannot reconstruct keep working), and the adapter serves every
operation from one `createModels()` collection. That also retires the
`@earendil-works/pi-ai/compat` import, which pi-ai documents as a
temporary entry point it deletes with its ModelManager migration.
Credentials stay on the harness seam: the resolved key rides the request
as pi-ai's highest-priority auth override, so `Models` holds no
credential store and a named-but-missing reference still fails loud
instead of falling back to an unrelated ambient key.
A model's configured maxTokens now reaches the seam as defaultMaxTokens.
2026-08-04 00:19:09 +08:00
|
|
|
|
})
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|