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

1195 lines
53 KiB
TypeScript
Raw Normal View History

fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, 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'
import LlmRuntime, { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm'
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
import type { StreamChunk } from '@deepseek-ai/dsh-llm'
import 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'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
import type { Api, Model, OpenAICompletionsCompat, Provider } from '@earendil-works/pi-ai'
import { resolveProfiles } from '../src/config.ts'
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
import { buildProvider, supportedProtocols } from '../src/provider.ts'
import { assemble } from './assemble.ts'
feat(llm-pi-ai): sign in to a provider instead of withholding it pi-ai's auth model reaches this adapter through three translations, all of which live here: a CredentialStore over the harness credential records, an AuthContext over the credential plane and the host filesystem, and one authorization flow per installed provider that ships a login. The seams they consume name nothing from pi-ai, so a second adapter family can arrive with a different auth model and share them. Every collection is now built with the store and the context rather than with nothing, which is what makes a signed-in provider stay signed in across the collection rebuild a configuration change causes. With a posture that works, the configurable-provider directory no longer withholds OAuth-only routes and `openai-codex` is offered again; the predicate that withheld it is gone. The credential plane stays optional. Reads answer "nothing stored" without a credentials service because such a composition genuinely holds no credential, while writes refuse by name — a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam, so a headless or ACP composition mounts with no sign-in and everything else unchanged. Two fixes found while wiring this up: pre-release credential fixtures in the llm suites still used the flat document the record work replaced, and a flow that ignores its cancellation signal would have held its key for the life of the process — withdrawal now settles the attempt either way.
2026-08-13 15:54:47 +08:00
import { memoryAuth } from './auth-double.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[] = []
// 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 () => {
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()
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
}
/** 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': {
apiKeyEnv: KEY_ENV,
displayName: 'Acme Gateway',
api: 'openai-completions',
baseURL,
models: [{ id: 'acme-large', name: 'Acme Large', contextWindow: 65_536, maxTokens: 4096 }],
...overrides,
},
},
}
}
async function harness(config: LlmPiAi.Config): Promise<Context> {
const ctx = new Context()
await ctx.plugin(LlmRuntime)
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'])
// The reference resolved through the environment and reached the wire.
expect(server.headers[0]?.authorization).toBe('Bearer test-key')
})
it('lists and resolves the declared models rather than a catalog', async () => {
const server = await mockServer([])
const ctx = await harness(gateway(`${server.url}/v1`))
expect(await ctx.llm.listModels('acme-gateway')).toEqual([
Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input # Conflicts: # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.i18n.yaml # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.md # .agents/notes/implemented/architecture/2026-07-05-reconstructable-requests.zh.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.i18n.yaml # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.md # .agents/notes/implemented/architecture/2026-07-25-web-input-machine-and-slash-pipeline.zh.md # THIRD_PARTY_NOTICES.md # apps/cli/composition.md # apps/cli/config/base.cordis.yml # apps/cli/package.json # apps/cli/src/app-cli-entry.ts # apps/cli/src/bin.ts # apps/cli/tests/args.spec.ts # apps/web/tests/built-boot.snapshot.ts # apps/web/tests/navigation-panes.e2e.ts # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/services.md # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/llm-streaming.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # packages/README.i18n.yaml # packages/bundle/README.i18n.yaml # packages/client/connection/README.i18n.yaml # packages/client/connection/README.md # packages/client/connection/README.zh.md # packages/client/connection/src/client/fixture.ts # packages/client/connection/src/http-bridge.ts # packages/client/connection/src/index.ts # packages/client/connection/tests/fixture.spec.ts # packages/client/connection/tests/node-half.spec.ts # packages/client/runtime/README.i18n.yaml # packages/client/runtime/README.md # packages/client/runtime/README.zh.md # packages/client/runtime/src/client/contract/session.ts # packages/client/runtime/src/client/sessions/session.ts # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/apply.ts # packages/client/ui-conversation/src/client/chat/AssistantMarkdown.tsx # packages/client/ui-conversation/src/client/chat/ChatView.tsx # packages/client/ui-conversation/src/client/chat/MessageItem.module.css # packages/client/ui-conversation/src/client/chat/MessageItem.tsx # packages/client/ui-conversation/src/client/contract/slots.ts # packages/client/ui-conversation/src/client/index.ts # packages/client/ui-conversation/src/client/input/contract.ts # packages/client/ui-conversation/src/client/input/facade.ts # packages/client/ui-conversation/src/client/input/hub.ts # packages/client/ui-conversation/src/client/locales.ts # packages/client/ui-conversation/src/client/service.ts # packages/client/ui-conversation/src/client/skeleton/ConversationSession.tsx # packages/client/ui-conversation/src/client/skeleton/InputBar.tsx # packages/client/ui-conversation/tests/apply-inject.spec.tsx # packages/client/ui-conversation/tests/input-bar.spec.tsx # packages/client/ui-conversation/tests/input-matrix.spec.tsx # packages/client/ui-conversation/tests/input-scenarios.spec.tsx # packages/client/ui-conversation/tests/service-orchestration.spec.ts # packages/client/ui-conversation/tests/skeleton.spec.tsx # packages/client/ui-trajectory/tests/views.spec.tsx # packages/compact/compact-basic/README.i18n.yaml # packages/cordis/tool-cordis/src/api-catalog.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api-proxy.ts # packages/host/apiproxy/src/api/rpc.ts # packages/host/apiproxy/src/api/sessions.ts # packages/host/apiproxy/src/index.ts # packages/host/apiproxy/tests/api-proxy-models.spec.ts # packages/host/apiproxy/tests/rpc-schemas.spec.ts # packages/llm/llm-pi-ai/README.i18n.yaml # packages/llm/llm-pi-ai/README.md # packages/llm/llm-pi-ai/README.zh.md # packages/llm/llm-pi-ai/src/adapter.ts # packages/llm/llm/README.i18n.yaml # packages/ui/tui/README.md # packages/ui/tui/README.zh.md # packages/ui/tui/src/components/content.ts # packages/ui/tui/src/components/transcript.ts # packages/ui/tui/tests/tui.spec.ts # pnpm-lock.yaml
2026-08-08 01:56:29 +08:00
{ provider: 'acme-gateway', id: 'acme-large', name: 'Acme Large', inputModalities: ['text'] },
])
const info = await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')
expect(info).toMatchObject({
provider: 'acme-gateway',
id: 'acme-large',
name: 'Acme Large',
context: { contextWindow: 65_536 },
defaultMaxTokens: 4096,
})
})
fix(llm): size unknown models and refuse a section that cannot be served Three defects surfaced while driving the Models page. A hand-declared model needed an explicit contextWindow and maxTokens, but a provider listing usually returns ids and nothing else — so the page happily wrote a profile the adapter then rejected, which took the whole namespace down silently. Capacities now fall back to the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields a deployment corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. That silent failure was the second defect. A schema-valid profile the adapter could not serve was stored and only rejected later, disabling every route in the namespace with nothing said. `dsh-settings` gains an optional `validate` on registration — a check for what a schema cannot express — and `llm-pi-ai` refuses an unserviceable section at the write that produced it. A stored section that fails keeps the namespace's last good value, as a schema failure already did, so an externally edited document still cannot strand the owner. The plugin's own last-good fallback goes with it: nothing reaching it can fail any more. Third, a model with no reasoning metadata advertised the single level `off`, which pi-ai translates to *omitting* the reasoning option — the same request naming no effort produces. Selecting it disabled nothing, so a provider whose default is to think kept thinking with `off` shown as selected. Such a model now reports no reasoning capability at all, which is the seam's way of saying the control is unavailable, and the per-model `reasoning` flag is gone: without a thinkingLevelMap to spell levels it could only invent them. The protocol table narrows to the three a hand-declared route reaches today, most-reached first so a surface offering a choice defaults to the one gateways actually speak.
2026-08-04 13:32:56 +08:00
it('offers no reasoning control it could not honour', async () => {
const server = await mockServer([])
const ctx = await harness(gateway(`${server.url}/v1`))
// pi-ai reports a model with no reasoning metadata as supporting the single
// level `off`, but `off` is translated to *omitting* the reasoning option —
// byte-for-byte the same request as naming no effort — so a provider whose
// own default is to think would keep thinking with `off` selected. The
// capability is reported unavailable instead of offering that control.
expect((await ctx.llm.resolveModelInfo('acme-gateway', 'acme-large')).reasoning).toBeUndefined()
// A catalog route is unaffected: its models carry the metadata that makes
// `off` actually disable thinking.
const withCatalog = await harness({ providers: { deepseek: { 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')
})
it('joins the configurable-provider directory so a settings surface can reach it', async () => {
const server = await mockServer([])
const ctx = await harness(gateway(`${server.url}/v1`))
const directory = ctx.llm.listConfigurableProviders()
expect(directory).toContainEqual({
provider: 'acme-gateway',
displayName: 'Acme Gateway',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'acme-gateway'],
// Nothing in the installed catalog answers for this route, which is what
// configuration surfaces mark as a route this deployment declared.
declared: true,
})
// Membership of the catalog, not of the settings document: a shipped
// provider carries a stored profile the moment anyone corrects it.
expect(directory.filter(entry => entry.declared).map(entry => entry.provider))
.toEqual(['acme-gateway'])
expect(directory.find(entry => entry.provider === 'deepseek')?.declared).toBe(false)
})
fix(llm): size unknown models and refuse a section that cannot be served Three defects surfaced while driving the Models page. A hand-declared model needed an explicit contextWindow and maxTokens, but a provider listing usually returns ids and nothing else — so the page happily wrote a profile the adapter then rejected, which took the whole namespace down silently. Capacities now fall back to the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields a deployment corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. That silent failure was the second defect. A schema-valid profile the adapter could not serve was stored and only rejected later, disabling every route in the namespace with nothing said. `dsh-settings` gains an optional `validate` on registration — a check for what a schema cannot express — and `llm-pi-ai` refuses an unserviceable section at the write that produced it. A stored section that fails keeps the namespace's last good value, as a schema failure already did, so an externally edited document still cannot strand the owner. The plugin's own last-good fallback goes with it: nothing reaching it can fail any more. Third, a model with no reasoning metadata advertised the single level `off`, which pi-ai translates to *omitting* the reasoning option — the same request naming no effort produces. Selecting it disabled nothing, so a provider whose default is to think kept thinking with `off` shown as selected. Such a model now reports no reasoning capability at all, which is the seam's way of saying the control is unavailable, and the per-model `reasoning` flag is gone: without a thinkingLevelMap to spell levels it could only invent them. The protocol table narrows to the three a hand-declared route reaches today, most-reached first so a surface offering a choice defaults to the one gateways actually speak.
2026-08-04 13:32:56 +08:00
it('sizes a model the catalog cannot describe from the route\u2019s own fallbacks', () => {
const resolved = resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
// A listing endpoint that discloses nothing but ids still yields a
// serviceable route.
models: [{ id: 'bare' }, { id: 'sized', contextWindow: 8192, maxTokens: 512 }],
},
'tuned-gateway': {
api: 'openai-completions',
baseURL: 'https://tuned.test',
defaultContextWindow: 4096,
defaultMaxTokens: 256,
models: [{ id: 'bare' }],
},
})
const modelsOf = (route: string): readonly { id: string; contextWindow: number; maxTokens: number }[] =>
resolved.get(route)?.piProvider.getModels() ?? []
expect(modelsOf('acme-gateway')).toMatchObject([
{ id: 'bare', contextWindow: 262_144, maxTokens: 32_768 },
{ id: 'sized', contextWindow: 8192, maxTokens: 512 },
])
// The fallback is a guess, so a deployment whose gateway serves smaller
// models corrects it once for the whole route.
expect(modelsOf('tuned-gateway')).toMatchObject([{ id: 'bare', contextWindow: 4096, maxTokens: 256 }])
// Only an explicitly configured cap is a request default; a fallback is
// the model's capability and stops there.
expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('bare')).toBeUndefined()
expect(resolved.get('acme-gateway')?.configuredMaxTokens.get('sized')).toBe(512)
})
it('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)
})
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)
})
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', () => {
const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) =>
() => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } })
fix(llm): size unknown models and refuse a section that cannot be served Three defects surfaced while driving the Models page. A hand-declared model needed an explicit contextWindow and maxTokens, but a provider listing usually returns ids and nothing else — so the page happily wrote a profile the adapter then rejected, which took the whole namespace down silently. Capacities now fall back to the route's `defaultContextWindow` (262,144) and `defaultMaxTokens` (32,768). Both are guesses by construction, which is why they are route fields a deployment corrects once rather than constants buried in the adapter; the fallback sizes the model and never becomes a per-request cap. That silent failure was the second defect. A schema-valid profile the adapter could not serve was stored and only rejected later, disabling every route in the namespace with nothing said. `dsh-settings` gains an optional `validate` on registration — a check for what a schema cannot express — and `llm-pi-ai` refuses an unserviceable section at the write that produced it. A stored section that fails keeps the namespace's last good value, as a schema failure already did, so an externally edited document still cannot strand the owner. The plugin's own last-good fallback goes with it: nothing reaching it can fail any more. Third, a model with no reasoning metadata advertised the single level `off`, which pi-ai translates to *omitting* the reasoning option — the same request naming no effort produces. Selecting it disabled nothing, so a provider whose default is to think kept thinking with `off` shown as selected. Such a model now reports no reasoning capability at all, which is the seam's way of saying the control is unavailable, and the per-model `reasoning` flag is gone: without a thinkingLevelMap to spell levels it could only invent them. The protocol table narrows to the three a hand-declared route reaches today, most-reached first so a surface offering a choice defaults to the one gateways actually speak.
2026-08-04 13:32:56 +08:00
expect(declare({ id: '' })).toThrow(/empty id/)
expect(() => resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{ id: 'dup', contextWindow: 1, maxTokens: 1 }, { id: 'dup', contextWindow: 2, maxTokens: 2 }],
},
})).toThrow(/more than once/)
})
it('rejects a declaration that names no wire protocol or endpoint', () => {
expect(() => resolveProfiles({
'acme-gateway': { baseURL: 'https://acme.test', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
})).toThrow(/needs an api/)
expect(() => resolveProfiles({
'acme-gateway': { api: 'openai-completions', models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }] },
})).toThrow(/needs a baseURL/)
})
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
it.each(['bedrock-converse-stream', 'google-vertex', 'azure-openai-responses', 'openai-codex-responses'])(
'refuses %s, whose authentication a profile cannot express',
(api) => {
// These need SigV4 credentials and a region, a project plus ADC, provider
// environment and an api-version, or OAuth — none of which a key, an
// endpoint, and headers can carry, so a route naming one would be built
// unable to authenticate.
expect(supportedProtocols()).not.toContain(api)
fix(llm): let a catalog route keep the auth its provider actually declares pi-ai resolves a request's apiKey override only through a provider that declares an api-key method: resolveProviderAuth short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery. A provider with no api-key method at all therefore resolves to nothing, and the request fails with "Provider is not configured" before any network I/O. Two routes hit that. openai-codex ships OAuth alone, so moving off the /compat dispatch broke a profile that names a key for it — the old path handed the token straight to the provider. And a catalog route naming an api was being rebuilt with the harness's own auth, so `openai: {api: openai-completions}` stopped reading OPENAI_API_KEY, contradicting the documented promise that omitting a credential keeps provider-native discovery. Auth is now one decision for both constructions. A catalog route keeps its installed provider's auth, through an api override too: which environment a provider reads belongs to the provider, not to the wire format its models speak. A catalog provider with no api-key method gets the harness method beside its own, but only when the profile names a credential — a keyless codex profile keeps the honest refusal, since this adapter holds no OAuth store to resolve through. Materialization now spreads the installed entry instead of enumerating the result, so a Model field this package does not model survives a pi-ai upgrade; headers went missing from an nvidia route exactly that way once already. providerInfo reports the configured displayName, which also joins the registration facts so a rename re-registers rather than leaving the old label in every selector. A refused registration swap gets its own diagnostic naming the route, matching the directory swap beside it. The README documented endpoint interrogation this layer does not implement, and still described unknown providers as kept-last-good after they became legal declarations refused at the write point. The Agent Note claimed per-model reasoning configurability the schema never had, required capacities the route now defaults, and stated an apiKey override that short-circuits unconditionally.
2026-08-04 16:01:20 +08:00
expect(() => buildProvider({ provider: 'acme-gateway', displayName: 'Acme', api, models: [], namesCredential: true }))
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
.toThrow(/cannot serve; supported protocols are/)
},
)
it('rejects a protocol this build cannot serve, and a route that names none', () => {
fix(llm): let a catalog route keep the auth its provider actually declares pi-ai resolves a request's apiKey override only through a provider that declares an api-key method: resolveProviderAuth short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery. A provider with no api-key method at all therefore resolves to nothing, and the request fails with "Provider is not configured" before any network I/O. Two routes hit that. openai-codex ships OAuth alone, so moving off the /compat dispatch broke a profile that names a key for it — the old path handed the token straight to the provider. And a catalog route naming an api was being rebuilt with the harness's own auth, so `openai: {api: openai-completions}` stopped reading OPENAI_API_KEY, contradicting the documented promise that omitting a credential keeps provider-native discovery. Auth is now one decision for both constructions. A catalog route keeps its installed provider's auth, through an api override too: which environment a provider reads belongs to the provider, not to the wire format its models speak. A catalog provider with no api-key method gets the harness method beside its own, but only when the profile names a credential — a keyless codex profile keeps the honest refusal, since this adapter holds no OAuth store to resolve through. Materialization now spreads the installed entry instead of enumerating the result, so a Model field this package does not model survives a pi-ai upgrade; headers went missing from an nvidia route exactly that way once already. providerInfo reports the configured displayName, which also joins the registration facts so a rename re-registers rather than leaving the old label in every selector. A refused registration swap gets its own diagnostic naming the route, matching the directory swap beside it. The README documented endpoint interrogation this layer does not implement, and still described unknown providers as kept-last-good after they became legal declarations refused at the write point. The Agent Note claimed per-model reasoning configurability the schema never had, required capacities the route now defaults, and stated an apiKey override that short-circuits unconditionally.
2026-08-04 16:01:20 +08:00
const spec = { provider: 'acme-gateway', displayName: 'Acme Gateway', models: [], namesCredential: true }
expect(() => buildProvider({ ...spec, api: 'quantum-telepathy' }))
.toThrow(/cannot serve; supported protocols are/)
expect(() => buildProvider(spec)).toThrow(/cannot serve; supported protocols are/)
})
it('leaves an unauthenticated route to its protocol rather than inventing a credential', async () => {
const server = await mockServer([{ events: textEvents }])
// Naming no credential is the deliberately unauthenticated posture — a
// named reference that resolved to nothing would have failed with
// MISSING_CREDENTIAL long before this point. The route resolves as
// configured and the protocol decides: pi-ai's OpenAI-compatible
// implementation wants a key or an Authorization header of its own, and
// says so instead of the harness guessing a placeholder.
const ctx = await harness({
providers: {
'local-llm': {
api: 'openai-completions',
baseURL: `${server.url}/v1`,
models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }],
},
},
})
const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] })
expect(result.finish).toMatchObject({
kind: 'error',
failure: { message: 'No API key for provider: local-llm' },
})
expect(server.requests).toHaveLength(0)
})
it('authenticates an unauthenticated route through a configured header', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness({
providers: {
'local-llm': {
api: 'openai-completions',
baseURL: `${server.url}/v1`,
headers: { Authorization: 'Bearer local' },
models: [{ id: 'qwen3', contextWindow: 32_768, maxTokens: 2048 }],
},
},
})
const result = await assemble(ctx, { provider: 'local-llm', model: 'qwen3', messages: [] })
expect(result.finish).toEqual({ kind: 'stop' })
expect(server.headers[0]?.authorization).toBe('Bearer local')
})
it('rejects a capacity that is not a positive integer', () => {
const declare = (model: LlmPiAi.PiAiModelProfile): (() => unknown) =>
() => resolveProfiles({ 'acme-gateway': { api: 'openai-completions', baseURL: 'https://acme.test', models: [model] } })
expect(declare({ id: 'm', contextWindow: 0, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/)
expect(declare({ id: 'm', contextWindow: 1.5, maxTokens: 1 })).toThrow(/contextWindow must be a positive integer/)
expect(declare({ id: 'm', contextWindow: 1, maxTokens: 0 })).toThrow(/maxTokens must be a positive integer/)
expect(declare({ id: 'm', contextWindow: 1, maxTokens: 1.5 })).toThrow(/maxTokens must be a positive integer/)
})
it('names the route key when no displayName is configured', () => {
const resolved = resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{ id: 'm', contextWindow: 1, maxTokens: 1 }],
},
})
expect(resolved.get('acme-gateway')?.displayName).toBe('acme-gateway')
expect(() => resolveProfiles({ 'acme-gateway': { displayName: '' } })).toThrow(/empty displayName/)
})
})
describe('catalog routes with per-model configuration', () => {
it('serves the installed catalog untouched when the profile lists no models', async () => {
const server = await mockServer([])
const ctx = await harness({ providers: { deepseek: { baseURL: server.url } } })
const listed = await ctx.llm.listModels('deepseek')
expect(listed.map(model => model.id).sort())
.toEqual(getBuiltinModels('deepseek').map(model => model.id).sort())
})
it('overrides one catalog model field and defaults the rest from the catalog', async () => {
const server = await mockServer([])
const [catalogModel] = getBuiltinModels('deepseek')
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
const ctx = await harness({
providers: {
deepseek: {
baseURL: server.url,
models: [{ id: catalogModel.id, contextWindow: 4096 }],
},
},
})
const info = await ctx.llm.resolveModelInfo('deepseek', catalogModel.id)
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
// The configured field wins and the name still comes from the catalog. The
// catalog's own output cap is the model's capability, not a cap anyone
// chose, so it must not arrive as the request default.
expect(info.context).toEqual({ contextWindow: 4096 })
expect(info.name).toBe(catalogModel.name)
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
expect(info.defaultMaxTokens).toBeUndefined()
// An explicit list replaces the catalog rather than adding to it.
expect((await ctx.llm.listModels('deepseek')).map(model => model.id)).toEqual([catalogModel.id])
})
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
it('materializes a request default only from a configured output cap', async () => {
const server = await mockServer([])
const [catalogModel] = getBuiltinModels('deepseek')
if (catalogModel === undefined) throw new Error('the installed catalog ships no deepseek model')
const ctx = await harness({
providers: {
deepseek: {
baseURL: server.url,
models: [{ id: catalogModel.id, maxTokens: 4096 }],
},
},
})
// Configuring the cap is the deployment choosing one, so it becomes the
// default the seam materializes into requests that name none.
expect((await ctx.llm.resolveModelInfo('deepseek', catalogModel.id)).defaultMaxTokens).toBe(4096)
})
it('adds a model the installed catalog does not describe to a catalog route', async () => {
const server = await mockServer([{ events: textEvents }])
const ctx = await harness({
providers: {
deepseek: {
apiKeyEnv: KEY_ENV,
baseURL: `${server.url}/v1`,
models: [{ id: 'deepseek-preview', contextWindow: 200_000, maxTokens: 8192 }],
},
},
})
const result = await assemble(ctx, { provider: 'deepseek', model: 'deepseek-preview', messages: [] })
expect(result.finish).toEqual({ kind: 'stop' })
// The catalog route keeps its catalog protocol, so the new model reaches
// the same endpoint shape the shipped models use.
expect(server.paths).toEqual(['/v1/chat/completions'])
})
it('fails an unconfigured model id before any provider request', async () => {
const server = await mockServer([])
const ctx = await harness({
providers: {
deepseek: { baseURL: server.url, models: [{ id: 'deepseek-preview', contextWindow: 1, maxTokens: 1 }] },
},
})
const result = await assemble(ctx, { provider: 'deepseek', model: 'not-configured', messages: [] })
expect(result.finish).toMatchObject({ kind: 'error', failure: { code: 'UNKNOWN_MODEL' } })
expect(server.requests).toHaveLength(0)
})
it('preserves catalog-only model metadata the profile cannot express', () => {
// Some catalog models carry provider-required request headers; overriding a
// capacity must not drop them, because configuration has no way to restate
// them.
const headered = (getBuiltinModels('nvidia') as { id: string; headers?: unknown }[])
.find(model => model.headers !== undefined)
if (headered === undefined) throw new Error('the installed catalog ships no nvidia model with headers')
const resolved = resolveProfiles({
nvidia: { models: [{ id: headered.id, contextWindow: 4096 }] },
})
const [model] = resolved.get('nvidia')?.piProvider.getModels() ?? []
expect(model?.headers).toEqual(headered.headers)
expect(model?.contextWindow).toBe(4096)
})
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
it('delegates both stream methods back to the reused catalog provider', async () => {
const server = await mockServer([{ events: textEvents }, { events: textEvents }])
const resolved = resolveProfiles({ deepseek: { 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'])
})
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: {
apiKeyEnv: KEY_ENV,
api: 'openai-completions',
baseURL: `${server.url}/v1`,
models: [{ id: 'gpt-4.1', contextWindow: 100_000, maxTokens: 4096 }],
},
},
})
await assemble(ctx, { provider: 'openai', model: 'gpt-4.1', messages: [] })
expect(server.paths).toEqual(['/v1/chat/completions'])
})
fix(llm): let a catalog route keep the auth its provider actually declares pi-ai resolves a request's apiKey override only through a provider that declares an api-key method: resolveProviderAuth short-circuits to that method when the override is present, and otherwise falls through to the credential store and then to ambient discovery. A provider with no api-key method at all therefore resolves to nothing, and the request fails with "Provider is not configured" before any network I/O. Two routes hit that. openai-codex ships OAuth alone, so moving off the /compat dispatch broke a profile that names a key for it — the old path handed the token straight to the provider. And a catalog route naming an api was being rebuilt with the harness's own auth, so `openai: {api: openai-completions}` stopped reading OPENAI_API_KEY, contradicting the documented promise that omitting a credential keeps provider-native discovery. Auth is now one decision for both constructions. A catalog route keeps its installed provider's auth, through an api override too: which environment a provider reads belongs to the provider, not to the wire format its models speak. A catalog provider with no api-key method gets the harness method beside its own, but only when the profile names a credential — a keyless codex profile keeps the honest refusal, since this adapter holds no OAuth store to resolve through. Materialization now spreads the installed entry instead of enumerating the result, so a Model field this package does not model survives a pi-ai upgrade; headers went missing from an nvidia route exactly that way once already. providerInfo reports the configured displayName, which also joins the registration facts so a rename re-registers rather than leaving the old label in every selector. A refused registration swap gets its own diagnostic naming the route, matching the directory swap beside it. The README documented endpoint interrogation this layer does not implement, and still described unknown providers as kept-last-good after they became legal declarations refused at the write point. The Agent Note claimed per-model reasoning configurability the schema never had, required capacities the route now defaults, and stated an apiKey override that short-circuits unconditionally.
2026-08-04 16:01:20 +08:00
it('keeps the catalog provider’s own auth when the route repoints its protocol', () => {
// Which environment a provider reads is a property of the provider, not of
// the wire format its models speak: naming an api must not cost a profile
// its provider-native discovery.
const resolved = resolveProfiles({ openai: { api: 'openai-completions' } })
expect(resolved.get('openai')?.piProvider.auth.apiKey?.name).toBe('OpenAI API key')
})
it('lets an OAuth-only catalog route authenticate with the key its profile names', async () => {
// pi-ai honours a request's `apiKey` override only when the provider
// declares an api-key method. `openai-codex` ships OAuth alone, so without
// the harness method beside it the route refuses its own configured key as
// `Provider is not configured` before any request goes out.
const resolved = resolveProfiles({ 'openai-codex': { 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()
})
})
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('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'])
})
it('keeps a declared off value in the map for dispatch to send', () => {
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/)
})
})
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/)
})
})
describe('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, naming what it offers', () => {
expect(() => resolveProfiles({
anthropic: {
models: [{ id: 'claude-sonnet-4-5', compat: { thinkingFormat: 'openai' } }],
},
})).toThrow(/its api is "anthropic-messages", which does not take it.*exists on openai-completions/s)
})
it('rejects route switches no model on the route can take', () => {
expect(() => resolveProfiles({
anthropic: { compat: { thinkingFormat: 'openai' } },
})).toThrow(/no model on the route speaks a protocol that takes it/)
})
it('carries the developer-role switch onto a hand-declared reasoning model', () => {
// pi-ai reads this switch only for a reasoning model, and detects it from
// the endpoint URL — which for a private gateway answers as though it were
// OpenAI itself, so the route must be able to say otherwise.
const models = modelsOf({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
compat: { supportsDeveloperRole: false, maxTokensField: 'max_tokens' },
models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }],
},
}, 'acme-gateway')
expect(models.get('acme-think')?.compat).toEqual({
supportsDeveloperRole: false,
maxTokensField: 'max_tokens',
})
})
it('carries a switch both OpenAI protocols declare onto an openai-responses route', () => {
const models = modelsOf({
'acme-responses': {
api: 'openai-responses',
baseURL: 'https://acme.test',
compat: { supportsDeveloperRole: false },
models: [{ id: 'acme-r', reasoningEfforts: { off: null, high: 'high' } }],
},
}, 'acme-responses')
expect(models.get('acme-r')?.compat).toEqual({ supportsDeveloperRole: false })
})
it('carries an anthropic-only switch onto an anthropic-messages route', () => {
const models = modelsOf({
'acme-claude': {
api: 'anthropic-messages',
baseURL: 'https://acme.test',
compat: { supportsTemperature: false, supportsCacheControlOnTools: false },
models: [{ id: 'acme-opus' }],
},
}, 'acme-claude')
expect(models.get('acme-opus')?.compat).toEqual({
supportsTemperature: false,
supportsCacheControlOnTools: false,
})
})
it('lands each route switch only on the models whose protocol declares it', () => {
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: {
// Both protocols take the first switch; only completions takes the second.
compat: { supportsDeveloperRole: false, thinkingFormat: 'openai' },
models: [{ id: completions.id }, { id: responses.id }],
},
}, 'xai')
const onCompletions = models.get(completions.id)?.compat as OpenAICompletionsCompat
expect(onCompletions.supportsDeveloperRole).toBe(false)
expect(onCompletions.thinkingFormat).toBe('openai')
const onResponses = models.get(responses.id)?.compat as { supportsDeveloperRole?: boolean; thinkingFormat?: string }
expect(onResponses.supportsDeveloperRole).toBe(false)
expect(onResponses.thinkingFormat).toBeUndefined()
})
it('carries chat-template kwargs beside the thinking format that dispatches through them', () => {
const models = modelsOf({
'acme-qwen': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{
id: 'qwen-local',
reasoningEfforts: { off: null, medium: 'medium' },
compat: {
thinkingFormat: 'qwen-chat-template',
chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } },
},
}],
},
}, 'acme-qwen')
expect(models.get('qwen-local')?.compat).toEqual({
thinkingFormat: 'qwen-chat-template',
chatTemplateKwargs: { enable_thinking: { $var: 'thinking.enabled' } },
})
})
it('rejects a model switch on an unrecognized protocol as having no configurable compat', () => {
expect(() => resolveProfiles({
'acme-gateway': {
api: 'acme-chat',
baseURL: 'https://acme.test',
models: [{ id: 'acme-a', compat: { supportsStore: false } }],
},
})).toThrow(/its api is "acme-chat", which does not take it.*"acme-chat" offers no configurable compat/s)
})
it('refuses a valueless compat key written through the composed settings path', async () => {
// The write path an operator reaches: a section resolved by schemastery,
// judged by this adapter's section validator before it is stored.
// schemastery keeps the null, so nothing but that check stands between it
// and `Model.compat`.
const dir = await home()
const ctx = await bootWithSettings(dir, {})
await expect(ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test/v1',
compat: { supportsDeveloperRole: null },
models: [{ id: 'acme-a' }],
},
},
})).rejects.toThrow(/compat "supportsDeveloperRole" with no value/)
})
it('carries a compat switch from a written settings section onto the wire', async () => {
// End to end for the reported gap: the switch enters as configuration and
// changes the request the provider receives, not merely the resolved model.
vi.stubEnv(KEY_ENV, 'test-key')
const server = await mockServer([{ events: textEvents }])
const dir = await home()
const ctx = await bootWithSettings(dir, {})
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
providers: {
'acme-gateway': {
apiKeyEnv: KEY_ENV,
api: 'openai-completions',
baseURL: `${server.url}/v1`,
compat: { supportsDeveloperRole: false },
models: [{ id: 'acme-think', reasoningEfforts: { off: null, high: 'high' } }],
},
},
})
await assemble(ctx, {
provider: 'acme-gateway',
model: 'acme-think',
reasoningEffort: ReasoningEffortId('high'),
system: 'you are a harness',
messages: [],
})
const request = server.requests[0] as { messages: { role: string }[] }
expect(request.messages.map(message => message.role)).toEqual(['system'])
})
fix(llm-pi-ai): refuse valueless compat and group gates by compat type Review found two live defects in the compat surface. A valueless key (`supportsDeveloperRole:`) survives schemastery as null, and resolution carried it forward as a configured value. It landed on `Model.compat` as null, which replaced the installed catalog entry's value and left pi-ai's `??` reaching for its baseURL detection — the catalog layer skipped entirely, and the switch written but not applied. The vocabulary check now refuses it where it is written, matching the `reasoningEfforts` precedent in the same file. The gates were keyed by protocol name, but pi-ai keys compat by type: `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` share one `OpenAIResponsesCompat`, so two shipped catalog routes were refused the fields their own models declare. Gates now group by compat type, `bedrock-converse-stream` gains its own, and the protocol set is derived from `Model.compat`'s conditional so a release that gives a further protocol a compat type fails the gate list by name. Field types are derived from upstream rather than restated, with a proof pinning the profile assignable to the upstream types, so a widened value union cannot silently narrow what configuration accepts. The `undefined` filter stays removed: `exactOptionalPropertyTypes` keeps a typed caller from writing one, and schemastery never materializes one, so it was validation for a value the static interface already excludes. Refs #2646
2026-08-19 11:48:01 +08:00
it('refuses a valueless compat key rather than writing null over the catalog', () => {
// schemastery passes a YAML bare key through as null. Carried forward it
// would replace the installed entry's value, and pi-ai's `??` would then
// reach for its baseURL detection — the "written but not applied" outcome.
expect(() => resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
compat: { supportsDeveloperRole: null } as never,
models: [{ id: 'acme-a' }],
},
})).toThrow(/compat "supportsDeveloperRole" with no value/)
})
it('refuses a compat key whose value is undefined, as a cordis.yml entry can write', () => {
// `!!js undefined` reaches the same state as a YAML bare key, and
// schemastery keeps the key either way, so both are refused together.
expect(() => resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
compat: { supportsDeveloperRole: undefined } as never,
models: [{ id: 'acme-a' }],
},
})).toThrow(/compat "supportsDeveloperRole" with no value/)
})
fix(llm-pi-ai): refuse valueless compat and group gates by compat type Review found two live defects in the compat surface. A valueless key (`supportsDeveloperRole:`) survives schemastery as null, and resolution carried it forward as a configured value. It landed on `Model.compat` as null, which replaced the installed catalog entry's value and left pi-ai's `??` reaching for its baseURL detection — the catalog layer skipped entirely, and the switch written but not applied. The vocabulary check now refuses it where it is written, matching the `reasoningEfforts` precedent in the same file. The gates were keyed by protocol name, but pi-ai keys compat by type: `openai-responses`, `azure-openai-responses`, and `openai-codex-responses` share one `OpenAIResponsesCompat`, so two shipped catalog routes were refused the fields their own models declare. Gates now group by compat type, `bedrock-converse-stream` gains its own, and the protocol set is derived from `Model.compat`'s conditional so a release that gives a further protocol a compat type fails the gate list by name. Field types are derived from upstream rather than restated, with a proof pinning the profile assignable to the upstream types, so a widened value union cannot silently narrow what configuration accepts. The `undefined` filter stays removed: `exactOptionalPropertyTypes` keeps a typed caller from writing one, and schemastery never materializes one, so it was validation for a value the static interface already excludes. Refs #2646
2026-08-19 11:48:01 +08:00
it('refuses a valueless compat key on a model entry too', () => {
expect(() => resolveProfiles({
deepseek: {
modelOverrides: { 'deepseek-v4-flash': { compat: { requiresReasoningContentOnAssistantMessages: null } } as never },
},
})).toThrow(/model "deepseek-v4-flash" sets compat "requiresReasoningContentOnAssistantMessages" with no value/)
})
it('serves the Responses compat type on every protocol pi-ai gives it to', () => {
// pi-ai types azure-openai-responses and openai-codex-responses with the
// same OpenAIResponsesCompat, so a switch settable on one is settable on all.
for (const route of ['azure-openai-responses', 'openai-codex']) {
const models = modelsOf({ [route]: { compat: { supportsDeveloperRole: false } } }, route)
const [first] = [...models.values()]
expect((first?.compat as { supportsDeveloperRole?: boolean }).supportsDeveloperRole).toBe(false)
}
})
it('serves the Bedrock compat type on its own protocol', () => {
const models = modelsOf({ 'amazon-bedrock': { compat: { supportsStrictMode: false } } }, 'amazon-bedrock')
const [first] = [...models.values()]
expect((first?.compat as { supportsStrictMode?: boolean }).supportsStrictMode).toBe(false)
})
it('refuses a compat key no wire protocol declares instead of dropping it', () => {
// Schemastery passes unknown keys through, so silently dropping one would
// make an unreadable switch look applied; the resolver must refuse it.
expect(() => resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
compat: { supportsDevelperRole: false } as never,
models: [{ id: 'acme-a' }],
},
})).toThrow(/compat "supportsDevelperRole", which no wire protocol declares; the configurable switches are .*\bsupportsDeveloperRole\b/)
})
it('refuses a compat key pi-ai’s catalog owns, pointing at the catalog route', () => {
expect(() => resolveProfiles({
'acme-gateway': {
api: 'openai-completions',
baseURL: 'https://acme.test',
models: [{ id: 'acme-a', compat: { openRouterRouting: {} } as never }],
},
})).toThrow(/compat "openRouterRouting", which is not configurable here/)
})
})
fix(llm): capture an immutable snapshot per pi-ai operation Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
2026-08-04 11:42:35 +08:00
describe('resolution snapshots', () => {
it('finishes an in-flight request under the configuration it started with', async () => {
const server = await mockServer([{ events: textEvents }])
let current = resolveProfiles({ deepseek: { 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' },
feat(llm-pi-ai): sign in to a provider instead of withholding it pi-ai's auth model reaches this adapter through three translations, all of which live here: a CredentialStore over the harness credential records, an AuthContext over the credential plane and the host filesystem, and one authorization flow per installed provider that ships a login. The seams they consume name nothing from pi-ai, so a second adapter family can arrive with a different auth model and share them. Every collection is now built with the store and the context rather than with nothing, which is what makes a signed-in provider stay signed in across the collection rebuild a configuration change causes. With a posture that works, the configurable-provider directory no longer withholds OAuth-only routes and `openai-codex` is offered again; the predicate that withheld it is gone. The credential plane stays optional. Reads answer "nothing stored" without a credentials service because such a composition genuinely holds no credential, while writes refuse by name — a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam, so a headless or ACP composition mounts with no sign-in and everything else unchanged. Two fixes found while wiring this up: pre-release credential fixtures in the llm suites still used the flat document the record work replaced, and a flow that ignores its cancellation signal would have held its key for the life of the process — withdrawal now settles the attempt either way.
2026-08-13 15:54:47 +08:00
auth: memoryAuth(),
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 chunks: StreamChunk[] = []
const inFlight = (async () => {
for await (const chunk of adapter.stream({
provider: 'deepseek',
model: 'deepseek-v4-flash',
messages: [],
})) chunks.push(chunk)
})()
// The route set changes while the request waits, and something else reads
// the adapter meanwhile, which is what would rebuild a shared collection.
current = resolveProfiles({ openai: { 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 }])
let current = resolveProfiles({ deepseek: { baseURL: `${first.url}/v1` } })
feat(llm-pi-ai): sign in to a provider instead of withholding it pi-ai's auth model reaches this adapter through three translations, all of which live here: a CredentialStore over the harness credential records, an AuthContext over the credential plane and the host filesystem, and one authorization flow per installed provider that ships a login. The seams they consume name nothing from pi-ai, so a second adapter family can arrive with a different auth model and share them. Every collection is now built with the store and the context rather than with nothing, which is what makes a signed-in provider stay signed in across the collection rebuild a configuration change causes. With a posture that works, the configurable-provider directory no longer withholds OAuth-only routes and `openai-codex` is offered again; the predicate that withheld it is gone. The credential plane stays optional. Reads answer "nothing stored" without a credentials service because such a composition genuinely holds no credential, while writes refuse by name — a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam, so a headless or ACP composition mounts with no sign-in and everything else unchanged. Two fixes found while wiring this up: pre-release credential fixtures in the llm suites still used the flat document the record work replaced, and a flow that ignores its cancellation signal would have held its key for the life of the process — withdrawal now settles the attempt either way.
2026-08-13 15:54:47 +08:00
const adapter = new PiAiAdapter({
profiles: () => current,
resolveApiKey: () => Promise.resolve('k'),
auth: memoryAuth(),
})
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 drain = async (): Promise<void> => {
for await (const _chunk of adapter.stream({
provider: 'deepseek', model: 'deepseek-v4-flash', messages: [],
})) { /* drain */ }
}
await drain()
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, {})
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)
})
feat(llm-pi-ai): sign in to a provider instead of withholding it pi-ai's auth model reaches this adapter through three translations, all of which live here: a CredentialStore over the harness credential records, an AuthContext over the credential plane and the host filesystem, and one authorization flow per installed provider that ships a login. The seams they consume name nothing from pi-ai, so a second adapter family can arrive with a different auth model and share them. Every collection is now built with the store and the context rather than with nothing, which is what makes a signed-in provider stay signed in across the collection rebuild a configuration change causes. With a posture that works, the configurable-provider directory no longer withholds OAuth-only routes and `openai-codex` is offered again; the predicate that withheld it is gone. The credential plane stays optional. Reads answer "nothing stored" without a credentials service because such a composition genuinely holds no credential, while writes refuse by name — a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam, so a headless or ACP composition mounts with no sign-in and everything else unchanged. Two fixes found while wiring this up: pre-release credential fixtures in the llm suites still used the flat document the record work replaced, and a flow that ignores its cancellation signal would have held its key for the life of the process — withdrawal now settles the attempt either way.
2026-08-13 15:54:47 +08:00
it('offers every installed catalog route, including one that only signs in', async () => {
const ctx = await harness({})
const offered = ctx.llm.listConfigurableProviders().map(entry => entry.provider)
// `openai-codex` is the one installed provider that authenticates through
feat(llm-pi-ai): sign in to a provider instead of withholding it pi-ai's auth model reaches this adapter through three translations, all of which live here: a CredentialStore over the harness credential records, an AuthContext over the credential plane and the host filesystem, and one authorization flow per installed provider that ships a login. The seams they consume name nothing from pi-ai, so a second adapter family can arrive with a different auth model and share them. Every collection is now built with the store and the context rather than with nothing, which is what makes a signed-in provider stay signed in across the collection rebuild a configuration change causes. With a posture that works, the configurable-provider directory no longer withholds OAuth-only routes and `openai-codex` is offered again; the predicate that withheld it is gone. The credential plane stays optional. Reads answer "nothing stored" without a credentials service because such a composition genuinely holds no credential, while writes refuse by name — a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam, so a headless or ACP composition mounts with no sign-in and everything else unchanged. Two fixes found while wiring this up: pre-release credential fixtures in the llm suites still used the flat document the record work replaced, and a flow that ignores its cancellation signal would have held its key for the life of the process — withdrawal now settles the attempt either way.
2026-08-13 15:54:47 +08:00
// OAuth alone. It is offered like any other because the collection now
// carries a durable credential store and a login flow writes into it, so
// the route has a posture that works rather than only one that fails.
expect(offered).toContain('openai-codex')
expect(offered).toContain('anthropic')
expect(offered).toContain('openai')
})
feat(llm-pi-ai): sign in to a provider instead of withholding it pi-ai's auth model reaches this adapter through three translations, all of which live here: a CredentialStore over the harness credential records, an AuthContext over the credential plane and the host filesystem, and one authorization flow per installed provider that ships a login. The seams they consume name nothing from pi-ai, so a second adapter family can arrive with a different auth model and share them. Every collection is now built with the store and the context rather than with nothing, which is what makes a signed-in provider stay signed in across the collection rebuild a configuration change causes. With a posture that works, the configurable-provider directory no longer withholds OAuth-only routes and `openai-codex` is offered again; the predicate that withheld it is gone. The credential plane stays optional. Reads answer "nothing stored" without a credentials service because such a composition genuinely holds no credential, while writes refuse by name — a login whose grant evaporated would report success and then fail every request. Flow registration is scoped to the authorization seam, so a headless or ACP composition mounts with no sign-in and everything else unchanged. Two fixes found while wiring this up: pre-release credential fixtures in the llm suites still used the flat document the record work replaced, and a flow that ignores its cancellation signal would have held its key for the life of the process — withdrawal now settles the attempt either way.
2026-08-13 15:54:47 +08:00
it('lists a route a stored profile names as a catalog route, not a declared one', async () => {
// `declared` answers catalog membership, so a profile stored against a
// route pi-ai ships is not mislabelled as one this deployment invented.
const ctx = await harness({ providers: { 'openai-codex': { apiKeyEnv: KEY_ENV } } })
expect(ctx.llm.listConfigurableProviders()).toContainEqual({
provider: 'openai-codex',
displayName: 'openai-codex',
settingsNs: 'llm-pi-ai',
settingsPath: ['providers', 'openai-codex'],
declared: 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
})