Parcourir la source

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.
Yichen Jiang il y a 1 mois
Parent
commit
ffd2f188f2

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
 #   pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md
-2026-08-04-draft-provider-endpoint-interrogation.md: 86b3148626fea90f1d87b80084f7cd4bafeeb1f8
+2026-08-04-draft-provider-endpoint-interrogation.md: a09b971022986442b48bd7aa04a1dcabfa66eb8b
 2026-08-04-draft-provider-endpoint-interrogation.zh.md: 0f6a63385dc628938c702aca1895b608e4eeaf9a
 2026-08-04-draft-provider-endpoint-interrogation.zh.md: 0f6a63385dc628938c702aca1895b608e4eeaf9a

+ 1 - 1
.agents/notes/implemented/architecture/2026-08-04-draft-provider-endpoint-interrogation.md

@@ -21,7 +21,7 @@ Interrogation is keyed by **settings namespace**, not by provider route:
 - `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires.
 - `LlmDiscoveredModel` makes every field but `id` optional, because most listings disclose an id and nothing else. The reply is candidates, not a catalog: a surface adopting one still owes the capacities the adapter requires.
 - `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored, logged, or echoed. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered.
 - `llm.discoverModels` carries the same draft over the wire. Its `apiKey` is the third and last payload on which a secret may ride, alongside `settings.update`/`mutate` and `credentials.set`, and it is never stored, logged, or echoed. Every refusal folds into `model-discovery-failed`, whose message is the adapter's own text and whose details name the endpoint asked but never the credential offered.
 
 
-`dsh-llm-pi-ai` implements it as a plain `GET {baseURL}/models` for OpenAI-compatible protocols only. Their listing shape is the one a gateway, a self-hosted server, and the official endpoints all agree on, which is the case this action exists for. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs.
+`dsh-llm-pi-ai` implements the wire path as a plain `GET {baseURL}/models`, reading `openai-completions` and `openai-responses`: their `GET /models` shape with bearer auth is the one a gateway, a self-hosted server, and the official endpoints all agree on. Azure is excluded despite its OpenAI lineage — it authenticates with an `api-key` header and requires an `api-version` query — and Codex uses OAuth; both would have reported an authentication failure as a provider with no models. Every other protocol answers `DISCOVERY_UNSUPPORTED`, so the surface falls back to hand-entry rather than reporting a guessed response shape as an empty provider. `baseURL` is treated as a prefix rather than a URL to resolve against, so a deployment path such as `https://gateway.example/openai/v1` keeps its segments. The reply is read under a four-megabyte ceiling enforced on the bytes actually received — the endpoint is a URL the user typed, so a declared `content-length` is checked first as a courtesy but never trusted as the bound, matching `dsh-web-fetch`'s two-stage shape for its own caller-supplied URLs.
 
 
 ### Why not pi-ai's own refresh machinery
 ### Why not pi-ai's own refresh machinery
 
 

+ 4 - 4
docs/cordis-catalog/services.md

@@ -834,9 +834,9 @@ listProviders(): LlmProviderInfo[]
  * entry, or a provider already declared by any registration throws
  * entry, or a provider already declared by any registration throws
  * `LlmError` without registering the rest. Disposed with the fiber.
  * `LlmError` without registering the rest. Disposed with the fiber.
  * @param entries - every configurable provider this plugin owns.
  * @param entries - every configurable provider this plugin owns.
- * @returns the disposer that withdraws all of them.
+ * @returns a handle that withdraws all of them, and can atomically replace them.
  */
  */
-registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): () => void
+registerConfigurableProviders(entries: readonly LlmConfigurableProvider[]): DirectoryRegistrationHandle
 
 
 /**
 /**
  * List every declared configurable provider, registered or dormant.
  * List every declared configurable provider, registered or dormant.
@@ -938,9 +938,9 @@ async prepareCall(config: LlmCallConfig, signal?: AbortSignal): Promise<Prepared
 stream(options: GenerateOptions): AsyncIterable<StreamChunk>
 stream(options: GenerateOptions): AsyncIterable<StreamChunk>
 ```
 ```
 
 
-Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
+Types: [AdapterRegistrationHandle](../core-data-structures/core.md) · [DirectoryRegistrationHandle](../core-data-structures/core.md) · [GenerateOptions](../core-data-structures/core.md) · [LlmAdapter](../core-data-structures/llm-streaming.md) · [LlmCallConfig](../core-data-structures/core.md) · [LlmConfigurableProvider](../core-data-structures/core.md) · [LlmDiscoveredModel](../core-data-structures/core.md) · [LlmModelDiscoveryRequest](../core-data-structures/core.md) · [LlmModelInfo](../core-data-structures/core.md) · [LlmProviderInfo](../core-data-structures/core.md) · [LlmResolvedModelInfo](../core-data-structures/core.md) · [PreparedLlmCall](../core-data-structures/llm-streaming.md) · [ResolvedRetryPolicy](../core-data-structures/llm-streaming.md) · [StreamChunk](../core-data-structures/llm-streaming.md)
 
 
-Source: [`packages/llm/llm/src/index.ts:234`](../../packages/llm/llm/src/index.ts)
+Source: [`packages/llm/llm/src/index.ts:255`](../../packages/llm/llm/src/index.ts)
 
 
 ## `ctx.permission` — `PermissionService`
 ## `ctx.permission` — `PermissionService`
 
 

+ 3 - 3
packages/client/connection/src/client/fixture.ts

@@ -2438,9 +2438,9 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
     llm: {
     llm: {
       providers: request => ok(request, {
       providers: request => ok(request, {
         providers: [
         providers: [
-          { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
-          { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
-          { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
+          { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false },
+          { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true, supportsDiscovery: true },
+          { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false, supportsDiscovery: true },
         ],
         ],
       }),
       }),
       models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),
       models: request => ok(request, { groups: fixtureModelGroups(), failures: [] }),

+ 2 - 2
packages/client/ui-models/tests/components.spec.tsx

@@ -145,7 +145,7 @@ function scriptedFace(overrides: {
     llm: {
     llm: {
       providers: vi.fn(() => Promise.resolve(ok({
       providers: vi.fn(() => Promise.resolve(ok({
         providers: [
         providers: [
-          { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
+          { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false },
           { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
           { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: true },
           { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
           { provider: 'anthropic', displayName: 'anthropic', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'anthropic'], active: false },
           { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
           { provider: 'zombie', displayName: 'zombie', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'zombie'], active: false },
@@ -230,7 +230,7 @@ describe('ModelsSection', () => {
   })
   })
 
 
   it('decides setup need from the joined credential state and literal-key sidecar', () => {
   it('decides setup need from the joined credential state and literal-key sidecar', () => {
-    const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true }
+    const entry = { provider: 'p', displayName: 'p', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false }
     const row = (
     const row = (
       credential: ProviderRow['credential'],
       credential: ProviderRow['credential'],
       literalApiKeyConfigured = false,
       literalApiKeyConfigured = false,

+ 1 - 1
packages/client/ui-models/tests/readiness.spec.ts

@@ -13,7 +13,7 @@ function row(overrides: Partial<ProviderRow> = {}): ProviderRow {
       displayName: 'DeepSeek',
       displayName: 'DeepSeek',
       settingsNs: 'llm-deepseek',
       settingsNs: 'llm-deepseek',
       settingsPath: [],
       settingsPath: [],
-      active: true,
+      active: true, supportsDiscovery: false,
     },
     },
     configured: true,
     configured: true,
     removable: false,
     removable: false,

+ 1 - 1
packages/cordis/tool-cordis/src/api-catalog.ts

@@ -2123,7 +2123,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [
   },
   },
   {
   {
     name: 'LlmModelDiscoveryRequest',
     name: 'LlmModelDiscoveryRequest',
-    declaration: 'export interface LlmModelDiscoveryRequest {\n    baseURL: string;\n    api?: string;\n    apiKey?: string;\n    signal?: AbortSignal;\n}',
+    declaration: 'export interface LlmModelDiscoveryRequest {\n    provider?: string;\n    baseURL?: string;\n    api?: string;\n    apiKey?: string;\n    signal?: AbortSignal;\n}',
   },
   },
   {
   {
     name: 'LlmModelInfo',
     name: 'LlmModelInfo',

+ 2 - 2
packages/host/apiproxy/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
 #   pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md
-README.md: 633c8fe39d989802e8debc350279137b66979593
-README.zh.md: 5e5102840319900f6607acc17288882ccf3c0075
+README.md: 70d0ff258d5ed55678789ef6c7e6c8e64e822db3
+README.zh.md: 3259c03b3d3ca19658d20040024dc40c5c3f287a

Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
packages/host/apiproxy/README.md


Fichier diff supprimé car celui-ci est trop grand
+ 0 - 0
packages/host/apiproxy/README.zh.md


+ 7 - 3
packages/host/apiproxy/src/api-proxy.ts

@@ -2563,12 +2563,14 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
         const active = new Set(registered.map(provider => provider.id))
         const active = new Set(registered.map(provider => provider.id))
         const directory = ctx.llm.listConfigurableProviders()
         const directory = ctx.llm.listConfigurableProviders()
         const declared = new Set(directory.map(entry => entry.provider))
         const declared = new Set(directory.map(entry => entry.provider))
+        const discoverable = new Set(ctx.llm.listModelDiscoveryNamespaces())
         const views = directory.map(entry => ({
         const views = directory.map(entry => ({
           provider: entry.provider,
           provider: entry.provider,
           displayName: entry.displayName,
           displayName: entry.displayName,
           settingsNs: entry.settingsNs,
           settingsNs: entry.settingsNs,
           settingsPath: [...entry.settingsPath],
           settingsPath: [...entry.settingsPath],
           active: active.has(entry.provider),
           active: active.has(entry.provider),
+          supportsDiscovery: discoverable.has(entry.settingsNs),
         }))
         }))
         // Routes registered without a directory declaration still appear —
         // Routes registered without a directory declaration still appear —
         // they exist and serve models — just with no settings address.
         // they exist and serve models — just with no settings address.
@@ -2580,6 +2582,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
             settingsNs: '',
             settingsNs: '',
             settingsPath: [],
             settingsPath: [],
             active: true,
             active: true,
+            supportsDiscovery: false,
           })
           })
         }
         }
         return Promise.resolve(ok(request, { providers: views }))
         return Promise.resolve(ok(request, { providers: views }))
@@ -2590,10 +2593,11 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
       },
       },
 
 
       async discoverModels(request, signal) {
       async discoverModels(request, signal) {
-        const { settingsNs, baseURL, api, apiKey } = request.payload
+        const { settingsNs, provider, baseURL, api, apiKey } = request.payload
         try {
         try {
           const models = await ctx.llm.discoverModels(settingsNs, {
           const models = await ctx.llm.discoverModels(settingsNs, {
-            baseURL,
+            ...provider === undefined ? {} : { provider },
+            ...baseURL === undefined ? {} : { baseURL },
             ...api === undefined ? {} : { api },
             ...api === undefined ? {} : { api },
             ...apiKey === undefined ? {} : { apiKey },
             ...apiKey === undefined ? {} : { apiKey },
             ...signal === undefined ? {} : { signal },
             ...signal === undefined ? {} : { signal },
@@ -2607,7 +2611,7 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
           return err(request, {
           return err(request, {
             code: 'model-discovery-failed',
             code: 'model-discovery-failed',
             message: error instanceof Error ? error.message : String(error),
             message: error instanceof Error ? error.message : String(error),
-            details: { settingsNs, baseURL },
+            details: { settingsNs, ...baseURL === undefined ? {} : { baseURL } },
           })
           })
         }
         }
       },
       },

+ 8 - 4
packages/host/apiproxy/src/api/llm.schema.ts

@@ -16,6 +16,7 @@ export const configurableProviderViewSchema = z.object({
   settingsNs: z.string(),
   settingsNs: z.string(),
   settingsPath: z.array(z.string()),
   settingsPath: z.array(z.string()),
   active: z.boolean(),
   active: z.boolean(),
+  supportsDiscovery: z.boolean(),
 }) satisfies z.ZodType<Wire<ConfigurableProviderView>>
 }) satisfies z.ZodType<Wire<ConfigurableProviderView>>
 
 
 /** llm.providers request payload. */
 /** llm.providers request payload. */
