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'
|
2026-08-06 16:52:19 +08:00
|
|
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
build(vendor): rescope the vendored Cordis packages into @deepseek-ai
Machine-produced by `pnpm run rescope-vendor --apply` plus the regeneration it
prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`,
`verify-translation-pairing --write` for the touched bilingual pairs,
`gen-doc-graphs`, and one typert snapshot whose ids embed character offsets.
`pnpm run rescope-vendor --check` verifies the result.
Renames nine vendored packages (cordis, cosmokit, schemastery and the six
@cordisjs plugins) and every reference that resolves them: manifest names and
dependency keys, module specifiers including declare-module merges, cordis.yml
plugin names, tsconfig paths, every Markdown fence, and `docs/` prose.
Directory names, upstream versions, and dependency ranges are unchanged, so
vendor/README.md still reads as an upstream snapshot; its manifest table gains
an upstream-name column so THIRD_PARTY_NOTICES keeps MIT attribution pointed
at each fork's origin.
The tutorial tier follows the rename end to end: its yaml fences named plugins
the Loader can no longer resolve, its `ts ignore-check` fences disagreed with
the compiled fences beside them, and its prose quoted both. The contracts that
told readers to keep upstream names — the root convention and the vendoring
cookbook's tree comment and manifest invariant — now say to rescope instead.
Two rules read `@deepseek-ai/` as "another workspace plugin": the client bundle
purity gate now names the vendored libraries a browser bundle inlines, and the
files where a bare `cordis` is an agent-preset id keep that product data.
2026-08-10 22:04:06 +08:00
|
|
|
|
import { Context } from '@deepseek-ai/cordis'
|
2026-08-13 00:36:22 +08:00
|
|
|
|
import LlmRuntime, { 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'
|
2026-08-13 00:36:22 +08:00
|
|
|
|
import FileSettingsProvider from '@deepseek-ai/dsh-settings-file'
|
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 { 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'
|
2026-08-08 00:51:54 +08:00
|
|
|
|
import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
|
|
|
|
|
|
import type { Api, Model, OpenAICompletionsCompat, 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[] = []
|
|
|
|
|
|
|
2026-08-06 16:52:19 +08:00
|
|
|
|
// Routes name their credential by reference; the value lives in the
|
|
|
|
|
|
// environment, which is the layer the adapter falls back to without a
|
|
|
|
|
|
// mounted credentials seam.
|
|
|
|
|
|
const KEY_ENV = 'PI_TEST_KEY'
|
|
|
|
|
|
|
|
|
|
|
|
beforeEach(() => {
|
|
|
|
|
|
vi.stubEnv(KEY_ENV, 'test-key')
|
|
|
|
|
|
})
|
|
|
|
|
|
|
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
|
|
|
|
afterEach(async () => {
|
2026-08-06 16:52:19 +08:00
|
|
|
|
vi.unstubAllEnvs()
|
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
|
|
|
|
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()
|
2026-08-13 00:36:22 +08:00
|
|
|
|
await ctx.plugin(LlmRuntime)
|
|
|
|
|
|
await ctx.plugin(FileSettingsProvider, { path: join(dir, 'settings.yaml'), watch: false })
|
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
|
|
|
|
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': {
|
2026-08-06 16:52:19 +08:00
|
|
|
|
apiKeyEnv: KEY_ENV,
|
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
|
|
|
|
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()
|
2026-08-13 00:36:22 +08:00
|
|
|
|
await ctx.plugin(LlmRuntime)
|
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
|
|
|
|
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'])
|
2026-08-06 16:52:19 +08:00
|
|
|
|
// The reference resolved through the environment and reached the wire.
|
|
|
|
|
|
expect(server.headers[0]?.authorization).toBe('Bearer test-key')
|
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('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([
|
2026-08-08 01:56:29 +08:00
|
|
|
|
{ provider: 'acme-gateway', id: 'acme-large', name: 'Acme Large', inputModalities: ['text'] },
|
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 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.
|
2026-08-06 16:52:19 +08:00
|
|
|
|
const withCatalog = await harness({ providers: { deepseek: { baseURL: server.url } } })
|
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
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-12 15:11:46 +08:00
|
|
|
|
it('takes a model’s declared modalities, then the catalog’s, then the route’s', () => {
|
|
|
|
|
|
const vision = getBuiltinModels('anthropic').find(model => model.input.includes('image'))
|
|
|
|
|
|
if (vision === undefined) throw new Error('the installed catalog ships no anthropic vision model')
|
|
|
|
|
|
const resolved = resolveProfiles({
|
|
|
|
|
|
'acme-gateway': {
|
|
|
|
|
|
api: 'openai-completions',
|
|
|
|
|
|
baseURL: 'https://acme.test',
|
|
|
|
|
|
// One route, two modality sets: the entry field is what says so.
|
|
|
|
|
|
models: [{ id: 'bare' }, { id: 'seeing', input: ['text', 'image'] }, { id: 'deaf', input: ['text'] }],
|
|
|
|
|
|
},
|
|
|
|
|
|
'seeing-gateway': {
|
|
|
|
|
|
api: 'openai-completions',
|
|
|
|
|
|
baseURL: 'https://seeing.test',
|
|
|
|
|
|
// A gateway whose undescribed models all take images says so once
|
|
|
|
|
|
// rather than on every entry; an entry still outranks it.
|
|
|
|
|
|
defaultInput: ['text', 'image'],
|
|
|
|
|
|
models: [{ id: 'bare' }, { id: 'deaf', input: ['text'] }],
|
|
|
|
|
|
},
|
|
|
|
|
|
// The route value is a fallback, never an override: a catalog model
|
|
|
|
|
|
// keeps what the catalog records even under a narrower route default,
|
|
|
|
|
|
// exactly as it keeps its own contextWindow.
|
|
|
|
|
|
'anthropic': { defaultInput: ['text'] },
|
|
|
|
|
|
})
|
|
|
|
|
|
const inputOf = (route: string, id: string): readonly string[] | undefined =>
|
|
|
|
|
|
resolved.get(route)?.piProvider.getModels().find(model => model.id === id)?.input
|
|
|
|
|
|
|
|
|
|
|
|
expect(inputOf('acme-gateway', 'bare')).toEqual(['text'])
|
|
|
|
|
|
expect(inputOf('acme-gateway', 'seeing')).toEqual(['text', 'image'])
|
|
|
|
|
|
expect(inputOf('acme-gateway', 'deaf')).toEqual(['text'])
|
|
|
|
|
|
expect(inputOf('seeing-gateway', 'bare')).toEqual(['text', 'image'])
|
|
|
|
|
|
expect(inputOf('seeing-gateway', 'deaf')).toEqual(['text'])
|
|
|
|
|
|
expect(inputOf('anthropic', vision.id)).toEqual(vision.input)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-12 15:49:23 +08:00
|
|
|
|
it('carries a written modality declaration all the way to the seam’s model metadata', async () => {
|
|
|
|
|
|
// The resolver-level cases above cannot see a break between the settings
|
|
|
|
|
|
// document and `LlmModelInfo`, so each rung is asserted once more through
|
|
|
|
|
|
// a written section, the plugin's own registration, and `ctx.llm`.
|
|
|
|
|
|
const dir = await home()
|
|
|
|
|
|
const ctx = await bootWithSettings(dir, {})
|
|
|
|
|
|
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
|
|
|
|
|
providers: {
|
|
|
|
|
|
'acme-gateway': {
|
|
|
|
|
|
api: 'openai-completions',
|
|
|
|
|
|
baseURL: 'https://acme.test/v1',
|
|
|
|
|
|
models: [{ id: 'bare' }, { id: 'seeing', input: ['text', 'image'] }],
|
|
|
|
|
|
},
|
|
|
|
|
|
'vision-gateway': {
|
|
|
|
|
|
api: 'openai-completions',
|
|
|
|
|
|
baseURL: 'https://vision.test/v1',
|
|
|
|
|
|
defaultInput: ['text', 'image'],
|
|
|
|
|
|
models: [{ id: 'bare' }, { id: 'deaf', input: ['text'] }],
|
|
|
|
|
|
},
|
|
|
|
|
|
'anthropic': { defaultInput: ['text'] },
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
const listed = async (provider: string): Promise<Record<string, readonly string[] | undefined>> =>
|
|
|
|
|
|
Object.fromEntries((await ctx.llm.listModels(provider)).map(model => [model.id, model.inputModalities]))
|
|
|
|
|
|
|
|
|
|
|
|
expect(await listed('acme-gateway')).toEqual({ bare: ['text'], seeing: ['text', 'image'] })
|
|
|
|
|
|
expect(await listed('vision-gateway')).toEqual({ bare: ['text', 'image'], deaf: ['text'] })
|
|
|
|
|
|
expect((await ctx.llm.resolveModelInfo('acme-gateway', 'seeing')).inputModalities).toEqual(['text', 'image'])
|
|
|
|
|
|
|
|
|
|
|
|
// A catalog vision model keeps what the catalog records even under a
|
|
|
|
|
|
// narrower route default: the route value is a fallback, not an override.
|
|
|
|
|
|
const vision = getBuiltinModels('anthropic').find(model => model.input.includes('image'))
|
|
|
|
|
|
if (vision === undefined) throw new Error('the installed catalog ships no anthropic vision model')
|
|
|
|
|
|
expect((await ctx.llm.resolveModelInfo('anthropic', vision.id)).inputModalities).toEqual(vision.input)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-12 15:11:46 +08:00
|
|
|
|
it('reads an entry’s empty modality list as no answer, and the route’s as unserviceable', () => {
|
|
|
|
|
|
// Absent and empty are the same request on an entry, exactly as they are
|
|
|
|
|
|
// for the route's `models` list — which matters because the config schema
|
|
|
|
|
|
// materializes `[]` for an absent array, so an entry naming a catalog
|
|
|
|
|
|
// model without declaring modalities must keep the catalog's rather than
|
|
|
|
|
|
// describe a model that accepts nothing.
|
|
|
|
|
|
const [catalogModel] = getBuiltinModels('deepseek')
|
|
|
|
|
|
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
|
|
|
|
|
const resolved = resolveProfiles({
|
|
|
|
|
|
'deepseek': { baseURL: 'https://catalog.test', models: [{ id: catalogModel.id, input: [] }] },
|
|
|
|
|
|
'acme-gateway': {
|
|
|
|
|
|
api: 'openai-completions',
|
|
|
|
|
|
baseURL: 'https://acme.test',
|
|
|
|
|
|
models: [{ id: 'bare', input: [] }],
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
expect(resolved.get('acme-gateway')?.piProvider.getModels()[0]?.input).toEqual(['text'])
|
|
|
|
|
|
expect(resolved.get('deepseek')?.piProvider.getModels()[0]?.input).toEqual(catalogModel.input)
|
|
|
|
|
|
|
|
|
|
|
|
// Nothing sits below the route value, so its empty list states no answer
|
|
|
|
|
|
// anything could take, and is refused where it is written.
|
|
|
|
|
|
expect(() => resolveProfiles({
|
|
|
|
|
|
'acme-gateway': {
|
|
|
|
|
|
api: 'openai-completions',
|
|
|
|
|
|
baseURL: 'https://acme.test',
|
|
|
|
|
|
defaultInput: [],
|
|
|
|
|
|
models: [{ id: 'bare' }],
|
|
|
|
|
|
},
|
|
|
|
|
|
})).toThrow(/defaultInput must name at least one modality/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
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('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([])
|
2026-08-06 16:52:19 +08:00
|
|
|
|
const ctx = await harness({ providers: { deepseek: { baseURL: server.url } } })
|
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 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: {
|
|
|
|
|
|
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: {
|
|
|
|
|
|
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: {
|
2026-08-06 16:52:19 +08:00
|
|
|
|
apiKeyEnv: KEY_ENV,
|
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
|
|
|
|
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: {
|
2026-08-06 16:52:19 +08:00
|
|
|
|
deepseek: { baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
|
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-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 }])
|
2026-08-06 16:52:19 +08:00
|
|
|
|
const resolved = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } })
|
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 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: {
|
2026-08-06 16:52:19 +08:00
|
|
|
|
apiKeyEnv: KEY_ENV,
|
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
|
|
|
|
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.
|
2026-08-06 16:52:19 +08:00
|
|
|
|
const resolved = resolveProfiles({ 'openai-codex': { apiKeyEnv: 'CODEX_TOKEN' } })
|
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 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
|
|
|
|
|
2026-08-08 00:51:54 +08:00
|
|
|
|
describe('per-model reasoning efforts', () => {
|
|
|
|
|
|
/** One hand-declared route holding exactly the given models. */
|
|
|
|
|
|
function declared(models: LlmPiAi.PiAiModelProfile[]): Record<string, LlmPiAi.PiAiProviderProfile> {
|
|
|
|
|
|
return { 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models } }
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/** The first materialized model of one route, or throw. */
|
|
|
|
|
|
function modelOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route = 'acme-gateway'): Model<Api> {
|
|
|
|
|
|
const [model] = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
|
|
|
|
|
|
if (model === undefined) throw new Error(`route "${route}" resolved no models`)
|
|
|
|
|
|
return model
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('declares selectable levels with their wire spellings on a hand-declared model', () => {
|
|
|
|
|
|
const model = modelOf(declared([{
|
|
|
|
|
|
id: 'acme-think',
|
|
|
|
|
|
reasoningEfforts: { off: null, low: 'low', high: 'high', max: 'ultra' },
|
|
|
|
|
|
}]))
|
|
|
|
|
|
|
|
|
|
|
|
expect(model.reasoning).toBe(true)
|
|
|
|
|
|
// Undeclared levels are pinned null rather than left to pi-ai's own
|
|
|
|
|
|
// defaulting, which is asymmetric: an absent key means "supported" for the
|
|
|
|
|
|
// five base levels but "unsupported" for xhigh/max. A profile author
|
|
|
|
|
|
// should not need to know that. Declared `off` with no value stays absent
|
|
|
|
|
|
// from the map — supported, send nothing.
|
|
|
|
|
|
expect(model.thinkingLevelMap).toEqual({
|
|
|
|
|
|
minimal: null,
|
|
|
|
|
|
medium: null,
|
|
|
|
|
|
xhigh: null,
|
|
|
|
|
|
low: 'low',
|
|
|
|
|
|
high: 'high',
|
|
|
|
|
|
max: 'ultra',
|
|
|
|
|
|
})
|
|
|
|
|
|
expect(getSupportedThinkingLevels(model)).toEqual(['off', 'low', 'high', 'max'])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-08 10:46:58 +08:00
|
|
|
|
it('keeps a declared off value in the map for dispatch to send', () => {
|
2026-08-08 00:51:54 +08:00
|
|
|
|
const model = modelOf(declared([{ id: 'm', reasoningEfforts: { off: 'none', high: 'high' } }]))
|
|
|
|
|
|
expect(model.thinkingLevelMap?.off).toBe('none')
|
|
|
|
|
|
expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high'])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('offers exactly the declared keys: leaving off out makes thinking mandatory', () => {
|
|
|
|
|
|
const model = modelOf(declared([{ id: 'm', reasoningEfforts: { high: 'high' } }]))
|
|
|
|
|
|
expect(getSupportedThinkingLevels(model)).toEqual(['high'])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('narrows a catalog model’s levels in place', () => {
|
|
|
|
|
|
const [catalogModel] = getBuiltinModels('deepseek')
|
|
|
|
|
|
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
|
|
|
|
|
expect(getSupportedThinkingLevels(catalogModel as Model<Api>)).toEqual(['off', 'high', 'max'])
|
|
|
|
|
|
|
|
|
|
|
|
const model = modelOf({
|
|
|
|
|
|
deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: { off: null, high: 'high' } }] },
|
|
|
|
|
|
}, 'deepseek')
|
|
|
|
|
|
|
|
|
|
|
|
expect(getSupportedThinkingLevels(model)).toEqual(['off', 'high'])
|
|
|
|
|
|
// Only the reasoning fields change; identity and capacities stay catalog.
|
|
|
|
|
|
expect(model.name).toBe(catalogModel.name)
|
|
|
|
|
|
expect(model.contextWindow).toBe(catalogModel.contextWindow)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('strips reasoning from a catalog model with false', () => {
|
|
|
|
|
|
const [catalogModel] = getBuiltinModels('deepseek')
|
|
|
|
|
|
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
|
|
|
|
|
expect(catalogModel.reasoning).toBe(true)
|
|
|
|
|
|
|
|
|
|
|
|
const model = modelOf({ deepseek: { models: [{ id: catalogModel.id, reasoningEfforts: false }] } }, 'deepseek')
|
|
|
|
|
|
|
|
|
|
|
|
expect(model.reasoning).toBe(false)
|
|
|
|
|
|
expect(getSupportedThinkingLevels(model)).toEqual(['off'])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('inherits the catalog capability when the field is absent', () => {
|
|
|
|
|
|
const [catalogModel] = getBuiltinModels('deepseek')
|
|
|
|
|
|
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
|
|
|
|
|
|
|
|
|
|
|
const model = modelOf({ deepseek: { models: [{ id: catalogModel.id }] } }, 'deepseek')
|
|
|
|
|
|
|
|
|
|
|
|
expect(model.reasoning).toBe(catalogModel.reasoning)
|
|
|
|
|
|
expect(model.thinkingLevelMap).toEqual(catalogModel.thinkingLevelMap)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('rejects a declaration that offers nothing or spells a level it cannot send', () => {
|
|
|
|
|
|
const declare = (efforts: NonNullable<LlmPiAi.PiAiModelProfile['reasoningEfforts']>): (() => unknown) =>
|
|
|
|
|
|
() => resolveProfiles(declared([{ id: 'm', reasoningEfforts: efforts }]))
|
|
|
|
|
|
|
|
|
|
|
|
expect(declare({})).toThrow(/empty reasoningEfforts/)
|
|
|
|
|
|
// A YAML `reasoningEfforts:` left valueless arrives as null through the
|
|
|
|
|
|
// schema union; it declares nothing and is not a spelling of "inherit".
|
|
|
|
|
|
expect(declare(null as never)).toThrow(/empty reasoningEfforts/)
|
|
|
|
|
|
expect(declare({ off: null })).toThrow(/offers no level beyond "off"/)
|
|
|
|
|
|
expect(declare({ off: 'none' })).toThrow(/offers no level beyond "off"/)
|
|
|
|
|
|
expect(declare({ high: null })).toThrow(/only "off" may leave it empty/)
|
|
|
|
|
|
expect(declare({ high: '' })).toThrow(/must not be an empty string/)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
feat(llm-pi-ai): modelOverrides reshapes catalog models without replacing the catalog
A route's modelOverrides dict customizes individual installed-catalog
models — key = catalog model id, value = the same fields a models entry
takes — while the rest of the catalog keeps serving, which a models list
cannot express because declaring one replaces the served set. An
override becomes the catalog entry's configuration and resolves through
the existing entry path, so capacities, reasoningEfforts, compat, and
request-default semantics are identical to a models entry's. Unlike
Pi's config layer, which ignores unknown ids, every override that lands
nowhere is refused at the write that produced it: beside a models list,
on a hand-declared route, naming a model the catalog does not describe,
or smuggling an id through the schema's unknown-key tolerance.
2026-08-08 00:57:41 +08:00
|
|
|
|
describe('modelOverrides', () => {
|
|
|
|
|
|
const deepseekModel = (): Model<Api> => {
|
|
|
|
|
|
const [model] = getBuiltinModels('deepseek')
|
|
|
|
|
|
if (model === undefined) throw new Error('the installed catalog ships no deepseek model')
|
|
|
|
|
|
return model
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('reshapes one catalog model while the rest of the catalog keeps serving', () => {
|
|
|
|
|
|
const catalogSize = getBuiltinModels('deepseek').length
|
|
|
|
|
|
const target = deepseekModel()
|
|
|
|
|
|
const resolved = resolveProfiles({
|
|
|
|
|
|
deepseek: {
|
|
|
|
|
|
modelOverrides: {
|
|
|
|
|
|
[target.id]: {
|
|
|
|
|
|
name: 'DeepSeek (proxied)',
|
|
|
|
|
|
maxTokens: 4096,
|
|
|
|
|
|
reasoningEfforts: { off: null, high: 'high' },
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
})
|
|
|
|
|
|
const models = resolved.get('deepseek')?.piProvider.getModels() ?? []
|
|
|
|
|
|
const reshaped = models.find(model => model.id === target.id)
|
|
|
|
|
|
if (reshaped === undefined) throw new Error('the overridden model vanished from the route')
|
|
|
|
|
|
|
|
|
|
|
|
// The whole catalog still serves — that is the difference from `models`,
|
|
|
|
|
|
// which replaces it.
|
|
|
|
|
|
expect(models).toHaveLength(catalogSize)
|
|
|
|
|
|
expect(reshaped.name).toBe('DeepSeek (proxied)')
|
|
|
|
|
|
expect(getSupportedThinkingLevels(reshaped)).toEqual(['off', 'high'])
|
|
|
|
|
|
// An override's cap is explicit configuration, so it becomes the request
|
|
|
|
|
|
// default exactly as a models entry's would.
|
|
|
|
|
|
expect(resolved.get('deepseek')?.configuredMaxTokens.get(target.id)).toBe(4096)
|
|
|
|
|
|
// A sibling the overrides do not name is byte-identical to the catalog.
|
|
|
|
|
|
const sibling = models.find(model => model.id !== target.id)
|
|
|
|
|
|
expect(sibling?.maxTokens).toBe(getBuiltinModels('deepseek').find(model => model.id === sibling?.id)?.maxTokens)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('refuses every override that lands nowhere instead of skipping it', () => {
|
|
|
|
|
|
expect(() => resolveProfiles({
|
|
|
|
|
|
deepseek: { modelOverrides: { 'no-such-model': { name: 'ghost' } } },
|
|
|
|
|
|
})).toThrow(/which the installed catalog does not describe/)
|
|
|
|
|
|
expect(() => resolveProfiles({
|
|
|
|
|
|
'acme-gateway': {
|
|
|
|
|
|
api: 'openai-completions',
|
|
|
|
|
|
baseURL: 'https://acme.test',
|
|
|
|
|
|
models: [{ id: 'm' }],
|
|
|
|
|
|
modelOverrides: { m: { name: 'renamed' } },
|
|
|
|
|
|
},
|
|
|
|
|
|
})).toThrow(/a declared route spells every model out/)
|
|
|
|
|
|
const declaredOnly = deepseekModel()
|
|
|
|
|
|
expect(() => resolveProfiles({
|
|
|
|
|
|
deepseek: {
|
|
|
|
|
|
models: [{ id: declaredOnly.id }],
|
|
|
|
|
|
modelOverrides: { [declaredOnly.id]: { name: 'renamed' } },
|
|
|
|
|
|
},
|
|
|
|
|
|
})).toThrow(/models already replaces the served catalog/)
|
|
|
|
|
|
expect(() => resolveProfiles({
|
|
|
|
|
|
deepseek: { modelOverrides: { '': { name: 'nameless' } } },
|
|
|
|
|
|
})).toThrow(/empty model id/)
|
|
|
|
|
|
// The dict key is the id; a value smuggling its own would quietly rename
|
|
|
|
|
|
// the model it meant to customize. The schema passes unknown keys
|
|
|
|
|
|
// through, so resolution is the boundary that refuses it — the variable
|
|
|
|
|
|
// indirection mirrors that boundary by sidestepping the literal check.
|
|
|
|
|
|
const smuggled = { name: 'x', id: 'other' }
|
|
|
|
|
|
expect(() => resolveProfiles({
|
|
|
|
|
|
deepseek: { modelOverrides: { [deepseekModel().id]: smuggled } },
|
|
|
|
|
|
})).toThrow(/sets "id", which is the dict key/)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-08 00:51:54 +08:00
|
|
|
|
describe('reasoning-dispatch compat switches', () => {
|
|
|
|
|
|
/** The materialized models of one route, keyed by id. */
|
|
|
|
|
|
function modelsOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>, route: string): Map<string, Model<Api>> {
|
|
|
|
|
|
const models = resolveProfiles(providers).get(route)?.piProvider.getModels() ?? []
|
|
|
|
|
|
return new Map(models.map(model => [model.id, model]))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
it('applies route switches to every openai-completions model, entries winning per field', () => {
|
|
|
|
|
|
const models = modelsOf({
|
|
|
|
|
|
'acme-gateway': {
|
|
|
|
|
|
api: 'openai-completions',
|
|
|
|
|
|
baseURL: 'https://acme.test',
|
|
|
|
|
|
compat: { thinkingFormat: 'deepseek' },
|
|
|
|
|
|
models: [
|
|
|
|
|
|
{ id: 'dialect-default', reasoningEfforts: { off: null, high: 'high' } },
|
|
|
|
|
|
{ id: 'dialect-odd', compat: { thinkingFormat: 'openai', supportsReasoningEffort: false } },
|
|
|
|
|
|
],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, 'acme-gateway')
|
|
|
|
|
|
|
|
|
|
|
|
expect(models.get('dialect-default')?.compat).toEqual({ thinkingFormat: 'deepseek' })
|
|
|
|
|
|
expect(models.get('dialect-odd')?.compat).toEqual({ thinkingFormat: 'openai', supportsReasoningEffort: false })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('merges the switches over the catalog entry’s own compat instead of replacing it', () => {
|
|
|
|
|
|
const [catalogModel] = getBuiltinModels('deepseek')
|
|
|
|
|
|
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
|
|
|
|
|
|
const inherited = catalogModel.compat as OpenAICompletionsCompat
|
|
|
|
|
|
expect(inherited.requiresReasoningContentOnAssistantMessages).toBe(true)
|
|
|
|
|
|
|
|
|
|
|
|
const models = modelsOf({
|
|
|
|
|
|
deepseek: { models: [{ id: catalogModel.id, compat: { thinkingFormat: 'openai' } }] },
|
|
|
|
|
|
}, 'deepseek')
|
|
|
|
|
|
|
|
|
|
|
|
// The one switched field changes; the catalog's other quirks survive,
|
|
|
|
|
|
// because configuration has no way to restate them.
|
|
|
|
|
|
expect(models.get(catalogModel.id)?.compat).toEqual({ ...inherited, thinkingFormat: 'openai' })
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('skips models of other protocols on a mixed route instead of failing them', () => {
|
|
|
|
|
|
// xai ships both completions and responses models, so a route-level switch
|
|
|
|
|
|
// must land on the former without invalidating the latter.
|
|
|
|
|
|
const catalog = getBuiltinModels('xai') as readonly Model<Api>[]
|
|
|
|
|
|
const completions = catalog.find(model => model.api === 'openai-completions')
|
|
|
|
|
|
const responses = catalog.find(model => model.api === 'openai-responses')
|
|
|
|
|
|
if (completions === undefined || responses === undefined) throw new Error('xai no longer ships a mixed catalog')
|
|
|
|
|
|
|
|
|
|
|
|
const models = modelsOf({
|
|
|
|
|
|
xai: {
|
|
|
|
|
|
compat: { supportsReasoningEffort: false },
|
|
|
|
|
|
models: [{ id: completions.id }, { id: responses.id }],
|
|
|
|
|
|
},
|
|
|
|
|
|
}, 'xai')
|
|
|
|
|
|
|
|
|
|
|
|
expect((models.get(completions.id)?.compat as OpenAICompletionsCompat).supportsReasoningEffort).toBe(false)
|
|
|
|
|
|
expect(models.get(responses.id)?.compat).toEqual(responses.compat)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('rejects a model-level switch on a protocol that has no such field', () => {
|
|
|
|
|
|
expect(() => resolveProfiles({
|
|
|
|
|
|
anthropic: {
|
|
|
|
|
|
models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }],
|
|
|
|
|
|
},
|
|
|
|
|
|
})).toThrow(/exist only on openai-completions/)
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
it('rejects route switches no model on the route can take', () => {
|
|
|
|
|
|
expect(() => resolveProfiles({
|
|
|
|
|
|
anthropic: { compat: { thinkingFormat: 'openai' } },
|
|
|
|
|
|
})).toThrow(/no model on the route speaks openai-completions/)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|
|
|
|
|
|
|
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 }])
|
2026-08-06 16:52:19 +08:00
|
|
|
|
let current = resolveProfiles({ deepseek: { baseURL: `${server.url}/v1` } })
|
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
|
|
|
|
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.
|
2026-08-06 16:52:19 +08:00
|
|
|
|
current = resolveProfiles({ openai: { baseURL: `${server.url}/v1` } })
|
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
|
|
|
|
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 }])
|
2026-08-06 16:52:19 +08:00
|
|
|
|
let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } })
|
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 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()
|
2026-08-06 16:52:19 +08:00
|
|
|
|
current = resolveProfiles({ deepseek: { baseURL: `${second.url}/v1` } })
|
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
|
|
|
|
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': {
|
|
|
|
|
|
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': {
|
|
|
|
|
|
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)
|
|
|
|
|
|
})
|
|
|
|
|
|
})
|