feat(llm): interrogate a draft provider endpoint for its models

Once a pi-ai route became a declaration rather than a catalog lookup,
adding an OpenAI-compatible gateway meant knowing its model ids up
front. Most such endpoints publish that list at `GET /models`, but no
seam operation could ask: every one is keyed by a registered provider
route, and the provider being added has no route, no stored profile,
and no stored credential — the endpoint and key are values in a form.

Interrogation is therefore keyed by settings namespace, which a
configuration surface already holds from the configurable-provider
directory. `registerModelDiscovery` offers it per namespace,
`discoverModels` asks, and the request carries the draft itself. The
reply is candidates, not a catalog: every field but the id is optional
because most listings disclose nothing else, and adopting one is a
settings write like any other. Nothing here reads or writes settings or
credentials, so `settings.yaml` still decides what a route serves.

`llm.discoverModels` carries the same draft over the wire. Its apiKey is
the third and last payload a secret may ride, and it is never stored,
logged, or echoed; every refusal folds into `model-discovery-failed`,
naming the endpoint asked but never the credential offered.

The pi-ai side is a plain GET for OpenAI-compatible protocols only —
their listing shape is the one gateways, self-hosted servers, and the
official endpoints agree on. Others say so, sending the user to
hand-entry rather than reporting a guessed shape as an empty provider.
The reply is read under a four-megabyte ceiling held on the bytes
actually received, because the endpoint is a URL the user typed.
This commit is contained in:
Yichen Jiang
2026-08-05 19:50:11 +08:00
parent 9948a37cbc
commit ecee93ec26
34 files changed
+985 -18

No files matched your search

+25 -1
View File
@@ -6,7 +6,7 @@
import { z } from 'zod'
import type { RequestPayload, ResponseValue } from './rpc-map.ts'
import type { Wire } from './rpc.schema.ts'
import type { ConfigurableProviderView } from './llm.ts'
import type { ConfigurableProviderView, DiscoveredModelView } from './llm.ts'
import { modelCatalogFailureSchema, modelProviderGroupSchema } from './sessions.schema.ts'
/** ConfigurableProviderView row of llm.providers. */
@@ -34,3 +34,27 @@ export const llmModelsValueSchema = z.object({
groups: z.array(modelProviderGroupSchema),
failures: z.array(modelCatalogFailureSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'llm.models'>>>
/** DiscoveredModelView row of llm.discoverModels. */
export const discoveredModelViewSchema = z.object({
id: z.string().min(1),
name: z.string().min(1).optional(),
contextWindow: z.number().int().positive().optional(),
maxTokens: z.number().int().positive().optional(),
}) satisfies z.ZodType<Wire<DiscoveredModelView>>
/** llm.discoverModels request payload. */
export const llmDiscoverModelsRequestSchema = z.object({
settingsNs: z.string().min(1),
baseURL: z.string().min(1),
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.
apiKey: z.string().min(1).optional(),
}) satisfies z.ZodType<Wire<RequestPayload<'llm.discoverModels'>>>
/** llm.discoverModels response value. */
export const llmDiscoverModelsValueSchema = z.object({
models: z.array(discoveredModelViewSchema),
}) satisfies z.ZodType<Wire<ResponseValue<'llm.discoverModels'>>>
+34
View File
@@ -40,4 +40,38 @@ export interface LlmApi {
* failures ride `failures` without failing the sound groups.
*/
models(request: RpcRequest<{}>): Promise<RpcResponse<{ groups: ModelProviderGroup[]; failures: ModelCatalogFailure[] }>>
/**
* Interrogate a provider endpoint the configuration surface is still
* drafting, and return the models it advertises for the user to adopt.
*
* 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.
*/
discoverModels(
request: RpcRequest<{
settingsNs: string
baseURL: string
api?: string
apiKey?: string
}>,
signal?: AbortSignal,
): Promise<RpcResponse<{ models: DiscoveredModelView[] }>>
}
/** Wire view of one model an interrogated endpoint advertises. */
export interface DiscoveredModelView {
/** Model id the endpoint accepts. */
id: string
/** Human-readable name when the endpoint supplies one. */
name?: string
/** Maximum combined request and response context, when disclosed. */
contextWindow?: number
/** Maximum output tokens, when disclosed. */
maxTokens?: number
}
@@ -66,6 +66,7 @@ export interface RpcMethodMap {
'credentials.unset': CredentialsApi['unset']
'llm.providers': LlmApi['providers']
'llm.models': LlmApi['models']
'llm.discoverModels': LlmApi['discoverModels']
}
/** Business request payload of method K (reaches through the RpcRequest narrow form to payload). */
@@ -55,6 +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-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('model-discovery-failed'), message: z.string(), details: z.object({ settingsNs: z.string(), baseURL: 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('subagent-parent-unavailable'), message: z.string(), details: z.object({ parentSessionId: z.string() }) }),
+9
View File
@@ -70,6 +70,15 @@ export interface RpcErrorDetailsMap {
'settings-conflict': { ns: string; expected: number; actual: number }
/** A credential write was refused (read-only shadowing layer or storage failure); the message is the seam's own text. */
'credential-rejected': { ref: string }
/**
* Interrogating a draft provider endpoint did not produce a model listing:
* no adapter family serves the namespace, the protocol has no listing this
* build can read, or the endpoint was unreachable, refused the credential,
* or answered with something else. The message is the adapter's own text —
* it is what the form shows before falling back to hand-entry — and the
* details name the endpoint asked, never the credential offered.
*/
'model-discovery-failed': { settingsNs: string; baseURL: string }
'title-invalid': { sessionId: SessionId }
'fork-unavailable': { sessionId: SessionId }
'subagent-parent-unavailable': { parentSessionId: SessionId }