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.
187 lines
7.8 KiB
TypeScript
187 lines
7.8 KiB
TypeScript
/**
|
|
* Four-quadrant RPC message model. Channels and messages are decoupled: HTTP,
|
|
* WebSocket, and in-process SSE are physical carriers, while logical messages
|
|
* are channel-independent and form a four-member discriminated union.
|
|
* api/ contract layer: zero Node dependencies, importable from the browser.
|
|
*/
|
|
|
|
import type { z as zCore } from 'zod'
|
|
type ZodIssue = zCore.core.$ZodIssue
|
|
import type { Branded } from '@deepseek-ai/dsh-brand'
|
|
import type { SessionId } from '@deepseek-ai/dsh-session/types'
|
|
import type { InboxItemId } from '@deepseek-ai/dsh-agent/brand'
|
|
|
|
/**
|
|
* Message correlation id: the initiator mints it on a request; a response
|
|
* echoes the matching request's rpcId and never mints a new one.
|
|
*/
|
|
export type RpcId = Branded<'rpc-id'>
|
|
|
|
/**
|
|
* Brands a string as RpcId (same precedent as core `SessionId()`). Minted by the initiator:
|
|
* client-request → client mints; server-request → host mints (answerable frames get a stable
|
|
* logical id, pure pushes mint a fresh one each time).
|
|
* @param id - Raw id string (implementations mint UUIDs; tests may pass fixtures).
|
|
* @returns The same string, branded (compile-time cast, zero runtime cost).
|
|
*/
|
|
export function RpcId(id: string): RpcId {
|
|
return id as RpcId
|
|
}
|
|
|
|
/** Error code → details type map (a second table isomorphic to RpcMethodMap). New code = one row here + one branch in the error schema. */
|
|
export interface RpcErrorDetailsMap {
|
|
'bad-request': { issues: ZodIssue[] }
|
|
'cancelled': {}
|
|
'session-not-found': { sessionId: SessionId }
|
|
'model-unavailable': { provider: string; model: string }
|
|
'session-conflict': { sessionId: SessionId; requestedCwd: string; existingCwd?: string }
|
|
'workspace-attach-failed': { sessionId: SessionId; workspaceId: string }
|
|
'workspace-not-found': { workspaceId: string }
|
|
'workspace-invalid-path': { path: string }
|
|
'workspace-name-conflict': { name: string }
|
|
'workspace-move-invalid': { workspaceId: string; sessionId: SessionId; beforeSessionId?: SessionId }
|
|
'directory-unreadable': { path: string }
|
|
'directory-exists': { path: string }
|
|
'directory-create-failed': { path: string }
|
|
'directory-picker-unavailable': { capability: string }
|
|
'agent-busy': { reason: string }
|
|
'queue-item-not-found': { itemId: InboxItemId }
|
|
'steer-unavailable': { itemId: InboxItemId }
|
|
/** A known slash command reported a usage/state error; the message is the command's own text. */
|
|
'command-error': {}
|
|
/** A leading-/ prompt named no registered command; the message names the token. */
|
|
'unknown-command': {}
|
|
/**
|
|
* A settings write was refused (schema validation, unknown namespace,
|
|
* read-only provider, or storage failure); the message is the seam's text.
|
|
*/
|
|
'settings-rejected': { ns: string }
|
|
/**
|
|
* A settings namespace exists in the seam but is outside the configuration
|
|
* plane's model-provider boundary, so this proxy neither reads nor writes
|
|
* it; the message names the namespace.
|
|
*/
|
|
'settings-not-exposed': { ns: string }
|
|
/**
|
|
* A settings write carried an `expectedRevision` the namespace has already
|
|
* moved past: another writer (tab, editor, or an external file edit) landed
|
|
* first. The details carry both revisions so a client can re-read and retry.
|
|
*/
|
|
'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 }
|
|
'subagent-not-found': { parentSessionId: SessionId; childSessionId: SessionId }
|
|
'subagent-catalog-diagnostic': {
|
|
parentSessionId: SessionId
|
|
childSessionId: SessionId
|
|
reason: 'corrupt' | 'unsupported' | 'unavailable'
|
|
}
|
|
'subagent-not-resumable': { childSessionId: SessionId }
|
|
'subagent-unauthorized': { childSessionId: SessionId }
|
|
'subagent-delivery-unavailable': { childSessionId: SessionId }
|
|
'internal': {}
|
|
}
|
|
|
|
/** Closed error-code union (the keys of RpcErrorDetailsMap). */
|
|
export type RpcErrorCode = keyof RpcErrorDetailsMap
|
|
|
|
/**
|
|
* Distributive union expanded from the map: code is the discriminant, so
|
|
* `switch (error.code)` narrows details. details is required (internal uses an explicit {}).
|
|
*/
|
|
export type RpcError = {
|
|
[C in RpcErrorCode]: { code: C; message: string; details: RpcErrorDetailsMap[C] }
|
|
}[RpcErrorCode]
|
|
|
|
/** Business success/failure result: the result slot of a unary response; methods never throw business errors. */
|
|
export type RpcResult<T> = { ok: true; value: T } | { ok: false; error: RpcError }
|
|
|
|
/**
|
|
* Fold a transport exception into the RpcResult error branch (unified error
|
|
* surface; 'internal' as the catch-all code). Lives with RpcResult so every
|
|
* carrier consumer folds the same way.
|
|
* @param error - the thrown value from the carrier.
|
|
* @returns the error branch of an RpcResult.
|
|
*/
|
|
export function transportError<T>(error: unknown): RpcResult<T> {
|
|
return {
|
|
ok: false,
|
|
error: { code: 'internal', message: error instanceof Error ? error.message : String(error), details: {} },
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Signature-layer narrow form, request side (domain-interface view, shared by
|
|
* both directions): rpcId is explicit in the signature, never mixed into the
|
|
* business payload; the type tag and method are filled in by the carrier layer.
|
|
*/
|
|
export interface RpcRequest<P> {
|
|
rpcId: RpcId
|
|
payload: P
|
|
}
|
|
|
|
/** Signature-layer narrow form, response side: rpcId always echoes the matching request. */
|
|
export interface RpcResponse<T> {
|
|
rpcId: RpcId
|
|
result: RpcResult<T>
|
|
}
|
|
|
|
// ---- Wire full forms: four named members of a discriminated union (discriminant = the four `type` literals) ----
|
|
|
|
/** Call initiated by the client (wire carrier: POST /api/<method> body). */
|
|
export interface ClientRequest {
|
|
type: 'client-request'
|
|
rpcId: RpcId
|
|
method: string
|
|
payload: unknown
|
|
}
|
|
|
|
/** Response to a ClientRequest (wire carrier: the HTTP response body of that POST); rpcId echoed. */
|
|
export interface ServerResponse {
|
|
type: 'server-response'
|
|
rpcId: RpcId
|
|
result: RpcResult<unknown>
|
|
}
|
|
|
|
/**
|
|
* Message initiated by the server (wire carrier: downstream stream frame). Answerable interactions
|
|
* (approval/question requested — stable rpcId, reused on replay) and pure pushes
|
|
* (session/event etc. — rpcId identifies that one push) share this shape; whether a
|
|
* response is expected is determined statically by method (a strict dichotomy, no third kind).
|
|
*/
|
|
export interface ServerRequest {
|
|
type: 'server-request'
|
|
rpcId: RpcId
|
|
method: string
|
|
payload: unknown
|
|
}
|
|
|
|
/** Response to a ServerRequest (wire carrier: POST /api/respond body); rpcId echoed, never minted anew. */
|
|
export interface ClientResponse {
|
|
type: 'client-response'
|
|
rpcId: RpcId
|
|
result: RpcResult<unknown>
|
|
}
|
|
|
|
/** Authoritative wire full-form union; narrow via `switch (message.type)`. */
|
|
export type RpcMessage = ClientRequest | ServerResponse | ServerRequest | ClientResponse
|
|
|
|
/**
|
|
* Carrier receipt (not an RpcMessage — it belongs to the carrier layer, same
|
|
* discipline as "HTTP status describes only the carrier"): the HTTP response
|
|
* body of the POST carrying a client-response. Late/duplicate responses yield not-pending.
|
|
*/
|
|
export type RpcReceipt = { accepted: true } | { accepted: false; reason: 'not-pending' | 'bad-response' }
|