4c80cab108
Review found four defects in the declared-provider work. `PiAiAdapter` reused one `Models` collection and mutated it whenever the configuration changed. `Models.streamSimple()` resolves its provider lazily — when the stream is first consumed, which is after the adapter awaits the route's credential — so a configuration change landing in that window let an in-flight request finish under a configuration it never resolved against, or fail on a provider that no longer existed. Each resolution now produces an immutable snapshot and every operation captures one before its first await, which is what makes the seam's per-step freeze (`llm.prepareCall()`) hold end to end: switching models mid-reply takes effect on the next step, never inside the one in flight. `defaultMaxTokens` was materialized from the catalog's `Model.maxTokens`. The two answer different questions: pi-ai requires that field as the model's output capability, while the seam's is a cap the deployment chose to send on requests naming none, so every request had started carrying a number nobody picked. Only an explicitly configured cap reaches the seam now. The configurable-provider directory was refreshed by disposing its registration and making a new one. A candidate set the registry refuses — a profile keyed `deepseek-official`, which llm-deepseek declares — left the whole directory withdrawn and the Models page empty, silently, because the settings callback contains the failure. The seam's registration handle now carries `replace()` with the same validate-first atomicity `registerAdapter` has. The protocol table offered every pi-ai streaming API, including four whose authentication a profile cannot express: Bedrock signs with SigV4 over AWS credentials and a region, Vertex needs a project, a location, and ADC, Azure needs provider environment plus an api-version, and Codex uses OAuth. Offering them handed back routes that cannot authenticate. Catalog routes still reach them through their own provider.
158 lines
7.3 KiB
TypeScript
158 lines
7.3 KiB
TypeScript
/**
|
|
* Construction of the pi-ai `Provider` that one configured route registers into
|
|
* the adapter's `Models` collection.
|
|
*
|
|
* Two constructions, one decision: a route the installed catalog ships, whose
|
|
* profile does not override the wire protocol, **reuses that catalog provider**
|
|
* with its models replaced — the catalog provider owns API implementations this
|
|
* package cannot reconstruct (Bedrock loads its Smithy module through a
|
|
* separate entry point), so rebuilding it from parts would silently narrow
|
|
* which providers work. Every other route — one pi-ai has never heard of, or a
|
|
* catalog route pointed at a different protocol — is built by `createProvider`
|
|
* over the protocol table below.
|
|
*
|
|
* Credentials never reach this module's storage: the harness resolves a route's
|
|
* key through `ctx.credentials` before the request enters pi-ai and hands it
|
|
* over as a stream option, which `Models` presents to `resolve()` as the
|
|
* credential key.
|
|
*
|
|
* @module dsh-llm-pi-ai/provider
|
|
*/
|
|
|
|
import { createProvider } from '@earendil-works/pi-ai'
|
|
import type { Api, ApiKeyAuth, Model, Provider, ProviderStreams } from '@earendil-works/pi-ai'
|
|
import { anthropicMessagesApi } from '@earendil-works/pi-ai/api/anthropic-messages.lazy'
|
|
import { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy'
|
|
import { mistralConversationsApi } from '@earendil-works/pi-ai/api/mistral-conversations.lazy'
|
|
import { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
|
|
import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.lazy'
|
|
import { piMessagesApi } from '@earendil-works/pi-ai/api/pi-messages.lazy'
|
|
import { catalogProvider } from './catalog.ts'
|
|
|
|
/**
|
|
* Wire protocols a configured route may name, mapped to pi-ai's lazily loaded
|
|
* implementations. Each entry is the factory that pi-ai's matching provider
|
|
* factory uses, so a hand-declared route reaches exactly the implementation a
|
|
* catalog route would.
|
|
*
|
|
* The table is deliberately narrower than pi-ai's full streaming API set: it
|
|
* holds only the protocols a profile can *completely* describe with a key, an
|
|
* endpoint, and headers. Bedrock signs with SigV4 over AWS credentials and a
|
|
* region, Vertex needs a project, a location, and application-default
|
|
* credentials, Azure needs provider environment plus an api-version, and
|
|
* Codex authenticates through OAuth — none of which this configuration shape
|
|
* can express, so offering them would hand back a provider that cannot
|
|
* authenticate. Catalog routes still reach those protocols through their own
|
|
* provider; only an explicit override is refused.
|
|
*/
|
|
const PROTOCOLS: Readonly<Record<string, () => ProviderStreams>> = {
|
|
'anthropic-messages': anthropicMessagesApi,
|
|
'google-generative-ai': googleGenerativeAIApi,
|
|
'mistral-conversations': mistralConversationsApi,
|
|
'openai-completions': openAICompletionsApi,
|
|
'openai-responses': openAIResponsesApi,
|
|
'pi-messages': piMessagesApi,
|
|
}
|
|
|
|
/**
|
|
* Every wire protocol a configured route may name, sorted for stable
|
|
* diagnostics and configuration surfaces.
|
|
* @returns the supported protocol identifiers.
|
|
*/
|
|
export function supportedProtocols(): readonly string[] {
|
|
return Object.keys(PROTOCOLS).sort()
|
|
}
|
|
|
|
/**
|
|
* Api-key auth for a route the harness authenticates itself. `Models` calls
|
|
* this after the adapter has already resolved the route's credential, so a
|
|
* missing key here is not this layer's failure: a named-but-unresolvable
|
|
* reference has already failed the request with `MISSING_CREDENTIAL`, and a
|
|
* route naming no credential at all is deliberately unauthenticated. Reporting
|
|
* it as configured hands the decision to the protocol, which is where the
|
|
* requirement actually lives — pi-ai's OpenAI-compatible implementation, for
|
|
* one, still insists on a key or an `Authorization` header of its own.
|
|
* @param name - display name used as the resolution's status label.
|
|
* @returns the api-key auth for a harness-authenticated route.
|
|
*/
|
|
function harnessApiKeyAuth(name: string): ApiKeyAuth {
|
|
return {
|
|
name,
|
|
resolve: ({ credential }) => Promise.resolve({
|
|
auth: credential?.key === undefined ? {} : { apiKey: credential.key },
|
|
source: name,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/** The resolved route facts provider construction reads. */
|
|
export interface ProviderSpec {
|
|
/** Provider route key; also the `Models` collection key and each model's `provider`. */
|
|
provider: string
|
|
/** Display name for selectors and status labels. */
|
|
displayName: string
|
|
/** Wire protocol override; absent means each model keeps its catalog protocol. */
|
|
api?: string
|
|
/** Endpoint override already applied to {@link models}; kept for provider-level display. */
|
|
baseURL?: string
|
|
/** The route's materialized models, in configuration order. */
|
|
models: readonly Model<Api>[]
|
|
}
|
|
|
|
/**
|
|
* Reuse an installed catalog provider with this route's models and identity.
|
|
* Model dispatch stays with the catalog provider, so its API implementations,
|
|
* compatibility quirks, and ambient credential discovery are preserved exactly.
|
|
* Catalog-owned dynamic refresh is dropped: this route's catalog is the
|
|
* settings document, and a background refresh would contradict it.
|
|
*/
|
|
function reuseCatalogProvider(base: Provider, spec: ProviderSpec): Provider {
|
|
// Provider-level `baseUrl` is display metadata: pi-ai routes every request
|
|
// through `Model.baseUrl`, which model resolution has already overridden.
|
|
const baseUrl = spec.baseURL ?? base.baseUrl
|
|
return {
|
|
id: spec.provider,
|
|
name: spec.displayName,
|
|
...baseUrl === undefined ? {} : { baseUrl },
|
|
auth: base.auth,
|
|
getModels: () => spec.models,
|
|
// Delegated rather than copied: the catalog provider stays the receiver, so
|
|
// an implementation holding state on itself keeps working.
|
|
stream: (model, context, options) => base.stream(model, context, options),
|
|
streamSimple: (model, context, options) => base.streamSimple(model, context, options),
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Build the pi-ai provider for one resolved route.
|
|
* @param spec - the resolved route facts.
|
|
* @returns the provider to register in the adapter's `Models` collection.
|
|
* @throws Error when the route names a wire protocol this build cannot serve.
|
|
*/
|
|
export function buildProvider(spec: ProviderSpec): Provider {
|
|
const catalog = catalogProvider(spec.provider)
|
|
// A catalog route keeping its catalog protocol reuses the catalog provider;
|
|
// an explicit protocol means the deployment is repointing the route at a
|
|
// different wire format, which only the protocol table can serve.
|
|
if (catalog !== undefined && spec.api === undefined) return reuseCatalogProvider(catalog, spec)
|
|
|
|
// Every model on this path carries the route's protocol: model resolution
|
|
// requires one for a route the catalog cannot default, and an explicit one
|
|
// replaces each catalog model's own. So the route has a single API.
|
|
const factory = spec.api === undefined ? undefined : PROTOCOLS[spec.api]
|
|
if (factory === undefined) {
|
|
throw new Error(
|
|
`llm-pi-ai: provider "${spec.provider}" names api "${spec.api}", which this build cannot serve;`
|
|
+ ` supported protocols are ${supportedProtocols().join(', ')}`,
|
|
)
|
|
}
|
|
return createProvider({
|
|
id: spec.provider,
|
|
name: spec.displayName,
|
|
...spec.baseURL === undefined ? {} : { baseUrl: spec.baseURL },
|
|
auth: { apiKey: harnessApiKeyAuth(spec.displayName) },
|
|
models: spec.models,
|
|
api: factory(),
|
|
})
|
|
}
|