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-04 10:14:46 +08:00
parent 9948a37cbc
commit ecee93ec26
34 changed files with 985 additions and 18 deletions

View File

@@ -2588,6 +2588,29 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro
async models(request) {
return ok(request, await buildModelCatalog(ctx))
},
async discoverModels(request, signal) {
const { settingsNs, baseURL, api, apiKey } = request.payload
try {
const models = await ctx.llm.discoverModels(settingsNs, {
baseURL,
...api === undefined ? {} : { api },
...apiKey === undefined ? {} : { apiKey },
...signal === undefined ? {} : { signal },
})
return ok(request, { models })
} catch (error: unknown) {
// Every failure here is the user's next move, not a transport fault:
// a wrong endpoint, a rejected key, or a protocol with no listing all
// end at the same place — fill the models in by hand. The details
// repeat only what the caller already sent, never the credential.
return err(request, {
code: 'model-discovery-failed',
message: error instanceof Error ? error.message : String(error),
details: { settingsNs, baseURL },
})
}
},
},
events: {

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'>>>

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
}

View File

@@ -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). */

View File

@@ -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() }) }),

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 }

View File

@@ -55,7 +55,7 @@ import {
import {
credentialsDescribeValueSchema, credentialsSetValueSchema, credentialsUnsetValueSchema,
} from '../api/credentials.schema.ts'
import { llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import { llmDiscoverModelsValueSchema, llmModelsValueSchema, llmProvidersValueSchema } from '../api/llm.schema.ts'
import {
subagentHistoryValueSchema,
subagentListValueSchema,
@@ -146,6 +146,7 @@ export interface IApiClient {
llm: {
providers(payload: RequestPayload<'llm.providers'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.providers'>>>
models(payload: RequestPayload<'llm.models'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.models'>>>
discoverModels(payload: RequestPayload<'llm.discoverModels'>, signal?: AbortSignal): Promise<RpcResponse<ResponseValue<'llm.discoverModels'>>>
}
/** client-response passthrough (rpcId is a backfill of the server-request's id — never minted here). */
respond(message: ClientResponse, signal?: AbortSignal): Promise<RpcReceipt>
@@ -200,6 +201,7 @@ const UNARY_VALUE_SCHEMAS: { [K in keyof RpcMethodMap]: z.ZodType<Wire<ResponseV
'credentials.unset': credentialsUnsetValueSchema,
'llm.providers': llmProvidersValueSchema,
'llm.models': llmModelsValueSchema,
'llm.discoverModels': llmDiscoverModelsValueSchema,
}
/** Default timeout for bounded unary calls (rpc-compare 2026-07-19: a hung host must not leave callers pending forever). */
@@ -467,6 +469,7 @@ export abstract class AbstractApiClient implements IApiClient {
readonly llm: IApiClient['llm'] = {
providers: (payload, signal) => this.callUnary('llm.providers', payload, signal),
models: (payload, signal) => this.callUnary('llm.models', payload, signal),
discoverModels: (payload, signal) => this.callUnary('llm.discoverModels', payload, signal),
}
readonly events: IApiClient['events'] = {

View File

@@ -57,7 +57,7 @@ import {
import {
credentialsDescribeRequestSchema, credentialsSetRequestSchema, credentialsUnsetRequestSchema,
} from '../api/credentials.schema.ts'
import { llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import { llmDiscoverModelsRequestSchema, llmModelsRequestSchema, llmProvidersRequestSchema } from '../api/llm.schema.ts'
import {
subagentHistoryRequestSchema,
subagentListRequestSchema,
@@ -125,6 +125,7 @@ const UNARY_ROUTES: UnaryRoutes = {
'credentials.unset': { schema: credentialsUnsetRequestSchema, invoke: (api, r) => api.credentials.unset(r) },
'llm.providers': { schema: llmProvidersRequestSchema, invoke: (api, r) => api.llm.providers(r) },
'llm.models': { schema: llmModelsRequestSchema, invoke: (api, r) => api.llm.models(r) },
'llm.discoverModels': { schema: llmDiscoverModelsRequestSchema, invoke: (api, r, signal) => api.llm.discoverModels(r, signal) },
}
/** Route lookup that narrows an arbitrary path segment to a map key (single cast point for the string→key refinement). */