fix(llm): answer a catalog route's models from pi-ai's own registry

Clicking "fetch available models" on a built-in provider went to the
network. That is the wrong source: pi-ai's registry is the authoritative
list for its own providers, and it carries the context windows and output
caps a `GET /models` listing does not disclose. Asking api.deepseek.com
what DeepSeek serves is both slower and worse, and against an endpoint
that answers a different shape it failed outright.

Interrogation is still keyed by settings namespace — the provider being
added has no route — but the request may now name the route it is
editing. An adapter that already describes that route answers from what
it knows, needs no endpoint at all, and never touches the network; only a
route the catalog does not describe reaches the wire, and one naming no
endpoint is told to set one or enter its models by hand.

`ConfigurableProviderView` gained `supportsDiscovery` so a surface offers
the action where a namespace can answer instead of hardcoding an adapter
family.

Three narrower corrections ride along. Discovery no longer claims Azure
or Codex: Azure authenticates with an `api-key` header and an
`api-version` query despite its OpenAI lineage, and Codex uses OAuth, so
both reported an authentication failure as a provider with no models.
Cancellation during the body read escaped as the raw abort reason rather
than a coded ABORTED. And the schema comment claiming the probe key is
never logged overstated it: the host neither stores nor returns it, but
it rides the client's outgoing envelope like every other secret-bearing
payload, and redacting that tap is a configuration-plane-wide change.
This commit is contained in:
Yichen Jiang
2026-08-04 12:30:41 +08:00
parent ecee93ec26
commit ffd2f188f2
25 changed files with 217 additions and 64 deletions

View File

@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from 'vitest'
import { Context } from 'cordis'
import LlmService, { userAgent } from '@deepseek-ai/dsh-llm'
import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
import { discoverModels } from '../src/discovery.ts'
const servers: Server[] = []
@@ -25,6 +27,7 @@ async function listingServer(behavior: {
status?: number
body?: string
chunks?: string[]
holdOpenMs?: number
}): Promise<ListingServer> {
const paths: string[] = []
const headers: IncomingMessage['headers'][] = []
@@ -35,7 +38,10 @@ async function listingServer(behavior: {
// No declared length: the ceiling has to hold on what is read.
response.writeHead(behavior.status ?? 200, { 'content-type': 'application/json' })
for (const chunk of behavior.chunks) response.write(chunk)
response.end()
if (behavior.holdOpenMs === undefined) { response.end(); return }
// Left open so a caller's cancellation lands while the body is still
// being read rather than after it completed.
setTimeout(() => { response.end() }, behavior.holdOpenMs)
return
}
const body = behavior.body ?? '{}'
@@ -60,6 +66,39 @@ async function harness(): Promise<Context> {
return ctx
}
describe('catalog-route model discovery', () => {
it('answers from the installed registry, with capacities and no network call', async () => {
const server = await listingServer({ body: JSON.stringify({ data: [{ id: 'from-the-endpoint' }] }) })
const ctx = await harness()
const models = await ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek', baseURL: server.url })
// pi-ai's own registry is the authority for its own providers, and it
// carries what a listing endpoint would not disclose.
expect(models.map(model => model.id).sort())
.toEqual(getBuiltinModels('deepseek').map(model => model.id).sort())
expect(models.every(model => (model.contextWindow ?? 0) > 0 && (model.maxTokens ?? 0) > 0)).toBe(true)
expect(server.paths).toEqual([])
})
it('needs no endpoint for a route the catalog describes', async () => {
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'deepseek' })).resolves.not.toHaveLength(0)
})
it('says where a route the catalog does not describe must get its models', async () => {
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway' }))
.rejects.toThrow(/ships no catalog for provider "acme-gateway".*set a baseURL/s)
// A form that cleared the field says the same thing as one that never had it.
await expect(ctx.llm.discoverModels('llm-pi-ai', { provider: 'acme-gateway', baseURL: '' }))
.rejects.toThrow(/set a baseURL/)
// The seam refuses a request naming neither, so the module's own guard for
// that shape is only reachable by calling it directly.
await expect(discoverModels({})).rejects.toThrow(/set a baseURL/)
})
})
describe('draft-provider model discovery', () => {
it('reads an OpenAI-compatible listing and keeps the capacities it discloses', async () => {
const server = await listingServer({
@@ -171,12 +210,27 @@ describe('draft-provider model discovery', () => {
.rejects.toMatchObject({ code: 'DISCOVERY_FAILED' })
})
it('says which protocols it cannot interrogate rather than guessing a shape', async () => {
it.each(['anthropic-messages', 'azure-openai-responses', 'openai-codex-responses', 'google-generative-ai'])(
'says it cannot interrogate %s rather than guessing a shape',
async (api) => {
// Azure authenticates with an `api-key` header and an `api-version`
// query despite its OpenAI lineage, and Codex uses OAuth; guessing at
// either would report an auth failure as a provider with no models.
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', { baseURL: 'https://gateway.example/v1', api }))
.rejects.toMatchObject({ code: 'DISCOVERY_UNSUPPORTED' })
},
)
it('reports cancellation during the body read as an abort, not a raw reason', async () => {
const ctx = await harness()
await expect(ctx.llm.discoverModels('llm-pi-ai', {
baseURL: 'https://gateway.example/v1',
api: 'anthropic-messages',
})).rejects.toMatchObject({ code: 'DISCOVERY_UNSUPPORTED' })
const controller = new AbortController()
// Chunked, so the headers arrive and the cancellation lands mid-body.
const slow = await listingServer({ chunks: ['{"data":[', '{"id":"a"}'], holdOpenMs: 400 })
const probe = ctx.llm.discoverModels('llm-pi-ai', { baseURL: slow.url, signal: controller.signal })
setTimeout(() => { controller.abort('test cancellation') }, 40)
await expect(probe).rejects.toMatchObject({ code: 'ABORTED' })
})
it('honors caller cancellation', async () => {