@@ -46,11 +47,14 @@ export const discoveredModelViewSchema = z.object({
 /** llm.discoverModels request payload. */
 /** llm.discoverModels request payload. */
 export const llmDiscoverModelsRequestSchema = z.object({
 export const llmDiscoverModelsRequestSchema = z.object({
   settingsNs: z.string().min(1),
   settingsNs: z.string().min(1),
-  baseURL: z.string().min(1),
+  provider: z.string().min(1).optional(),
+  baseURL: z.string().min(1).optional(),
   api: z.string().min(1).optional(),
   api: z.string().min(1).optional(),
-  // Write-only: the host uses it for this one interrogation and never stores,
-  // logs, or returns it. Kept out of any redacted echo for the same reason
-  // `credentials.set` never reads a value back.
+  // Write-only at the host: used for this one interrogation, never stored and
+  // never returned. It does ride the client's outgoing envelope like every
+  // other secret-bearing payload (`credentials.set`, `settings.update`), which
+  // `subscribeEnvelopes()` observers can see — redacting that tap is a
+  // configuration-plane-wide change, not this method's to make alone.
   apiKey: z.string().min(1).optional(),
   apiKey: z.string().min(1).optional(),
 }) satisfies z.ZodType<Wire<RequestPayload<'llm.discoverModels'>>>
 }) satisfies z.ZodType<Wire<RequestPayload<'llm.discoverModels'>>>
 
 

+ 18 - 7
packages/host/apiproxy/src/api/llm.ts

@@ -22,6 +22,12 @@ export interface ConfigurableProviderView {
   settingsPath: string[]
   settingsPath: string[]
   /** Whether the route is currently registered (its models are requestable). */
   /** Whether the route is currently registered (its models are requestable). */
   active: boolean
   active: boolean
+  /**
+   * Whether `llm.discoverModels` can answer for this entry's namespace. A
+   * surface offers the action only where it works instead of naming an adapter
+   * family it would have to hardcode.
+   */
+  supportsDiscovery: boolean
 }
 }
 
 
 /** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */
 /** Llm-domain unary methods (the map keys llm.* of RpcMethodMap). */
@@ -46,17 +52,22 @@ export interface LlmApi {
    * drafting, and return the models it advertises for the user to adopt.
    * drafting, and return the models it advertises for the user to adopt.
    *
    *
    * The payload is the draft, not a stored route: `settingsNs` selects the
    * The payload is the draft, not a stored route: `settingsNs` selects the
-   * adapter family that knows how to read the listing, and the endpoint,
-   * protocol, and key come from the form. Nothing is written — the reply is
-   * candidates, and only a later `settings.mutate` decides what a route
-   * serves. `apiKey` is therefore accepted here but never stored, logged, or
-   * echoed back; a provider whose key is already stored omits it and the
-   * endpoint answers unauthenticated or refuses.
+   * adapter family that answers, and the rest comes from the form. `provider`
+   * names the route being edited when there is one — an adapter that already
+   * describes that route answers from its own registry, with better metadata
+   * and no network call, and needs no endpoint. A route it does not describe is
+   * asked over the wire, which is what `baseURL`, `api`, and `apiKey` are for.
+   *
+   * Nothing is written — the reply is candidates, and only a later
+   * `settings.mutate` decides what a route serves. `apiKey` is accepted here
+   * but never stored or returned; a provider whose key is already stored omits
+   * it and the endpoint answers unauthenticated or refuses.
    */
    */
   discoverModels(
   discoverModels(
     request: RpcRequest<{
     request: RpcRequest<{
       settingsNs: string
       settingsNs: string
-      baseURL: string
+      provider?: string
+      baseURL?: string
       api?: string
       api?: string
       apiKey?: string
       apiKey?: string
     }>,
     }>,

+ 1 - 1
packages/host/apiproxy/src/api/rpc.schema.ts

@@ -55,7 +55,7 @@ export const rpcErrorSchema: z.ZodType<RpcError> = z.discriminatedUnion('code',
   z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
   z.object({ code: z.literal('settings-not-exposed'), message: z.string(), details: z.object({ ns: z.string() }) }),
   z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
   z.object({ code: z.literal('settings-conflict'), message: z.string(), details: z.object({ ns: z.string(), expected: z.number(), actual: z.number() }) }),
   z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
   z.object({ code: z.literal('credential-rejected'), message: z.string(), details: z.object({ ref: z.string() }) }),
-  z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string() }) }),
+  z.object({ code: z.literal('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: z.string().optional() }) }),
   z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
   z.object({ code: z.literal('title-invalid'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
   z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
   z.object({ code: z.literal('fork-unavailable'), message: z.string(), details: z.object({ sessionId: z.string() }) }),
   z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
   z.object({ code: z.literal('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),

+ 1 - 1
packages/host/apiproxy/src/api/rpc.ts

@@ -78,7 +78,7 @@ export interface RpcErrorDetailsMap {
    * it is what the form shows before falling back to hand-entry — and the
    * it is what the form shows before falling back to hand-entry — and the
    * details name the endpoint asked, never the credential offered.
    * details name the endpoint asked, never the credential offered.
    */
    */
-  'model-discovery-failed': { settingsNs: string; baseURL: string }
+  'model-discovery-failed': { settingsNs: string; baseURL?: string }
   'title-invalid': { sessionId: SessionId }
   'title-invalid': { sessionId: SessionId }
   'fork-unavailable': { sessionId: SessionId }
   'fork-unavailable': { sessionId: SessionId }
   'subagent-parent-unavailable': { parentSessionId: SessionId }
   'subagent-parent-unavailable': { parentSessionId: SessionId }

+ 27 - 3
packages/host/apiproxy/tests/api-proxy-config.spec.ts

@@ -523,12 +523,17 @@ describe('llm domain', () => {
     ])
     ])
     ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash']))
     ctx.llm.registerAdapter(['deepseek-official'], new CatalogAdapter('DeepSeek', ['deepseek-v4-flash']))
     ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1']))
     ctx.llm.registerAdapter(['undeclared'], new CatalogAdapter('Undeclared', ['u-1']))
+    // Only one namespace can answer an interrogation, so the flag follows the
+    // entry's namespace rather than being assumed for every row.
+    ctx.llm.registerModelDiscovery('llm-pi-ai', () => Promise.resolve([]))
     const api = createApiProxy(ctx, DEFAULTS)
     const api = createApiProxy(ctx, DEFAULTS)
     const value = expectOk(await api.llm.providers(request({})))
     const value = expectOk(await api.llm.providers(request({})))
     expect(value.providers).toEqual([
     expect(value.providers).toEqual([
-      { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true },
-      { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false },
-      { provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true },
+      { provider: 'deepseek-official', displayName: 'DeepSeek', settingsNs: 'llm-deepseek', settingsPath: [], active: true, supportsDiscovery: false },
+      { provider: 'openai', displayName: 'openai', settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'], active: false, supportsDiscovery: true },
+      // An undeclared live route has no settings address, so nothing can be
+      // interrogated on its behalf either.
+      { provider: 'undeclared', displayName: 'Undeclared', settingsNs: '', settingsPath: [], active: true, supportsDiscovery: false },
     ])
     ])
   })
   })
 
 
@@ -596,6 +601,25 @@ describe('llm.discoverModels', () => {
       .not.toContain('llm-pi-ai')
       .not.toContain('llm-pi-ai')
   })
   })
 
 
+  it('carries the route being edited so an adapter can answer from its own registry', async () => {
+    const ctx = await harness()
+    let probe: unknown
+    ctx.llm.registerModelDiscovery('llm-pi-ai', (request_) => {
+      probe = request_
+      return Promise.resolve([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }])
+    })
+    const api = createApiProxy(ctx, DEFAULTS)
+
+    const value = expectOk(await api.llm.discoverModels(request({
+      settingsNs: 'llm-pi-ai',
+      provider: 'deepseek',
+    })))
+
+    // No endpoint at all: a route the adapter already describes needs none.
+    expect(probe).toEqual({ provider: 'deepseek' })
+    expect(value.models).toEqual([{ id: 'from-registry', contextWindow: 65_536, maxTokens: 4096 }])
+  })
+
   it('omits a credential and protocol the draft does not name', async () => {
   it('omits a credential and protocol the draft does not name', async () => {
     const ctx = await harness()
     const ctx = await harness()
     let probe: unknown
     let probe: unknown

+ 1 - 0
packages/host/apiproxy/tests/client-handler.spec.ts

@@ -677,6 +677,7 @@ describe('config unary surface', () => {
       settingsNs: 'llm-pi-ai',
       settingsNs: 'llm-pi-ai',
       settingsPath: ['providers', 'openai'],
       settingsPath: ['providers', 'openai'],
       active: false,
       active: false,
+      supportsDiscovery: true,
     }
     }
     const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
     const group = { id: 'deepseek-official', name: 'DeepSeek', models: [{ id: 'deepseek-v4-flash', name: 'Flash' }] }
     const api = scriptedApi({
     const api = scriptedApi({

+ 51 - 14
packages/llm/llm-pi-ai/src/discovery.ts

@@ -1,12 +1,17 @@
 /**
 /**
- * One-shot interrogation of a provider endpoint's model listing, serving the
- * configuration surface's "fetch available models" action.
+ * Answering "which models can this provider serve?" for the configuration
+ * surface's "fetch available models" action.
  *
  *
- * This is deliberately *not* a catalog refresh. Nothing here is stored: the
- * request carries a draft the user is still editing — an endpoint and a
- * credential neither of which may exist in `settings.yaml` yet — and the reply
- * is candidate metadata the surface offers for adoption. `settings.yaml`
- * remains the only thing that decides what a route serves.
+ * A route the installed pi-ai catalog ships is answered **from that catalog**,
+ * with no network call at all: pi-ai's registry is the authoritative list for
+ * its own providers, and it carries the capacities a listing endpoint would
+ * not disclose. Only a route the catalog does not describe — a gateway, a
+ * self-hosted server — is interrogated over the wire.
+ *
+ * Neither path is a catalog refresh. Nothing here is stored: the request
+ * carries a draft the user is still editing, and the reply is candidate
+ * metadata the surface offers for adoption. `settings.yaml` remains the only
+ * thing that decides what a route serves.
  *
  *
  * Only OpenAI-compatible protocols are interrogated. Their listing is the one
  * Only OpenAI-compatible protocols are interrogated. Their listing is the one
  * shape a gateway, a self-hosted server, and the official endpoints all agree
  * shape a gateway, a self-hosted server, and the official endpoints all agree
@@ -20,16 +25,17 @@
 import { LlmError } from '@deepseek-ai/dsh-llm'
 import { LlmError } from '@deepseek-ai/dsh-llm'
 import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm'
 import type { LlmDiscoveredModel, LlmModelDiscoveryRequest } from '@deepseek-ai/dsh-llm'
 import { attributionHeaders } from '@deepseek-ai/dsh-llm'
 import { attributionHeaders } from '@deepseek-ai/dsh-llm'
+import { catalogModels } from './catalog.ts'
 
 
 /**
 /**
- * Protocols whose model listing this module can read. Every entry speaks
- * OpenAI's `GET /models` shape; pi-ai's other protocols are absent because a
- * wrong guess at their response shape would be reported as an empty provider
- * rather than as the gap it is.
+ * Protocols whose model listing this module can read: the two that speak
+ * OpenAI's `GET /models` shape with bearer auth. Azure is absent despite its
+ * OpenAI lineage — it authenticates with an `api-key` header and requires an
+ * `api-version` query — and Codex authenticates through OAuth; guessing at
+ * either would report an authentication failure as a provider with no models.
+ * pi-ai's remaining protocols are absent for the same reason.
  */
  */
 const LISTABLE_PROTOCOLS: ReadonlySet<string> = new Set([
 const LISTABLE_PROTOCOLS: ReadonlySet<string> = new Set([
-  'azure-openai-responses',
-  'openai-codex-responses',
   'openai-completions',
   'openai-completions',
   'openai-responses',
   'openai-responses',
 ])
 ])
@@ -165,6 +171,26 @@ function readListing(body: unknown): LlmDiscoveredModel[] {
 export async function discoverModels(
 export async function discoverModels(
   request: LlmModelDiscoveryRequest,
   request: LlmModelDiscoveryRequest,
 ): Promise<readonly LlmDiscoveredModel[]> {
 ): Promise<readonly LlmDiscoveredModel[]> {
+  // A catalog route already has its answer, and a better one: the installed
+  // entries carry context windows and output caps no listing endpoint reports.
+  if (request.provider !== undefined) {
+    const installed = catalogModels(request.provider)
+    if (installed.size > 0) {
+      return [...installed.values()].map(model => ({
+        id: model.id,
+        name: model.name,
+        contextWindow: model.contextWindow,
+        maxTokens: model.maxTokens,
+      }))
+    }
+  }
+  if (request.baseURL === undefined || request.baseURL.length === 0) {
+    throw new LlmError(
+      `pi-ai ships no catalog for provider "${request.provider ?? ''}", so its models can only come from its`
+      + " endpoint; set a baseURL, or enter this provider's models by hand",
+      'DISCOVERY_FAILED',
+    )
+  }
   const api = request.api ?? 'openai-completions'
   const api = request.api ?? 'openai-completions'
   if (!LISTABLE_PROTOCOLS.has(api)) {
   if (!LISTABLE_PROTOCOLS.has(api)) {
     throw new LlmError(
     throw new LlmError(
@@ -196,7 +222,18 @@ export async function discoverModels(
       'DISCOVERY_FAILED',
       'DISCOVERY_FAILED',
     )
     )
   }
   }
-  const text = await readBounded(response, url)
+  let text: string
+  try {
+    text = await readBounded(response, url)
+  } catch (error: unknown) {
+    // Cancellation during the body read rejects with the abort reason, which
+    // may be any value; the caller gets the same coded failure it would have
+    // for a cancellation before the request went out.
+    if (request.signal?.aborted) {
+      throw new LlmError('model discovery aborted by caller', 'ABORTED', { cause: error })
+    }
+    throw error
+  }
   let body: unknown
   let body: unknown
   try {
   try {
     body = JSON.parse(text)
     body = JSON.parse(text)

+ 60 - 6
packages/llm/llm-pi-ai/tests/discovery.spec.ts

@@ -4,6 +4,8 @@ import { afterEach, describe, expect, it } from 'vitest'
 import { Context } from 'cordis'
 import { Context } from 'cordis'
 import LlmService, { userAgent } from '@deepseek-ai/dsh-llm'
 import LlmService, { userAgent } from '@deepseek-ai/dsh-llm'
 import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai'
 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[] = []
 const servers: Server[] = []
 
 
@@ -25,6 +27,7 @@ async function listingServer(behavior: {
   status?: number
   status?: number
   body?: string
   body?: string
   chunks?: string[]
   chunks?: string[]
+  holdOpenMs?: number
 }): Promise<ListingServer> {
 }): Promise<ListingServer> {
   const paths: string[] = []
   const paths: string[] = []
   const headers: IncomingMessage['headers'][] = []
   const headers: IncomingMessage['headers'][] = []
@@ -35,7 +38,10 @@ async function listingServer(behavior: {
       // No declared length: the ceiling has to hold on what is read.
       // No declared length: the ceiling has to hold on what is read.
       response.writeHead(behavior.status ?? 200, { 'content-type': 'application/json' })
       response.writeHead(behavior.status ?? 200, { 'content-type': 'application/json' })
       for (const chunk of behavior.chunks) response.write(chunk)
       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
       return
     }
     }
     const body = behavior.body ?? '{}'
     const body = behavior.body ?? '{}'
@@ -60,6 +66,39 @@ async function harness(): Promise<Context> {
   return ctx
   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', () => {
 describe('draft-provider model discovery', () => {
   it('reads an OpenAI-compatible listing and keeps the capacities it discloses', async () => {
   it('reads an OpenAI-compatible listing and keeps the capacities it discloses', async () => {
     const server = await listingServer({
     const server = await listingServer({
@@ -171,12 +210,27 @@ describe('draft-provider model discovery', () => {
       .rejects.toMatchObject({ code: 'DISCOVERY_FAILED' })
       .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()
     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 () => {
   it('honors caller cancellation', async () => {

+ 2 - 2
packages/llm/llm/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/llm/llm/README.md
 #   pnpm run verify-translation-pairing --write packages/llm/llm/README.md
-README.md: 60cc94b6375030955136b4efaf969b69bca2530a
-README.zh.md: 5b24a1e311c37d13dc4f287e5ae4b57efe00e4e6
+README.md: 3a7ec1e8daa33d825fadc15e6481781da48571c4
+README.zh.md: d5a60a574a7947de83c44df85ce71e16b54be9f4

+ 1 - 1
packages/llm/llm/README.md

@@ -26,7 +26,7 @@ An adapter registry plus a single streaming call surface, interceptable via a wa
 
 
 `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
 `LlmService` preserves errors from final adapter selection, synchronous dispatch, iterator construction, and iteration, and binds their provenance to the exact stream handle returned for that model call. `isLlmAdapterFailure(stream, value)` reports only errors from that call's final adapter boundary; `llmFailureOf(stream, value)` returns the adjacent immutable `LlmFailure`; `llmRetryPolicyOf(stream)` returns the immutable policy of the exact registration selected at that boundary, even if the route is later disposed or replaced. A call that never reaches a final adapter has no serving policy. Nested model calls, `llm/stream` middleware, and downstream consumer failures remain unclassified for the outer call. Classification never replaces or mutates the adapter's original coded `Error`.
 
 
-Interrogating an endpoint is configuration-time work over a *draft*, which is why it is keyed by settings namespace rather than by provider route: the provider a surface is adding does not exist yet, so there is no route to name. The request carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and an empty namespace or endpoint fails with `INVALID_DISCOVERY`.
+Interrogating an endpoint is configuration-time work over a *draft*, which is why it is keyed by settings namespace rather than by provider route: the provider a surface is adding does not exist yet, so there is no route to name. The request may still *name* a route it is editing, and an adapter that already describes that route should answer from its own knowledge — better metadata, no network call — which is why `baseURL` is optional and one of the two is required. The request otherwise carries the endpoint, the protocol, and a credential the harness uses for that one interrogation and never stores — nothing here reads or writes settings or credentials, and the reply is candidate metadata a surface may offer for adoption, never a registered catalog. `LlmDiscoveredModel` makes every field but `id` optional because most provider listings disclose an id and nothing else; a surface adopting one still owes the capacities its adapter requires. Duplicate and unusable ids are dropped, an unserved namespace fails with `NO_DISCOVERY`, and a request naming neither a route nor an endpoint fails with `INVALID_DISCOVERY`.
 
 
 Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
 Provider and model metadata is a discovery surface, not a routing whitelist. `registerAdapter()` still owns provider exclusivity and captures the adapter's retry policy for each route, while an adapter may accept model ids absent from `listModels()`; consumers must not reject a request because its model is unlisted. Returned selector metadata is detached and invalid or duplicate adapter entries fail with `INVALID_ADAPTER` or `INVALID_CATALOG`.
 
 

+ 1 - 1
packages/llm/llm/README.zh.md

@@ -26,7 +26,7 @@
 
 
 `LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`。
 `LlmService` 保留来自最终适配器选择、同步 dispatch、iterator 构造与迭代的错误,并将其溯源绑定到该次模型调用返回的精确流句柄。`isLlmAdapterFailure(stream, value)` 只报告该调用最终适配器边界的错误;`llmFailureOf(stream, value)` 返回关联的不可变 `LlmFailure`;`llmRetryPolicyOf(stream)` 返回在该边界选中的确切注册所对应的不可变策略,即使之后释放或替换路由也不变。未到达最终适配器的调用没有服务策略。嵌套模型调用、`llm/stream` middleware 和下游消费方失败对外层调用仍未分类。分类绝不替换或更改适配器原有的带代码 `Error`。
 
 
-询问端点属于配置期针对**草稿**的操作,因此以 settings namespace 而非提供方路由为键:界面正在新增的提供方还不存在,也就没有路由可点名。请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials,回复是界面可供用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,空 namespace 或空端点以 `INVALID_DISCOVERY` 失败。
+询问端点属于配置期针对**草稿**的操作,因此以 settings namespace 而非提供方路由为键:界面正在新增的提供方还不存在,也就没有路由可点名。但请求仍可**点名**它正在编辑的路由,而已经描述该路由的适配器应当用自己的知识作答——元数据更好,且无需联网——这正是 `baseURL` 可选、两者必居其一的原因。除此之外,请求携带端点、协议,以及一条 harness 只用于这一次询问、绝不存储的凭据——这里既不读也不写 settings 与 credentials,回复是界面可供用户采纳的候选元数据,而不是已注册的 catalog。`LlmDiscoveredModel` 除 `id` 外每个字段都是可选的,因为大多数提供方列表只公布 id;采纳其中一条的界面仍要补上其适配器所需的容量。重复与不可用的 id 会被丢弃,无人服务的 namespace 以 `NO_DISCOVERY` 失败,既不点名路由也不给端点的请求以 `INVALID_DISCOVERY` 失败。
 
 
 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
 提供方与模型元数据是发现接口,不是路由白名单。`registerAdapter()` 仍拥有提供方排他性,并为每条路由捕获适配器的重试策略;适配器则可以接受 `listModels()` 中不存在的模型 id,消费方禁止因模型未列出而拒绝请求。返回的 selector 元数据与输入脱离,无效或重复适配器配置项会以 `INVALID_ADAPTER` 或 `INVALID_CATALOG` 失败。
 
 

+ 4 - 2
packages/llm/llm/src/index.ts

@@ -517,8 +517,10 @@ export class LlmService extends Service {
     if (discover === undefined) {
     if (discover === undefined) {
       throw new LlmError(`no model discovery is registered for "${settingsNs}"`, 'NO_DISCOVERY')
       throw new LlmError(`no model discovery is registered for "${settingsNs}"`, 'NO_DISCOVERY')
     }
     }
-    if (request.baseURL.length === 0) {
-      throw new LlmError('model discovery needs a non-empty baseURL', 'INVALID_DISCOVERY')
+    // One of the two identifies what to describe: a route the adapter knows, or
+    // an endpoint to ask. Neither leaves nothing to answer about.
+    if ((request.provider ?? '').length === 0 && (request.baseURL ?? '').length === 0) {
+      throw new LlmError('model discovery needs a provider route or a baseURL', 'INVALID_DISCOVERY')
     }
     }
     const discovered = await discover(request)
     const discovered = await discover(request)
     const seen = new Set<string>()
     const seen = new Set<string>()

+ 12 - 2
packages/llm/llm/src/types.ts

@@ -146,8 +146,18 @@ export interface LlmConfigurableProvider {
  * route: a provider being added has no route to name.
  * route: a provider being added has no route to name.
  */
  */
 export interface LlmModelDiscoveryRequest {
 export interface LlmModelDiscoveryRequest {
-  /** Endpoint to interrogate. */
-  baseURL: string
+  /**
+   * Route the draft is editing, when it edits an existing one. A route whose
+   * adapter already knows its models answers from that knowledge instead of
+   * asking the endpoint — the adapter's own registry is the better answer, and
+   * it costs no network call.
+   */
+  provider?: string
+  /**
+   * Endpoint to interrogate. Optional because a route the adapter already
+   * describes needs none; a route it does not must supply one.
+   */
+  baseURL?: string
   /** Wire protocol the endpoint speaks, when the draft names one. */
   /** Wire protocol the endpoint speaks, when the draft names one. */
   api?: string
   api?: string
   /** Credential for this interrogation alone; the harness never stores it. */
   /** Credential for this interrogation alone; the harness never stores it. */

+ 6 - 0
packages/llm/llm/tests/topology.spec.ts

@@ -255,5 +255,11 @@ describe('model discovery registry', () => {
       .rejects.toMatchObject({ code: 'NO_DISCOVERY' })
       .rejects.toMatchObject({ code: 'NO_DISCOVERY' })
     await expect(ctx.llm.discoverModels('llm-example', { baseURL: '' }))
     await expect(ctx.llm.discoverModels('llm-example', { baseURL: '' }))
       .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
       .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
+    await expect(ctx.llm.discoverModels('llm-example', { provider: '', baseURL: '' }))
+      .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
+    await expect(ctx.llm.discoverModels('llm-example', {}))
+      .rejects.toMatchObject({ code: 'INVALID_DISCOVERY' })
+    // Naming a route alone is enough: the adapter may know it without an endpoint.
+    await expect(ctx.llm.discoverModels('llm-example', { provider: 'known-route' })).resolves.toEqual([])
   })
   })
 })
 })

Certains fichiers n'ont pas été affichés car il y a eu trop de fichiers modifiés dans ce diff