Files
deepseek-harness/packages/llm/llm-pi-ai/src/provider.ts
T
Yichen Jiang 73fce861e5 fix(llm): let a catalog route keep the auth its provider actually declares
pi-ai resolves a request's apiKey override only through a provider that
declares an api-key method: resolveProviderAuth short-circuits to that
method when the override is present, and otherwise falls through to the
credential store and then to ambient discovery. A provider with no
api-key method at all therefore resolves to nothing, and the request
fails with "Provider is not configured" before any network I/O.

Two routes hit that. openai-codex ships OAuth alone, so moving off the
/compat dispatch broke a profile that names a key for it — the old path
handed the token straight to the provider. And a catalog route naming an
api was being rebuilt with the harness's own auth, so `openai: {api:
openai-completions}` stopped reading OPENAI_API_KEY, contradicting the
documented promise that omitting a credential keeps provider-native
discovery.

Auth is now one decision for both constructions. A catalog route keeps
its installed provider's auth, through an api override too: which
environment a provider reads belongs to the provider, not to the wire
format its models speak. A catalog provider with no api-key method gets
the harness method beside its own, but only when the profile names a
credential — a keyless codex profile keeps the honest refusal, since
this adapter holds no OAuth store to resolve through.

Materialization now spreads the installed entry instead of enumerating
the result, so a Model field this package does not model survives a
pi-ai upgrade; headers went missing from an nvidia route exactly that
way once already. providerInfo reports the configured displayName, which
also joins the registration facts so a rename re-registers rather than
leaving the old label in every selector. A refused registration swap
gets its own diagnostic naming the route, matching the directory swap
beside it.

The README documented endpoint interrogation this layer does not
implement, and still described unknown providers as kept-last-good after
they became legal declarations refused at the write point. The Agent
Note claimed per-model reasoning configurability the schema never had,
required capacities the route now defaults, and stated an apiKey
override that short-circuits unconditionally.
2026-08-05 18:54:47 +08:00

192 lines
9.2 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 { openAICompletionsApi } from '@earendil-works/pi-ai/api/openai-completions.lazy'
import { openAIResponsesApi } from '@earendil-works/pi-ai/api/openai-responses.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 narrow: the protocols a hand-declared route
* actually reaches for today, each completely describable 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. The remainder are absent for want of a consumer rather than a
* blocker: each is one line here once a deployment needs it. Catalog routes
* still reach every protocol through their own provider; only an explicit
* override is refused.
*/
const PROTOCOLS: Readonly<Record<string, () => ProviderStreams>> = {
'openai-completions': openAICompletionsApi,
'openai-responses': openAIResponsesApi,
'anthropic-messages': anthropicMessagesApi,
}
/**
* Every wire protocol a configured route may name, most-reached first. The
* order is the table's and therefore stable; a configuration surface offering
* a choice presents the first as its default, which is why the protocol a
* hand-declared gateway most often speaks — and the one endpoint interrogation
* can read — leads.
* @returns the supported protocol identifiers.
*/
export function supportedProtocols(): readonly string[] {
return Object.keys(PROTOCOLS)
}
/**
* 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>[]
/**
* Whether the profile names a credential — a literal key or a reference.
* Only that decides whether {@link routeAuth} adds the harness's own api-key
* method to a catalog provider that offers none; the key itself still arrives
* per request, never at construction.
*/
namesCredential: boolean
}
/**
* The auth one route resolves its credential through.
*
* A catalog route keeps the installed provider's own auth, which is what
* preserves provider-native ambient discovery for a profile naming no
* credential. That holds even when the profile repoints the protocol: which
* environment a provider reads is a property of the provider, not of the wire
* format its models speak.
*
* The single addition covers a catalog provider that offers no api-key method
* at all. pi-ai resolves a request's `apiKey` override only when the provider
* declares one (`resolveProviderAuth` checks `provider.auth.apiKey` before
* honouring the override), so an OAuth-only provider — `openai-codex` is the
* one the installed catalog ships — would refuse a profile's explicit key with
* `Provider is not configured` before any request went out. Adding the harness
* method beside the provider's own restores that route. A keyless profile adds
* nothing and still reports the honest refusal, because this adapter resolves
* credentials through its own seam and holds no OAuth store to fall back on.
* @param spec - the resolved route facts.
* @param catalog - the installed catalog provider, when pi-ai ships one.
* @returns the auth to construct this route's provider with.
*/
function routeAuth(spec: ProviderSpec, catalog: Provider | undefined): Provider['auth'] {
if (catalog === undefined) return { apiKey: harnessApiKeyAuth(spec.displayName) }
if (catalog.auth.apiKey !== undefined || !spec.namesCredential) return catalog.auth
return { ...catalog.auth, apiKey: harnessApiKeyAuth(spec.displayName) }
}
/**
* 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: routeAuth(spec, base),
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: routeAuth(spec, catalog),
models: spec.models,
api: factory(),
})
}