1
/**2
* Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of3
* provider routes; a route naming an installed pi-ai provider inherits that4
* provider's endpoint, protocol, and model catalog as defaults, and a route5
* pi-ai does not ship is declared outright. Profile facts resolve per request6
* over the optional `llm-pi-ai` user-settings section and the optional7
* credential seam, so a changed key, endpoint, model, or knob reaches the next8
* request without a restart; a changed *route set* (or a route's9
* registration-captured retry policy) re-registers the same adapter instance10
* in place.11
*12
* ```yaml13
* - id: llm14
* name: '@deepseek-ai/dsh-llm-pi-ai'15
* config:16
* providers:17
* # Catalog route: everything but the credential comes from pi-ai.18
* openai:19
* apiKeyEnv: OPENAI_API_KEY20
* retryPolicy:21
* mode: normal22
* maxRetries: 223
* # Catalog route with the catalog narrowed and one capacity corrected.24
* anthropic:25
* apiKeyEnv: ANTHROPIC_API_KEY26
* models:27
* - id: claude-sonnet-4-528
* contextWindow: 20000029
* # Hand-declared route: pi-ai ships nothing under this key.30
* acme-gateway:31
* displayName: Acme Gateway32
* apiKeyEnv: ACME_GATEWAY_API_KEY33
* api: openai-completions34
* baseURL: https://gateway.acme.example/v135
* # Reasoning dialect for a URL pi-ai cannot recognize.36
* compat:37
* thinkingFormat: deepseek38
* models:39
* - id: acme-large40
* name: Acme Large41
* contextWindow: 6553642
* maxTokens: 409643
* - id: acme-think44
* name: Acme Think45
* contextWindow: 26214446
* maxTokens: 3276847
* # key = selectable level, value = wire spelling; only off may48
* # leave the value empty (supported, send nothing).49
* reasoningEfforts:50
* off:51
* high: high52
* max: ultra53
* ```54
*55
* @module @deepseek-ai/dsh-llm-pi-ai56
*/57
import type {} from '@deepseek-ai/dsh-settings'59
import type {} from '@deepseek-ai/cordis-plugin-loader'61
import type { Context } from '@deepseek-ai/cordis'62
import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'63
import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm'64
import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'65
import type {} from '@deepseek-ai/dsh-fs'66
import { deepEqualJson } from '@deepseek-ai/dsh-util-values'67
import { PiAiAdapter } from './adapter.ts'68
import { authContextFrom, credentialStoreFrom } from './auth.ts'69
import { catalogProviderIds } from './catalog.ts'70
import { assertServiceable, Config, resolveProfiles } from './config.ts'71
import type { ResolvedPiAiProviderProfile } from './config.ts'72
import { discoverModels } from './discovery.ts'73
import type { StoredModelDiscoveryProfile } from './discovery.ts'74
import { registerPiAiFlows } from './login.ts'76
export { PiAiAdapter } from './adapter.ts'77
export type { PiAiAdapterOptions } from './adapter.ts'78
export { Config } from './config.ts'79
export type {80
Options,81
PiAiCompatProfile,82
PiAiModality,83
PiAiModelOverride,84
PiAiModelProfile,85
PiAiProviderProfile,86
PiAiReasoningEfforts,87
PiAiThinkingFormat,88
ResolvedPiAiProviderProfile,89
} from './config.ts'90
export { recordKeyFor } from './auth.ts'91
export { supportedProtocols } from './provider.ts'93
export const name = 'llm-pi-ai'94
export const inject = ['llm']96
const NS = 'llm-pi-ai'98
/**99
* The registry captures these per route; a change here must re-register.100
* Sorted by provider so a settings document that merely reorders its keys is101
* not mistaken for a route change.102
*/103
function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {104
return [...profiles.entries()]105
// `displayName` rides along because the registry hands it to every selector106
// through `providerInfo()`: a rename that did not re-register would leave107
// the old label showing until some unrelated fact happened to change.108
.map(([provider, profile]) => ({109
provider,110
displayName: profile.displayName,111
retryPolicy: profile.retryPolicy,112
}))113
.sort((left, right) => left.provider.localeCompare(right.provider))114
}116
/**117
* The configurable-provider directory: every installed catalog route, plus118
* every route the current profiles declare. A hand-declared route has no119
* catalog entry, so without this union it would have no settings address and120
* configuration surfaces could neither show nor edit it.121
* @param profiles - the currently resolved provider profiles.122
* @returns the directory entries in catalog order, declared routes last.123
*/124
function directoryEntries(125
profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>,126
settingsNs: string,127
): LlmConfigurableProvider[] {128
const catalog = new Set(catalogProviderIds())129
const entries = new Map<string, LlmConfigurableProvider>()130
const declare = (provider: string, displayName: string, error?: string): void => {131
entries.set(provider, {132
provider,133
displayName,134
settingsNs,135
settingsPath: ['providers', provider],136
// Membership of the installed catalog, not of the settings document:137
// narrowing a shipped provider's models stores a profile too, and that138
// route is still one pi-ai knows.139
declared: !catalog.has(provider),140
...error === undefined ? {} : { error },141
})142
}143
for (const provider of catalog) declare(provider, provider)144
for (const [provider, profile] of profiles) declare(provider, profile.displayName, profile.catalogError)145
return [...entries.values()]146
}148
/** Register one generic pi-ai adapter for all configured provider routes. */149
export function apply(ctx: Context, config: Config): void {150
ctx.inject(['settings'], (child) => { child.effect(() => child.settings.configure({ auto: false }, ctx.fiber)) })151
const settingsNs = ctx.fiber.entry?.options.id ?? NS152
let lastRaw: ReturnType<Config['providers']['get']> | undefined153
let memoized: ReadonlyMap<string, ResolvedPiAiProviderProfile> | undefined154
/**155
* The resolved profiles for the current configuration, memoized by the raw156
* snapshot's identity — which is also what makes the adapter's own snapshot157
* stable across operations that observe no change.158
*159
* Catalog diagnostics stay in the snapshot beside serviceable models, so160
* stored configuration remains visible after an installed catalog changes.161
* Scalar configuration errors still reject resolution.162
*/163
const profiles = (): ReadonlyMap<string, ResolvedPiAiProviderProfile> => {164
const raw = config.providers.get()165
if (raw === lastRaw && memoized !== undefined) return memoized166
const next = resolveProfiles(structuredClone(raw) as import('./config.ts').Options['providers'], 'deferred')167
lastRaw = raw168
memoized = next169
return next170
}171
profiles()172
ctx.on('internal/config', function (this: import('@deepseek-ai/cordis').Fiber, _raw, next) {173
const raw: unknown = next()174
if (this !== ctx.fiber) return raw175
const candidate = Config(raw as import('./config.ts').Options)176
assertServiceable(177
{ providers: structuredClone(candidate.providers.get()) } as import('./config.ts').Options,178
{ providers: structuredClone(config.providers.get()) } as import('./config.ts').Options,179
)180
return raw181
})183
const resolveApiKey = async (184
provider: string,185
profile: ResolvedPiAiProviderProfile,186
): Promise<string | undefined> => {187
const ref = profile.apiKeyEnv188
// Only a profile that names no credential at all defers to pi-ai's189
// provider-native discovery. Once one is named, a miss must fail loud:190
// handing pi-ai `undefined` would let it pick up an unrelated ambient key191
// (OPENAI_API_KEY and friends), billing another tenant for a request the192
// deployment meant to authenticate differently.193
if (ref === undefined) return undefined194
const credentials = ctx.get('credentials')195
const hit = credentials !== undefined196
? (await credentials.resolve(ref))?.value197
// Without the seam the environment is the whole credential plane.198
: launchEnvironmentOf(ctx).get(ref)?.value199
if (hit !== undefined && hit.length > 0) return assertUsableApiKey(hit, 'llm-pi-ai', ref)200
throw new LlmError(201
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`202
+ ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,`203
+ ' and remove apiKeyEnv only if this provider should authenticate from pi-ai\'s own environment discovery',204
'MISSING_CREDENTIAL',205
)206
}208
// One store and one ambient context for the whole plugin instance: both read209
// through `ctx` per call, so they stay correct across the collection rebuilds210
// a configuration change causes, and a sign-in survives one.211
const auth = { credentials: credentialStoreFrom(ctx), authContext: authContextFrom(ctx) }212
const adapter = new PiAiAdapter({213
profiles,214
resolveApiKey,215
auth,216
resolveAttachments: () => ctx.get('attachments'),217
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(218
attachments,219
hostPath => ctx.get('fs')?.processPathFromHostPath(hostPath),220
ref,221
),222
onReplayDegrade: ({ provider, model, reason }) => {223
ctx.logger.warn(224
`llm-pi-ai: unusable replay state on assistant history for route "${provider}/${model}";`225
+ ` sending that message as provider-neutral content (${reason})`,226
)227
},228
})229
// Independent of the route set: signing in is what makes a route worth230
// adding, so the flows are offered before any profile names their provider.231
// Scoped to the authorization seam rather than injected outright, because a232
// composition without it (headless, ACP) simply has no surface to sign in233
// from, while everything else this plugin does still works.234
ctx.inject(['authorization'], (authorized) => { registerPiAiFlows(authorized, auth) })235
// The full installed catalog is configurable from the moment the plugin236
// mounts — dormant or not — so configuration surfaces can offer every237
// pi-ai provider before any route exists. Hand-declared routes join it as238
// profiles appear, and leave with them.239
let directory: DirectoryRegistrationHandle | undefined240
let directoryFacts: unknown241
const ensureDirectory = (): void => {242
const entries = directoryEntries(profiles(), settingsNs)243
if (deepEqualJson(entries, directoryFacts)) return244
// Atomic replace, never dispose-then-register: a route another adapter245
// family already declares (a profile keyed `deepseek-official`) would246
// otherwise leave this plugin's whole directory withdrawn and the Models247
// page empty. The candidate set is validated first, so a collision keeps248
// the previous entries serving and only costs a diagnostic.249
if (directory === undefined) {250
directory = ctx.llm.registerConfigurableProviders(entries)251
} else {252
directory.replace(entries)253
}254
directoryFacts = entries255
}256
ensureDirectory()257
/** Host-owned request inputs for discovery of one configured route. */258
const storedDiscoveryProfile = (259
provider: string | undefined,260
): StoredModelDiscoveryProfile | undefined => {261
if (provider === undefined) return undefined262
const profile = profiles().get(provider)263
if (profile === undefined) return undefined264
return {265
headers: profile.headers,266
resolveApiKey: () => resolveApiKey(provider, profile),267
}268
}269
// Interrogating an endpoint is a configuration-time action over a draft, so270
// it is offered for the whole namespace rather than per route: the provider271
// a surface is adding does not exist yet. The draft is the whole request272
// except the stored credential and deployment-owned headers: the curated UI273
// accepts neither, so an already-configured route supplies both inside the274
// Host rather than widening the discovery request.275
ctx.llm.registerModelDiscovery(settingsNs, (request, signal) => discoverModels(276
{ ...request, ...signal === undefined ? {} : { signal } },277
() => storedDiscoveryProfile(request.provider),278
))279
// Route effects bind to this apply fiber via the stable `ctx` reference,280
// even when a swap runs inside the scoped settings callback below. A bare281
// mount (zero routes) is the dormant posture: nothing registers until a282
// settings section supplies profiles, and routes drop when it empties.283
let registration: AdapterRegistrationHandle | undefined284
let registeredFacts: unknown285
const ensureRegistrationFacts = (): void => {286
const facts = registrationFacts(profiles())287
if (deepEqualJson(facts, registeredFacts)) return288
// The registry captures the route set and each route's retry policy at289
// registration, so a change to either must re-register. The swap is290
// atomic (same adapter instance, validated before anything moves): a291
// conflicting route leaves the previous routes serving requests, and292
// `registeredFacts` only advances once the registry actually holds the293
// new set — so returning to a working configuration always re-applies.294
const routes = [...profiles().keys()]295
if (registration === undefined) {296
// Dormant bare mount: nothing is registered until a section supplies297
// profiles, and an empty section keeps it that way.298
if (routes.length === 0) {299
registeredFacts = facts300
return301
}302
registration = ctx.llm.registerAdapter(routes, adapter)303
} else {304
registration.replace(routes)305
}306
registeredFacts = facts307
}308
ensureRegistrationFacts()310
ctx.on('loader/volatile-update', () => {311
try { ensureRegistrationFacts(); ensureDirectory() }312
catch (error) {313
ctx.logger.error('llm-pi-ai: configuration conflicts with an existing provider route')314
ctx.logger.error(error)315
}316
})317
}