返回源码地图

packages/llm/llm-pi-ai/src/index.ts

main snapshot · da00f7f5358f · 正文引用章节 09;完整原文可核对,不声称全文件人工逐行审计

完整原文供逐行核对;页面收录不代表每行都经过人工语义审核。MIT 许可见 许可证。

1/**
2 * Generic pi-ai-backed LLM adapter plugin. One plugin instance owns a dict of
3 * provider routes; a route naming an installed pi-ai provider inherits that
4 * provider's endpoint, protocol, and model catalog as defaults, and a route
5 * pi-ai does not ship is declared outright. Profile facts resolve per request
6 * over the optional `llm-pi-ai` user-settings section and the optional
7 * credential seam, so a changed key, endpoint, model, or knob reaches the next
8 * request without a restart; a changed *route set* (or a route's
9 * registration-captured retry policy) re-registers the same adapter instance
10 * in place.
11 *
12 * ```yaml
13 * - id: llm
14 * 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_KEY
20 * retryPolicy:
21 * mode: normal
22 * maxRetries: 2
23 * # Catalog route with the catalog narrowed and one capacity corrected.
24 * anthropic:
25 * apiKeyEnv: ANTHROPIC_API_KEY
26 * models:
27 * - id: claude-sonnet-4-5
28 * contextWindow: 200000
29 * # Hand-declared route: pi-ai ships nothing under this key.
30 * acme-gateway:
31 * displayName: Acme Gateway
32 * apiKeyEnv: ACME_GATEWAY_API_KEY
33 * api: openai-completions
34 * baseURL: https://gateway.acme.example/v1
35 * # Reasoning dialect for a URL pi-ai cannot recognize.
36 * compat:
37 * thinkingFormat: deepseek
38 * models:
39 * - id: acme-large
40 * name: Acme Large
41 * contextWindow: 65536
42 * maxTokens: 4096
43 * - id: acme-think
44 * name: Acme Think
45 * contextWindow: 262144
46 * maxTokens: 32768
47 * # key = selectable level, value = wire spelling; only off may
48 * # leave the value empty (supported, send nothing).
49 * reasoningEfforts:
50 * off:
51 * high: high
52 * max: ultra
53 * ```
54 *
55 * @module @deepseek-ai/dsh-llm-pi-ai
56 */
57import type {} from '@deepseek-ai/dsh-settings'
58
59import type {} from '@deepseek-ai/cordis-plugin-loader'
60
61import type { Context } from '@deepseek-ai/cordis'
62import { launchEnvironmentOf } from '@deepseek-ai/dsh-launch-environment'
63import { assertUsableApiKey, LlmError, resolveImageAttachmentAccess } from '@deepseek-ai/dsh-llm'
64import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm'
65import type {} from '@deepseek-ai/dsh-fs'
66import { deepEqualJson } from '@deepseek-ai/dsh-util-values'
67import { PiAiAdapter } from './adapter.ts'
68import { authContextFrom, credentialStoreFrom } from './auth.ts'
69import { catalogProviderIds } from './catalog.ts'
70import { assertServiceable, Config, resolveProfiles } from './config.ts'
71import type { ResolvedPiAiProviderProfile } from './config.ts'
72import { discoverModels } from './discovery.ts'
73import type { StoredModelDiscoveryProfile } from './discovery.ts'
74import { registerPiAiFlows } from './login.ts'
75
76export { PiAiAdapter } from './adapter.ts'
77export type { PiAiAdapterOptions } from './adapter.ts'
78export { Config } from './config.ts'
79export type {
80 Options,
81 PiAiCompatProfile,
82 PiAiModality,
83 PiAiModelOverride,
84 PiAiModelProfile,
85 PiAiProviderProfile,
86 PiAiReasoningEfforts,
87 PiAiThinkingFormat,
88 ResolvedPiAiProviderProfile,
89} from './config.ts'
90export { recordKeyFor } from './auth.ts'
91export { supportedProtocols } from './provider.ts'
92
93export const name = 'llm-pi-ai'
94export const inject = ['llm']
95
96const NS = 'llm-pi-ai'
97
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 is
101 * not mistaken for a route change.
102 */
103function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
104 return [...profiles.entries()]
105 // `displayName` rides along because the registry hands it to every selector
106 // through `providerInfo()`: a rename that did not re-register would leave
107 // 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}
115
116/**
117 * The configurable-provider directory: every installed catalog route, plus
118 * every route the current profiles declare. A hand-declared route has no
119 * catalog entry, so without this union it would have no settings address and
120 * 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 */
124function 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 that
138 // 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}
147
148/** Register one generic pi-ai adapter for all configured provider routes. */
149export 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 ?? NS
152 let lastRaw: ReturnType<Config['providers']['get']> | undefined
153 let memoized: ReadonlyMap<string, ResolvedPiAiProviderProfile> | undefined
154 /**
155 * The resolved profiles for the current configuration, memoized by the raw
156 * snapshot's identity — which is also what makes the adapter's own snapshot
157 * stable across operations that observe no change.
158 *
159 * Catalog diagnostics stay in the snapshot beside serviceable models, so
160 * 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 memoized
166 const next = resolveProfiles(structuredClone(raw) as import('./config.ts').Options['providers'], 'deferred')
167 lastRaw = raw
168 memoized = next
169 return next
170 }
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 raw
175 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 raw
181 })
182
183 const resolveApiKey = async (
184 provider: string,
185 profile: ResolvedPiAiProviderProfile,
186 ): Promise<string | undefined> => {
187 const ref = profile.apiKeyEnv
188 // Only a profile that names no credential at all defers to pi-ai's
189 // 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 key
191 // (OPENAI_API_KEY and friends), billing another tenant for a request the
192 // deployment meant to authenticate differently.
193 if (ref === undefined) return undefined
194 const credentials = ctx.get('credentials')
195 const hit = credentials !== undefined
196 ? (await credentials.resolve(ref))?.value
197 // Without the seam the environment is the whole credential plane.
198 : launchEnvironmentOf(ctx).get(ref)?.value
199 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 }
207
208 // One store and one ambient context for the whole plugin instance: both read
209 // through `ctx` per call, so they stay correct across the collection rebuilds
210 // 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 worth
230 // adding, so the flows are offered before any profile names their provider.
231 // Scoped to the authorization seam rather than injected outright, because a
232 // composition without it (headless, ACP) simply has no surface to sign in
233 // 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 plugin
236 // mounts — dormant or not — so configuration surfaces can offer every
237 // pi-ai provider before any route exists. Hand-declared routes join it as
238 // profiles appear, and leave with them.
239 let directory: DirectoryRegistrationHandle | undefined
240 let directoryFacts: unknown
241 const ensureDirectory = (): void => {
242 const entries = directoryEntries(profiles(), settingsNs)
243 if (deepEqualJson(entries, directoryFacts)) return
244 // Atomic replace, never dispose-then-register: a route another adapter
245 // family already declares (a profile keyed `deepseek-official`) would
246 // otherwise leave this plugin's whole directory withdrawn and the Models
247 // page empty. The candidate set is validated first, so a collision keeps
248 // 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 = entries
255 }
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 undefined
262 const profile = profiles().get(provider)
263 if (profile === undefined) return undefined
264 return {
265 headers: profile.headers,
266 resolveApiKey: () => resolveApiKey(provider, profile),
267 }
268 }
269 // Interrogating an endpoint is a configuration-time action over a draft, so
270 // it is offered for the whole namespace rather than per route: the provider
271 // a surface is adding does not exist yet. The draft is the whole request
272 // except the stored credential and deployment-owned headers: the curated UI
273 // accepts neither, so an already-configured route supplies both inside the
274 // 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 bare
281 // mount (zero routes) is the dormant posture: nothing registers until a
282 // settings section supplies profiles, and routes drop when it empties.
283 let registration: AdapterRegistrationHandle | undefined
284 let registeredFacts: unknown
285 const ensureRegistrationFacts = (): void => {
286 const facts = registrationFacts(profiles())
287 if (deepEqualJson(facts, registeredFacts)) return
288 // The registry captures the route set and each route's retry policy at
289 // registration, so a change to either must re-register. The swap is
290 // atomic (same adapter instance, validated before anything moves): a
291 // conflicting route leaves the previous routes serving requests, and
292 // `registeredFacts` only advances once the registry actually holds the
293 // 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 supplies
297 // profiles, and an empty section keeps it that way.
298 if (routes.length === 0) {
299 registeredFacts = facts
300 return
301 }
302 registration = ctx.llm.registerAdapter(routes, adapter)
303 } else {
304 registration.replace(routes)
305 }
306 registeredFacts = facts
307 }
308 ensureRegistrationFacts()
309
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}