feat(llm): declare pi-ai providers instead of looking them up
A pi-ai route had to name an installed catalog provider, served that catalog's models verbatim, and could override only the endpoint. An OpenAI-compatible gateway, a self-hosted server, or a model newer than the pinned pi-ai release was therefore unreachable, and a stale context window could not be corrected without upgrading the package. A route is now a declaration whose defaults come from the installed catalog. `catalog.ts` merges that catalog under the profile's own model entries, `provider.ts` builds the pi-ai Provider (reusing the catalog provider when the route keeps its protocol, so implementations this package cannot reconstruct keep working), and the adapter serves every operation from one `createModels()` collection. That also retires the `@earendil-works/pi-ai/compat` import, which pi-ai documents as a temporary entry point it deletes with its ModelManager migration. Credentials stay on the harness seam: the resolved key rides the request as pi-ai's highest-priority auth override, so `Models` holds no credential store and a named-but-missing reference still fails loud instead of falling back to an unrelated ambient key. A model's configured maxTokens now reaches the seam as defaultMaxTokens.
This commit is contained in:
@@ -1,17 +1,25 @@
|
||||
/**
|
||||
* Generic pi-ai-backed implementation of the Harness LLM seam.
|
||||
*
|
||||
* The adapter owns one pi-ai `Models` collection and keeps it in step with the
|
||||
* resolved profiles: each route contributes the `Provider` its resolution built,
|
||||
* so model lookup, protocol dispatch, and request auth all reach pi-ai through
|
||||
* its supported runtime rather than the deprecated global compatibility entry.
|
||||
*
|
||||
* Credentials stay outside that collection. The harness resolves a route's key
|
||||
* through its own seam and passes it as the request's `apiKey` option, which
|
||||
* pi-ai treats as the highest-priority auth override — so `Models` never holds
|
||||
* a credential store and the harness keeps its fail-loud reference semantics.
|
||||
*
|
||||
* @module dsh-llm-pi-ai/adapter
|
||||
*/
|
||||
|
||||
import { streamSimple } from '@earendil-works/pi-ai/compat'
|
||||
import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all'
|
||||
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
|
||||
import { getSupportedThinkingLevels } from '@earendil-works/pi-ai'
|
||||
import { createModels, getSupportedThinkingLevels } from '@earendil-works/pi-ai'
|
||||
import type {
|
||||
Api,
|
||||
Model,
|
||||
ModelThinkingLevel,
|
||||
MutableModels,
|
||||
SimpleStreamOptions,
|
||||
ThinkingLevel,
|
||||
} from '@earendil-works/pi-ai'
|
||||
@@ -40,29 +48,15 @@ export interface PiAiAdapterOptions {
|
||||
profiles: () => ReadonlyMap<string, ResolvedPiAiProviderProfile>
|
||||
/**
|
||||
* Resolve the credential for one already-resolved profile; called once per
|
||||
* stream call and frozen for that call. `undefined` defers to pi-ai's
|
||||
* provider-native ambient discovery, which the plugin allows only for a
|
||||
* profile naming no credential at all; a named reference that misses throws
|
||||
* `LlmError` `MISSING_CREDENTIAL` rather than falling back.
|
||||
* stream call and frozen for that call. `undefined` defers to the route's own
|
||||
* pi-ai auth, which for an installed catalog route is its provider-native
|
||||
* ambient discovery; the plugin allows that only for a profile naming no
|
||||
* credential at all, because a named reference that misses throws `LlmError`
|
||||
* `MISSING_CREDENTIAL` rather than falling back.
|
||||
*/
|
||||
resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a catalog model dynamically and apply only the configured endpoint
|
||||
* override, preserving the catalog's API/capability/compatibility metadata.
|
||||
*/
|
||||
function resolvePiModel(
|
||||
profile: ResolvedPiAiProviderProfile,
|
||||
modelId: string,
|
||||
): Model<Api> {
|
||||
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
|
||||
if (model === undefined) {
|
||||
throw new LlmError(`pi-ai provider "${profile.provider}" has no catalog model "${modelId}"`, 'UNKNOWN_MODEL')
|
||||
}
|
||||
return profile.baseURL === undefined ? model : { ...model, baseUrl: profile.baseURL }
|
||||
}
|
||||
|
||||
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
|
||||
function profileOptions(
|
||||
profile: ResolvedPiAiProviderProfile,
|
||||
@@ -108,28 +102,65 @@ function requestHeaders(headers: Readonly<Record<string, string>> | undefined):
|
||||
}
|
||||
|
||||
/**
|
||||
* pi-ai-backed multi-provider adapter. Model descriptors are resolved for each
|
||||
* request, so models need not be registered during the Cordis lifecycle.
|
||||
* pi-ai-backed multi-provider adapter. Each operation reads the current
|
||||
* profiles, so a configuration change reaches the next request without a
|
||||
* restart; model descriptors come from the collection those profiles built.
|
||||
*/
|
||||
export class PiAiAdapter extends LlmAdapter {
|
||||
private readonly models: MutableModels = createModels()
|
||||
private registered: ReadonlyMap<string, ResolvedPiAiProviderProfile> | undefined
|
||||
|
||||
constructor(private readonly config: PiAiAdapterOptions) {
|
||||
super()
|
||||
}
|
||||
|
||||
/**
|
||||
* The `Models` collection for the current profiles. Resolution memoizes its
|
||||
* result, so an unchanged configuration is recognized by identity and the
|
||||
* collection is rebuilt only when the route set or any profile actually
|
||||
* changes.
|
||||
*/
|
||||
private collection(): MutableModels {
|
||||
const profiles = this.config.profiles()
|
||||
if (profiles === this.registered) return this.models
|
||||
this.models.clearProviders()
|
||||
for (const profile of profiles.values()) this.models.setProvider(profile.piProvider)
|
||||
this.registered = profiles
|
||||
return this.models
|
||||
}
|
||||
|
||||
/** The profile for one route, or the seam's own not-owned failure. */
|
||||
private profileOf(provider: string): ResolvedPiAiProviderProfile {
|
||||
const profile = this.config.profiles().get(provider)
|
||||
if (profile === undefined) {
|
||||
throw new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER')
|
||||
}
|
||||
return profile
|
||||
}
|
||||
|
||||
/** The configured descriptor for one exact route/model pair. */
|
||||
private modelOf(provider: string, model: string): Model<Api> {
|
||||
this.profileOf(provider)
|
||||
const resolved = this.collection().getModel(provider, model)
|
||||
if (resolved === undefined) {
|
||||
throw new LlmError(`pi-ai provider "${provider}" has no configured model "${model}"`, 'UNKNOWN_MODEL')
|
||||
}
|
||||
return resolved
|
||||
}
|
||||
|
||||
override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {
|
||||
return this.config.profiles().get(provider)?.retryPolicy
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
const profile = this.config.profiles().get(provider)
|
||||
if (profile === undefined) {
|
||||
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
|
||||
}
|
||||
return Promise.resolve(getBuiltinModels(profile.provider as BuiltinProvider).map(model => ({
|
||||
provider,
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
})))
|
||||
return Promise.resolve().then(() => {
|
||||
this.profileOf(provider)
|
||||
return this.collection().getModels(provider).map(model => ({
|
||||
provider,
|
||||
id: model.id,
|
||||
name: model.name,
|
||||
}))
|
||||
})
|
||||
}
|
||||
|
||||
override resolveModel(
|
||||
@@ -137,15 +168,9 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
model: string,
|
||||
_signal?: AbortSignal,
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
const profile = this.config.profiles().get(provider)
|
||||
if (profile === undefined) {
|
||||
return Promise.reject(new LlmError(
|
||||
`pi-ai adapter does not own provider "${provider}"`,
|
||||
'NO_ADAPTER',
|
||||
))
|
||||
}
|
||||
return Promise.resolve().then(() => {
|
||||
const resolvedModel = resolvePiModel(profile, model)
|
||||
const profile = this.profileOf(provider)
|
||||
const resolvedModel = this.modelOf(provider, model)
|
||||
const levels = getSupportedThinkingLevels(resolvedModel)
|
||||
const defaultLevel = resolveReasoningLevel(resolvedModel, profile.reasoning)
|
||||
return {
|
||||
@@ -153,6 +178,7 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
id: model,
|
||||
name: resolvedModel.name,
|
||||
context: { contextWindow: resolvedModel.contextWindow },
|
||||
defaultMaxTokens: resolvedModel.maxTokens,
|
||||
reasoning: {
|
||||
efforts: levels.map(level => ({
|
||||
id: ReasoningEffortId(level),
|
||||
@@ -170,14 +196,13 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
if (options.stop !== undefined) {
|
||||
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
|
||||
}
|
||||
// One resolution per stream call: the profile snapshot and the credential
|
||||
// freeze here and hold for this whole request, so an in-flight stream
|
||||
// never observes a configuration change and the next call re-resolves.
|
||||
const profile = this.config.profiles().get(options.provider)
|
||||
if (profile === undefined) {
|
||||
throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
|
||||
}
|
||||
const model = resolvePiModel(profile, options.model)
|
||||
// One resolution per stream call: the profile snapshot, the model
|
||||
// descriptor, and the credential freeze here and hold for this whole
|
||||
// request, so an in-flight stream never observes a configuration change and
|
||||
// the next call re-resolves.
|
||||
const profile = this.profileOf(options.provider)
|
||||
const collection = this.collection()
|
||||
const model = this.modelOf(options.provider, options.model)
|
||||
const reasoning = resolveReasoningLevel(
|
||||
model,
|
||||
options.reasoningEffort ?? profile.reasoning,
|
||||
@@ -192,7 +217,7 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
using watchdog = idleWatchdog(upstream, streamIdleTimeoutMs, 'LLM_STREAM_IDLE_TIMEOUT')
|
||||
|
||||
try {
|
||||
const events = streamSimple(model, toPiContext(options), {
|
||||
const events = collection.streamSimple(model, toPiContext(options), {
|
||||
...profileOptions(profile, reasoning, apiKey),
|
||||
...options.temperature === undefined ? {} : { temperature: options.temperature },
|
||||
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
|
||||
|
||||
193
packages/llm/llm-pi-ai/src/catalog.ts
Normal file
193
packages/llm/llm-pi-ai/src/catalog.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
/**
|
||||
* Materialization of one provider route's model catalog. The installed pi-ai
|
||||
* catalog supplies defaults keyed by model id, and a profile's own model
|
||||
* entries override them field by field, so a route naming a catalog provider
|
||||
* stays configuration-free while a route pi-ai has never heard of is fully
|
||||
* describable from `settings.yaml`.
|
||||
*
|
||||
* Every pi-ai `Model` field the harness cannot default is required here rather
|
||||
* than at request time: an unserviceable route fails while its configuration is
|
||||
* being resolved, which is the earliest point that can name the offending key.
|
||||
*
|
||||
* @module dsh-llm-pi-ai/catalog
|
||||
*/
|
||||
|
||||
import { builtinProviders, getBuiltinModels, getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
|
||||
import type { BuiltinProvider } from '@earendil-works/pi-ai/providers/all'
|
||||
import type { Api, Model, ModelCost, Provider } from '@earendil-works/pi-ai'
|
||||
|
||||
/**
|
||||
* Pricing for a model the installed catalog does not describe. The harness
|
||||
* never reads pi-ai's cost metadata — `replay.ts` zeroes it and no consumer
|
||||
* reports spend — so this is the absence of a fact, not a configurable rate.
|
||||
*/
|
||||
const NO_COST: ModelCost = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }
|
||||
|
||||
/**
|
||||
* Input modalities for a model the installed catalog does not describe. The
|
||||
* request converter keeps only text blocks, so text is the adapter's actual
|
||||
* capability rather than a deployment choice.
|
||||
*/
|
||||
const TEXT_ONLY: Model<Api>['input'] = ['text']
|
||||
|
||||
let providerIndex: Map<string, Provider> | undefined
|
||||
|
||||
/**
|
||||
* Installed catalog providers by id, constructed once. Each entry owns the API
|
||||
* implementations for its own models, which is why a catalog route reuses this
|
||||
* provider instead of being rebuilt from parts.
|
||||
* @returns the catalog provider index.
|
||||
*/
|
||||
function catalogProviders(): Map<string, Provider> {
|
||||
providerIndex ??= new Map(builtinProviders().map(provider => [provider.id, provider]))
|
||||
return providerIndex
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed catalog provider for one route, when pi-ai ships one.
|
||||
* @param provider - provider route key.
|
||||
* @returns the catalog provider, or `undefined` for a route pi-ai does not ship.
|
||||
*/
|
||||
export function catalogProvider(provider: string): Provider | undefined {
|
||||
return catalogProviders().get(provider)
|
||||
}
|
||||
|
||||
/**
|
||||
* Every provider route the installed pi-ai catalog ships.
|
||||
* @returns the catalog provider ids.
|
||||
*/
|
||||
export function catalogProviderIds(): readonly string[] {
|
||||
return getBuiltinProviders()
|
||||
}
|
||||
|
||||
/**
|
||||
* The installed catalog models for one route, indexed by model id.
|
||||
* @param provider - provider route key.
|
||||
* @returns catalog models by id; empty for a route pi-ai does not ship.
|
||||
*/
|
||||
export function catalogModels(provider: string): Map<string, Model<Api>> {
|
||||
if (!catalogProviders().has(provider)) return new Map()
|
||||
const models = getBuiltinModels(provider as BuiltinProvider) as Model<Api>[]
|
||||
return new Map(models.map(model => [model.id, model]))
|
||||
}
|
||||
|
||||
/** One configured model entry: an id plus the catalog fields it overrides. */
|
||||
export interface PiAiModelProfile {
|
||||
/** Model id sent to the provider and accepted by {@link GenerateOptions.model}. */
|
||||
id: string
|
||||
/** Display name for selectors; defaults to the catalog name, then the id. */
|
||||
name?: string
|
||||
/** Maximum combined request and response context in tokens. */
|
||||
contextWindow?: number
|
||||
/** Per-request output cap materialized when a caller omits one. */
|
||||
maxTokens?: number
|
||||
/** Whether the model exposes reasoning; defaults to the catalog capability. */
|
||||
reasoning?: boolean
|
||||
}
|
||||
|
||||
/** The route-level facts model materialization reads. */
|
||||
export interface RouteCatalogRequest {
|
||||
/** Provider route key, stamped onto every materialized model. */
|
||||
provider: string
|
||||
/** Wire protocol override; absent defers to each catalog model's own API. */
|
||||
api?: string
|
||||
/** Endpoint override; absent defers to the catalog model, then the catalog provider. */
|
||||
baseURL?: string
|
||||
/** Configured catalog; absent means the whole installed catalog for this route. */
|
||||
models?: readonly PiAiModelProfile[]
|
||||
}
|
||||
|
||||
/** Report a route the deployment cannot serve, naming the settings key at fault. */
|
||||
function invalid(provider: string, detail: string): never {
|
||||
throw new Error(`llm-pi-ai: provider "${provider}" ${detail}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* The one wire protocol a catalog route's shipped models agree on. This is what
|
||||
* lets a deployment add a model the installed catalog has not caught up with —
|
||||
* a provider's newest release — without restating the protocol its siblings
|
||||
* already use. A route whose shipped models disagree (an OpenAI-style catalog
|
||||
* spanning Responses and Chat Completions) has no such answer, so a model it
|
||||
* does not describe must name its protocol at the route.
|
||||
*/
|
||||
function sharedCatalogApi(defaults: ReadonlyMap<string, Model<Api>>): string | undefined {
|
||||
const apis = new Set<string>()
|
||||
for (const model of defaults.values()) apis.add(model.api)
|
||||
return apis.size === 1 ? [...apis][0] : undefined
|
||||
}
|
||||
|
||||
/**
|
||||
* Materialize one route's catalog by merging the installed catalog defaults
|
||||
* under the configured entries. A route with no configured `models` serves the
|
||||
* installed catalog unchanged, which is what keeps an existing
|
||||
* `providers: { deepseek: { apiKeyEnv: … } }` profile working untouched.
|
||||
* @param request - the route-level catalog facts.
|
||||
* @returns the materialized models in configuration order.
|
||||
*/
|
||||
export function resolveRouteModels(request: RouteCatalogRequest): readonly Model<Api>[] {
|
||||
const { provider } = request
|
||||
const defaults = catalogModels(provider)
|
||||
const providerBaseUrl = catalogProvider(provider)?.baseUrl
|
||||
// An absent `models` key and an empty one are the same request: the config
|
||||
// schema materializes `[]` for the absent case, and an empty catalog could
|
||||
// serve no request anyway, so both mean "serve the installed catalog".
|
||||
const configured = request.models ?? []
|
||||
const entries: readonly PiAiModelProfile[] = configured.length > 0
|
||||
? configured
|
||||
: [...defaults.values()].map(model => ({ id: model.id }))
|
||||
if (entries.length === 0) {
|
||||
invalid(provider, 'resolves no models; the installed catalog does not describe this route, so its models'
|
||||
+ ' must be listed in configuration')
|
||||
}
|
||||
const routeApi = sharedCatalogApi(defaults)
|
||||
const seen = new Set<string>()
|
||||
return entries.map((entry) => {
|
||||
if (entry.id.length === 0) invalid(provider, 'has a model with an empty id')
|
||||
if (seen.has(entry.id)) invalid(provider, `lists model "${entry.id}" more than once`)
|
||||
seen.add(entry.id)
|
||||
const base = defaults.get(entry.id)
|
||||
const api = request.api ?? base?.api ?? routeApi
|
||||
if (api === undefined) {
|
||||
invalid(provider, `model "${entry.id}" needs an api; the installed catalog does not describe it, so set the`
|
||||
+ ' route\'s api to the wire protocol its endpoint speaks')
|
||||
}
|
||||
const baseUrl = request.baseURL ?? base?.baseUrl ?? providerBaseUrl
|
||||
if (baseUrl === undefined) {
|
||||
invalid(provider, `model "${entry.id}" needs a baseURL; the installed catalog does not describe this route`)
|
||||
}
|
||||
const contextWindow = entry.contextWindow ?? base?.contextWindow
|
||||
if (contextWindow === undefined) {
|
||||
invalid(provider, `model "${entry.id}" needs a contextWindow; without it the harness cannot detect overflow`
|
||||
+ ' or size compaction')
|
||||
}
|
||||
if (!Number.isInteger(contextWindow) || contextWindow <= 0) {
|
||||
invalid(provider, `model "${entry.id}" contextWindow must be a positive integer`)
|
||||
}
|
||||
const maxTokens = entry.maxTokens ?? base?.maxTokens
|
||||
if (maxTokens === undefined) {
|
||||
invalid(provider, `model "${entry.id}" needs a maxTokens; it is the output cap materialized into requests`
|
||||
+ ' that omit one')
|
||||
}
|
||||
if (!Number.isInteger(maxTokens) || maxTokens <= 0) {
|
||||
invalid(provider, `model "${entry.id}" maxTokens must be a positive integer`)
|
||||
}
|
||||
return {
|
||||
id: entry.id,
|
||||
name: entry.name ?? base?.name ?? entry.id,
|
||||
api,
|
||||
provider,
|
||||
baseUrl,
|
||||
reasoning: entry.reasoning ?? base?.reasoning ?? false,
|
||||
input: base?.input ?? TEXT_ONLY,
|
||||
cost: base?.cost ?? NO_COST,
|
||||
contextWindow,
|
||||
maxTokens,
|
||||
// Catalog-only metadata: reasoning-level spellings and OpenAI-compatibility
|
||||
// quirks have no configuration surface, so they ride the catalog entry or
|
||||
// are absent for a model pi-ai has never described.
|
||||
...base?.thinkingLevelMap === undefined ? {} : { thinkingLevelMap: base.thinkingLevelMap },
|
||||
...base?.compat === undefined ? {} : { compat: base.compat },
|
||||
...base?.headers === undefined ? {} : { headers: base.headers },
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -3,29 +3,55 @@
|
||||
* Profiles are a dict keyed by provider route, so the composition base and a
|
||||
* user-settings layer merge per provider and the route set is structural.
|
||||
*
|
||||
* A route key is not required to name an installed pi-ai provider. When it does,
|
||||
* that provider's endpoint, protocol, display name, and model catalog are the
|
||||
* profile's defaults and the profile overrides them field by field; when it does
|
||||
* not, the profile is the whole provider declaration. Resolution therefore ends
|
||||
* in a built pi-ai `Provider` per route: everything a request needs is decided
|
||||
* once, while the configuration key that made a route unserviceable can still be
|
||||
* named in the failure.
|
||||
*
|
||||
* @module dsh-llm-pi-ai/config
|
||||
*/
|
||||
|
||||
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
|
||||
import type { CacheRetention, ModelThinkingLevel, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
|
||||
import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai'
|
||||
import z from 'schemastery'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import type { CredentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { resolveRouteModels } from './catalog.ts'
|
||||
import type { PiAiModelProfile } from './catalog.ts'
|
||||
import { buildProvider, supportedProtocols } from './provider.ts'
|
||||
|
||||
/** Default maximum idle interval while an adapter stream read is outstanding. */
|
||||
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
|
||||
|
||||
export type { PiAiModelProfile } from './catalog.ts'
|
||||
|
||||
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
|
||||
export interface PiAiProviderProfile {
|
||||
/** Literal provider credential; prefer {@link apiKeyEnv}. With both absent pi-ai uses its provider-native ambient discovery. */
|
||||
apiKey?: string
|
||||
/** Credential reference (environment-variable name) resolved per request through `ctx.credentials`. */
|
||||
apiKeyEnv?: string
|
||||
/** Override the selected catalog model's endpoint without changing its protocol metadata. */
|
||||
/** Name shown by configuration surfaces; defaults to the route key. */
|
||||
displayName?: string
|
||||
/**
|
||||
* Wire protocol every model on this route speaks. Omission keeps each
|
||||
* installed catalog model's own protocol, which is why a catalog route needs
|
||||
* no protocol at all; a route the catalog does not ship must name one.
|
||||
*/
|
||||
api?: string
|
||||
/** Endpoint for this route's models; defaults to the installed catalog's endpoint. */
|
||||
baseURL?: string
|
||||
/**
|
||||
* This route's model catalog. Omission serves the installed catalog for the
|
||||
* route unchanged; an explicit list replaces it, each entry defaulting its
|
||||
* unset fields from the installed model of the same id.
|
||||
*/
|
||||
models?: PiAiModelProfile[]
|
||||
/** Provider request headers; Harness attribution wins reserved names. */
|
||||
headers?: Record<string, string>
|
||||
/** Provider-neutral pi-ai reasoning level. */
|
||||
@@ -47,15 +73,25 @@ export interface PiAiProviderProfile {
|
||||
}
|
||||
|
||||
/** Validated profile with its route stamped and every adapter-owned default resolved. */
|
||||
export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, 'apiKeyEnv' | 'retryPolicy'> {
|
||||
/** pi-ai provider catalog name and Harness route key (the configuration dict key). */
|
||||
export interface ResolvedPiAiProviderProfile
|
||||
extends Omit<PiAiProviderProfile, 'apiKeyEnv' | 'retryPolicy' | 'models' | 'displayName'> {
|
||||
/** Harness route key and the `Models` collection key (the configuration dict key). */
|
||||
provider: string
|
||||
/** Resolved display name for selectors and configuration surfaces. */
|
||||
displayName: string
|
||||
/** Validated credential reference, when one is configured. */
|
||||
apiKeyEnv?: CredentialRef
|
||||
/** Positive finite provider-idle interval after defaulting. */
|
||||
streamIdleTimeoutMs: number
|
||||
/** Immutable retry policy captured with this provider route. */
|
||||
retryPolicy: ResolvedRetryPolicy
|
||||
/**
|
||||
* The pi-ai provider this route registers, built from the resolved models.
|
||||
* Construction happens here so an unserviceable protocol or an underspecified
|
||||
* model fails with the rest of resolution, leaving the last good route set
|
||||
* serving requests.
|
||||
*/
|
||||
piProvider: Provider
|
||||
}
|
||||
|
||||
/** Plugin configuration: the provider routes this instance owns. */
|
||||
@@ -75,10 +111,21 @@ const thinkingBudgets = z.object({
|
||||
high: z.number(),
|
||||
})
|
||||
|
||||
const modelProfile: z<PiAiModelProfile> = z.object({
|
||||
id: z.string().required(),
|
||||
name: z.string(),
|
||||
contextWindow: z.number().step(1).min(1),
|
||||
maxTokens: z.number().step(1).min(1),
|
||||
reasoning: z.boolean(),
|
||||
})
|
||||
|
||||
const profile = z.object({
|
||||
apiKey: z.string().role('secret'),
|
||||
apiKeyEnv: z.string().role('credential-ref'),
|
||||
displayName: z.string(),
|
||||
api: z.union(supportedProtocols()),
|
||||
baseURL: z.string(),
|
||||
models: z.array(modelProfile),
|
||||
headers: z.dict(z.string()),
|
||||
reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
|
||||
thinkingBudgets,
|
||||
@@ -95,11 +142,29 @@ export const Config: z<Config> = z.object({
|
||||
providers: z.dict(profile).default({}),
|
||||
})
|
||||
|
||||
/** Reject a pre-release profile shape, naming the replacement. */
|
||||
function rejectRemovedFields(provider: string, source: PiAiProviderProfile): void {
|
||||
const legacy = source as PiAiProviderProfile & {
|
||||
provider?: unknown
|
||||
maxRetries?: unknown
|
||||
maxRetryDelayMs?: unknown
|
||||
}
|
||||
if ('provider' in legacy) {
|
||||
throw new Error(`llm-pi-ai: provider "${provider}" sets "provider", which moved to the providers dict key`)
|
||||
}
|
||||
if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) {
|
||||
throw new Error(
|
||||
`llm-pi-ai: provider "${provider}" sets maxRetries or maxRetryDelayMs, which were removed;`
|
||||
+ ' compose agent recovery with dsh-llm-retry',
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate profiles against the installed pi-ai catalog and return a detached
|
||||
* route-keyed map suitable for per-request reads. This is the one explicit
|
||||
* resolve step, so an omitted dict resolves to the empty (dormant) route set
|
||||
* here rather than through a hidden fallback.
|
||||
* Validate profiles and return a detached route-keyed map suitable for
|
||||
* per-request reads. This is the one explicit resolve step, so an omitted dict
|
||||
* resolves to the empty (dormant) route set here rather than through a hidden
|
||||
* fallback, and each route's models and pi-ai provider are materialized once.
|
||||
* @param providers - configured provider profiles keyed by route.
|
||||
* @returns validated profiles in configuration order.
|
||||
*/
|
||||
@@ -110,28 +175,19 @@ export function resolveProfiles(
|
||||
throw new Error('llm-pi-ai: providers is now a dict keyed by provider route, not an array of profiles')
|
||||
}
|
||||
const entries = Object.entries(providers ?? {})
|
||||
const supported = new Set<string>(getBuiltinProviders())
|
||||
const resolved = new Map<string, ResolvedPiAiProviderProfile>()
|
||||
for (const [provider, source] of entries) {
|
||||
const legacy = source as PiAiProviderProfile & {
|
||||
provider?: unknown
|
||||
maxRetries?: unknown
|
||||
maxRetryDelayMs?: unknown
|
||||
}
|
||||
if ('provider' in legacy) {
|
||||
throw new Error('llm-pi-ai: the profile "provider" field moved to the providers dict key')
|
||||
}
|
||||
if ('maxRetries' in legacy || 'maxRetryDelayMs' in legacy) {
|
||||
throw new Error('llm-pi-ai: maxRetries and maxRetryDelayMs were removed; compose agent recovery with dsh-llm-retry')
|
||||
}
|
||||
rejectRemovedFields(provider, source)
|
||||
if (provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
|
||||
if (!supported.has(provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${provider}"`)
|
||||
if (source.apiKey !== undefined && source.apiKey.trim().length === 0) {
|
||||
throw new Error(`llm-pi-ai: provider "${provider}" has an empty apiKey; omit it to use ambient authentication`)
|
||||
}
|
||||
if (source.baseURL !== undefined && source.baseURL.length === 0) {
|
||||
throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
|
||||
}
|
||||
if (source.displayName !== undefined && source.displayName.length === 0) {
|
||||
throw new Error(`llm-pi-ai: provider "${provider}" has an empty displayName`)
|
||||
}
|
||||
const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
|
||||
if (!Number.isFinite(streamIdleTimeoutMs)
|
||||
|| streamIdleTimeoutMs <= 0
|
||||
@@ -140,15 +196,33 @@ export function resolveProfiles(
|
||||
`llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
|
||||
)
|
||||
}
|
||||
const { apiKeyEnv, retryPolicy, ...rest } = source
|
||||
// The route key, not the installed provider's own name: the directory has
|
||||
// always shown route keys, and a catalog route must not silently rename
|
||||
// itself on every configuration surface just because it gained a profile.
|
||||
const displayName = source.displayName ?? provider
|
||||
const models = resolveRouteModels({
|
||||
provider,
|
||||
...source.api === undefined ? {} : { api: source.api },
|
||||
...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
|
||||
...source.models === undefined ? {} : { models: source.models },
|
||||
})
|
||||
const { apiKeyEnv, retryPolicy, models: _models, displayName: _displayName, ...rest } = source
|
||||
resolved.set(provider, {
|
||||
...rest,
|
||||
provider,
|
||||
displayName,
|
||||
...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
|
||||
streamIdleTimeoutMs,
|
||||
retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
|
||||
...rest.headers === undefined ? {} : { headers: { ...rest.headers } },
|
||||
...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
|
||||
piProvider: buildProvider({
|
||||
provider,
|
||||
displayName,
|
||||
...source.api === undefined ? {} : { api: source.api },
|
||||
...source.baseURL === undefined ? {} : { baseURL: source.baseURL },
|
||||
models,
|
||||
}),
|
||||
})
|
||||
}
|
||||
return resolved
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
/**
|
||||
* Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of
|
||||
* provider routes; requests select a profile by provider and resolve the
|
||||
* model dynamically from pi-ai's installed catalog. Profile facts resolve per
|
||||
* request over the optional `llm-pi-ai` user-settings section and the
|
||||
* optional credential seam, so a changed key, endpoint, or knob reaches the
|
||||
* next request without a restart; a changed *route set* (or a route's
|
||||
* provider routes; a route naming an installed pi-ai provider inherits that
|
||||
* provider's endpoint, protocol, and model catalog as defaults, and a route
|
||||
* pi-ai does not ship is declared outright. Profile facts resolve per request
|
||||
* over the optional `llm-pi-ai` user-settings section and the optional
|
||||
* credential seam, so a changed key, endpoint, model, or knob reaches the next
|
||||
* request without a restart; a changed *route set* (or a route's
|
||||
* registration-captured retry policy) re-registers the same adapter instance
|
||||
* in place.
|
||||
*
|
||||
@@ -13,34 +14,48 @@
|
||||
* name: '@deepseek-ai/dsh-llm-pi-ai'
|
||||
* config:
|
||||
* providers:
|
||||
* # Catalog route: everything but the credential comes from pi-ai.
|
||||
* openai:
|
||||
* apiKeyEnv: OPENAI_API_KEY
|
||||
* retryPolicy:
|
||||
* mode: normal
|
||||
* maxRetries: 2
|
||||
* # Catalog route with the catalog narrowed and one capacity corrected.
|
||||
* anthropic:
|
||||
* apiKeyEnv: ANTHROPIC_API_KEY
|
||||
* openrouter:
|
||||
* apiKeyEnv: OPENROUTER_API_KEY
|
||||
* baseURL: https://proxy.example.com/v1
|
||||
* models:
|
||||
* - id: claude-sonnet-4-5
|
||||
* contextWindow: 200000
|
||||
* # Hand-declared route: pi-ai ships nothing under this key.
|
||||
* acme-gateway:
|
||||
* displayName: Acme Gateway
|
||||
* apiKeyEnv: ACME_GATEWAY_API_KEY
|
||||
* api: openai-completions
|
||||
* baseURL: https://gateway.acme.example/v1
|
||||
* models:
|
||||
* - id: acme-large
|
||||
* name: Acme Large
|
||||
* contextWindow: 65536
|
||||
* maxTokens: 4096
|
||||
* ```
|
||||
*
|
||||
* @module @deepseek-ai/dsh-llm-pi-ai
|
||||
*/
|
||||
|
||||
import type { Context } from 'cordis'
|
||||
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
|
||||
import { LlmError } from '@deepseek-ai/dsh-llm'
|
||||
import type { AdapterRegistrationHandle } from '@deepseek-ai/dsh-llm'
|
||||
import type { AdapterRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
|
||||
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { PiAiAdapter } from './adapter.ts'
|
||||
import { catalogProviderIds } from './catalog.ts'
|
||||
import { Config, resolveProfiles } from './config.ts'
|
||||
import type { ResolvedPiAiProviderProfile } from './config.ts'
|
||||
|
||||
export { PiAiAdapter } from './adapter.ts'
|
||||
export type { PiAiAdapterOptions } from './adapter.ts'
|
||||
export { Config } from './config.ts'
|
||||
export type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
|
||||
export type { PiAiModelProfile, PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
|
||||
export { supportedProtocols } from './provider.ts'
|
||||
|
||||
export const name = 'llm-pi-ai'
|
||||
export const inject = ['llm']
|
||||
@@ -58,6 +73,26 @@ function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderPro
|
||||
.sort((left, right) => left.provider.localeCompare(right.provider))
|
||||
}
|
||||
|
||||
/**
|
||||
* The configurable-provider directory: every installed catalog route, plus
|
||||
* every route the current profiles declare. A hand-declared route has no
|
||||
* catalog entry, so without this union it would have no settings address and
|
||||
* configuration surfaces could neither show nor edit it.
|
||||
* @param profiles - the currently resolved provider profiles.
|
||||
* @returns the directory entries in catalog order, declared routes last.
|
||||
*/
|
||||
function directoryEntries(
|
||||
profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>,
|
||||
): LlmConfigurableProvider[] {
|
||||
const entries = new Map<string, LlmConfigurableProvider>()
|
||||
const declare = (provider: string, displayName: string): void => {
|
||||
entries.set(provider, { provider, displayName, settingsNs: NS, settingsPath: ['providers', provider] })
|
||||
}
|
||||
for (const provider of catalogProviderIds()) declare(provider, provider)
|
||||
for (const [provider, profile] of profiles) declare(provider, profile.displayName)
|
||||
return [...entries.values()]
|
||||
}
|
||||
|
||||
/** Register one generic pi-ai adapter for all configured provider routes. */
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
let current: () => Config = () => config
|
||||
@@ -114,13 +149,18 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const adapter = new PiAiAdapter({ profiles, resolveApiKey })
|
||||
// The full installed catalog is configurable from the moment the plugin
|
||||
// mounts — dormant or not — so configuration surfaces can offer every
|
||||
// pi-ai provider before any route exists.
|
||||
ctx.llm.registerConfigurableProviders(getBuiltinProviders().map(provider => ({
|
||||
provider,
|
||||
displayName: provider,
|
||||
settingsNs: NS,
|
||||
settingsPath: ['providers', provider],
|
||||
})))
|
||||
// pi-ai provider before any route exists. Hand-declared routes join it as
|
||||
// profiles appear, and leave with them.
|
||||
let directory: (() => void) | undefined
|
||||
let directoryFacts: unknown
|
||||
const ensureDirectory = (): void => {
|
||||
const entries = directoryEntries(profiles())
|
||||
if (deepEqualJson(entries, directoryFacts)) return
|
||||
directory?.()
|
||||
directory = ctx.llm.registerConfigurableProviders(entries)
|
||||
directoryFacts = entries
|
||||
}
|
||||
ensureDirectory()
|
||||
// Route effects bind to this apply fiber via the stable `ctx` reference,
|
||||
// even when a swap runs inside the scoped settings callback below. A bare
|
||||
// mount (zero routes) is the dormant posture: nothing registers until a
|
||||
@@ -156,6 +196,11 @@ export function apply(ctx: Context, config: Config): void {
|
||||
setSource: (source) => {
|
||||
current = source
|
||||
},
|
||||
onChange: ensureRegistrationFacts,
|
||||
onChange: () => {
|
||||
ensureRegistrationFacts()
|
||||
// The directory follows the profiles the registry accepted, so a route
|
||||
// that failed to register is not advertised as configurable.
|
||||
ensureDirectory()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
155
packages/llm/llm-pi-ai/src/provider.ts
Normal file
155
packages/llm/llm-pi-ai/src/provider.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
/**
|
||||
* 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 { azureOpenAIResponsesApi } from '@earendil-works/pi-ai/api/azure-openai-responses.lazy'
|
||||
import { bedrockConverseStreamApi } from '@earendil-works/pi-ai/api/bedrock-converse-stream.lazy'
|
||||
import { googleGenerativeAIApi } from '@earendil-works/pi-ai/api/google-generative-ai.lazy'
|
||||
import { googleVertexApi } from '@earendil-works/pi-ai/api/google-vertex.lazy'
|
||||
import { mistralConversationsApi } from '@earendil-works/pi-ai/api/mistral-conversations.lazy'
|
||||
import { openAICodexResponsesApi } from '@earendil-works/pi-ai/api/openai-codex-responses.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. The table is pi-ai's own streaming API set: 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.
|
||||
*/
|
||||
const PROTOCOLS: Readonly<Record<string, () => ProviderStreams>> = {
|
||||
'anthropic-messages': anthropicMessagesApi,
|
||||
'azure-openai-responses': azureOpenAIResponsesApi,
|
||||
'bedrock-converse-stream': bedrockConverseStreamApi,
|
||||
'google-generative-ai': googleGenerativeAIApi,
|
||||
'google-vertex': googleVertexApi,
|
||||
'mistral-conversations': mistralConversationsApi,
|
||||
'openai-codex-responses': openAICodexResponsesApi,
|
||||
'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(),
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user