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:
@@ -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: {
|
||||
|
||||
@@ -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'>>>
|
||||
|
||||
@@ -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() }) }),
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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'] = {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
Reference in New Issue
Block a user