Files
deepseek-harness/packages/llm/llm-pi-ai/src/index.ts
T
Yichen Jiang 590b76a7f0 fix(config): close the review findings on configuration source ownership
Two had real security consequences:

The bootstrap rejection ran on npm dotenv's parser while process.loadEnvFile
applied the file with Node's own. Two independently maintained dialects meant
the check and the thing it guards could disagree: a name Node accepts but the
checker misses would reach process.env unchecked, and BASH_ENV there runs a
file of the project's choosing on every `bash -c` the bash tool issues. Parse
once with node:util's parseEnv — the same engine loadEnvFile uses — and assign
the entries already checked, which also drops the dotenv dependency.

llm-pi-ai still returned a literal profile.apiKey ahead of everything, and it
registers a settings namespace, so the defect removed from llm-deepseek
survived intact in its design twin. The field is gone from the profile schema,
the resolution path, and the tests.

The rest are consistency and documentation defects the review named:

- verify-config-source-ownership did not scan the Python runtime's bundled
  cordis.yml, which still inlined apiKey and baseURL. Both are covered now, and
  the line-anchored INLINE_DENY documents that it is a tripwire, not a parser.
- The deny list missed NODE_TLS_REJECT_UNAUTHORIZED, the askpass hooks, the
  GIT_CONFIG_* redirections, and PYTHONHOME — all implied by its own stated
  rule about what a variable does.
- Snapshot lookups folded case on Windows, where environment names are
  case-insensitive and an exact-match Map could miss a higher-ranked layer.
- The credentials note claimed a read-time permission check was "not taken"
  while this PR implemented it; the credentials-local README still described
  two layers, live process.env reads, dotenv-era limitations, and a renamed
  anchor; the llm-deepseek README still advertised the removed literal apiKey;
  and web.ts and base.cordis.yml kept personal-overlay wording.
- The ownership note's literal-apiKey claim now names its scope: the
  web-search providers keep a literal field but register no settings
  namespace, so nothing can shadow a stored credential through them.
2026-08-05 11:18:06 +08:00

161 lines
6.7 KiB
TypeScript

/**
* 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
* registration-captured retry policy) re-registers the same adapter instance
* in place.
*
* ```yaml
* - id: llm
* name: '@deepseek-ai/dsh-llm-pi-ai'
* config:
* providers:
* openai:
* apiKeyEnv: OPENAI_API_KEY
* retryPolicy:
* mode: normal
* maxRetries: 2
* anthropic:
* apiKeyEnv: ANTHROPIC_API_KEY
* openrouter:
* apiKeyEnv: OPENROUTER_API_KEY
* baseURL: https://proxy.example.com/v1
* ```
*
* @module @deepseek-ai/dsh-llm-pi-ai
*/
import type { Context } from 'cordis'
import { environmentOf } from '@deepseek-ai/dsh-environment'
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 { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
import { PiAiAdapter } from './adapter.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 const name = 'llm-pi-ai'
export const inject = ['llm']
const NS = settingsNamespace('llm-pi-ai')
/**
* The registry captures these per route; a change here must re-register.
* Sorted by provider so a settings document that merely reorders its keys is
* not mistaken for a route change.
*/
function registrationFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
return [...profiles.entries()]
.map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy }))
.sort((left, right) => left.provider.localeCompare(right.provider))
}
/** Register one generic pi-ai adapter for all configured provider routes. */
export function apply(ctx: Context, config: Config): void {
let current: () => Config = () => config
let lastRaw: Config | undefined
let lastGood: ReadonlyMap<string, ResolvedPiAiProviderProfile> | undefined
const profiles = (): ReadonlyMap<string, ResolvedPiAiProviderProfile> => {
const raw = current()
if (raw === lastRaw && lastGood !== undefined) return lastGood
try {
const next = resolveProfiles(raw.providers)
lastRaw = raw
lastGood = next
return next
} catch (error) {
// Static composition resolves before anything registers, so this branch
// only sees a live settings snapshot failing catalog or bound checks:
// keep serving the last good profiles and say so once per bad snapshot.
if (lastGood === undefined) throw error
lastRaw = raw
ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section')
ctx.logger.error(error)
return lastGood
}
}
profiles()
const resolveApiKey = async (
provider: string,
profile: ResolvedPiAiProviderProfile,
): Promise<string | undefined> => {
const ref = profile.apiKeyEnv
// Only a profile that names no credential at all defers to pi-ai's
// provider-native discovery. Once one is named, a miss must fail loud:
// handing pi-ai `undefined` would let it pick up an unrelated ambient key
// (OPENAI_API_KEY and friends), billing another tenant for a request the
// deployment meant to authenticate differently.
if (ref === undefined) return undefined
const credentials = ctx.get('credentials')
const hit = credentials !== undefined
? (await credentials.resolve(ref))?.value
// Without the seam the environment is the whole credential plane.
: environmentOf(ctx).getFrom(ref, ['process', 'project-env', 'user-env'])?.value
if (hit !== undefined && hit.length > 0) return hit
throw new LlmError(
`llm-pi-ai: no credential for provider route "${provider}"; its profile resolves ${ref}, which is not`
+ ` set — store ${ref} through the credentials service (the web Models page writes it) or export it,`
+ ' and remove apiKeyEnv only if this provider should authenticate from pi-ai\'s own environment discovery',
'MISSING_CREDENTIAL',
)
}
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],
})))
// 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
// settings section supplies profiles, and routes drop when it empties.
let registration: AdapterRegistrationHandle | undefined
let registeredFacts: unknown
const ensureRegistrationFacts = (): void => {
const facts = registrationFacts(profiles())
if (deepEqualJson(facts, registeredFacts)) return
// The registry captures the route set and each route's retry policy at
// registration, so a change to either must re-register. The swap is
// atomic (same adapter instance, validated before anything moves): a
// conflicting route leaves the previous routes serving requests, and
// `registeredFacts` only advances once the registry actually holds the
// new set — so returning to a working configuration always re-applies.
const routes = [...profiles().keys()]
if (registration === undefined) {
// Dormant bare mount: nothing is registered until a section supplies
// profiles, and an empty section keeps it that way.
if (routes.length === 0) {
registeredFacts = facts
return
}
registration = ctx.llm.registerAdapter(routes, adapter)
} else {
registration.replace(routes)
}
registeredFacts = facts
}
ensureRegistrationFacts()
installSettingsSection(ctx, NS, Config, config, {
setSource: (source) => {
current = source
},
onChange: ensureRegistrationFacts,
})
}