Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input

# Conflicts:
#	docs/architecture.i18n.yaml
#	docs/cordis-catalog/services.md
#	docs/core-data-structures/core.i18n.yaml
#	docs/module-graph.md
#	examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl
#	packages/README.i18n.yaml
#	packages/llm/llm-pi-ai/README.i18n.yaml
#	packages/llm/llm-pi-ai/package.json
#	packages/llm/llm-pi-ai/src/adapter.ts
#	packages/llm/llm-pi-ai/src/index.ts
#	packages/llm/llm-pi-ai/tests/adapter.spec.ts
#	packages/llm/llm-pi-ai/tsconfig.json
#	packages/llm/llm/README.i18n.yaml
#	pnpm-lock.yaml
#	tsconfig.host.json
This commit is contained in:
creatixchu
2026-07-31 01:11:50 +08:00
166 changed files with 6309 additions and 750 deletions
+29 -21
View File
@@ -18,6 +18,7 @@ import type {
import type { AttachmentStore } from '@deepseek-ai/dsh-attachment'
import {
attributionHeaders,
contentHasImage,
LlmAdapter,
LlmError,
ReasoningEffortId,
@@ -31,16 +32,22 @@ import type {
StreamChunk,
} from '@deepseek-ai/dsh-llm'
import { idleWatchdog, timeoutOf } from '@deepseek-ai/dsh-timeout'
import { resolveProfiles } from './config.ts'
import type { PiAiProviderProfile, ResolvedPiAiProviderProfile } from './config.ts'
import { contentHasImage } from '@deepseek-ai/dsh-llm'
import type { ResolvedPiAiProviderProfile } from './config.ts'
import { toPiContext } from './context.ts'
import { toStreamChunks } from './stream.ts'
/** Constructor options for {@link PiAiAdapter}. */
/** Constructor options for {@link PiAiAdapter}: the request-time resolution seams the plugin owns. */
export interface PiAiAdapterOptions {
/** Validated provider profiles this adapter instance owns. */
profiles: readonly PiAiProviderProfile[]
/** Current validated profiles by provider route; called once per operation. */
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.
*/
resolveApiKey: (provider: string, profile: ResolvedPiAiProviderProfile) => Promise<string | undefined>
/** Resolve durable image storage at request time so plugin load order does not become capability state. */
resolveAttachments?: () => AttachmentStore | undefined
}
@@ -50,7 +57,7 @@ export interface PiAiAdapterOptions {
* override, preserving the catalog's API/capability/compatibility metadata.
*/
function resolvePiModel(
profile: Omit<PiAiProviderProfile, 'retryPolicy'>,
profile: ResolvedPiAiProviderProfile,
modelId: string,
): Model<Api> {
const model = getBuiltinModels(profile.provider as BuiltinProvider).find(candidate => candidate.id === modelId) as Model<Api> | undefined
@@ -62,12 +69,13 @@ function resolvePiModel(
/** Copy profile stream knobs into pi-ai's common option vocabulary. */
function profileOptions(
profile: Omit<PiAiProviderProfile, 'retryPolicy'>,
profile: ResolvedPiAiProviderProfile,
reasoning: ModelThinkingLevel | undefined,
apiKey: string | undefined,
): SimpleStreamOptions {
const enabledReasoning: ThinkingLevel | undefined = reasoning === 'off' ? undefined : reasoning
return {
...profile.apiKey === undefined ? {} : { apiKey: profile.apiKey },
...apiKey === undefined ? {} : { apiKey },
...enabledReasoning === undefined ? {} : { reasoning: enabledReasoning },
...profile.thinkingBudgets === undefined ? {} : { thinkingBudgets: profile.thinkingBudgets },
...profile.cacheRetention === undefined ? {} : { cacheRetention: profile.cacheRetention },
@@ -108,21 +116,16 @@ function requestHeaders(headers: Readonly<Record<string, string>> | undefined):
* request, so models need not be registered during the Cordis lifecycle.
*/
export class PiAiAdapter extends LlmAdapter {
private readonly profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>
private readonly resolveAttachments: () => AttachmentStore | undefined
constructor(options: PiAiAdapterOptions) {
constructor(private readonly config: PiAiAdapterOptions) {
super()
this.profiles = new Map(resolveProfiles(options.profiles).map(profile => [profile.provider, profile]))
this.resolveAttachments = options.resolveAttachments ?? (() => undefined)
}
override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {
return this.profiles.get(provider)?.retryPolicy
return this.config.profiles().get(provider)?.retryPolicy
}
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
const profile = this.profiles.get(provider)
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'))
}
@@ -139,7 +142,7 @@ export class PiAiAdapter extends LlmAdapter {
model: string,
_signal?: AbortSignal,
): Promise<LlmResolvedModelInfo> {
const profile = this.profiles.get(provider)
const profile = this.config.profiles().get(provider)
if (profile === undefined) {
return Promise.reject(new LlmError(
`pi-ai adapter does not own provider "${provider}"`,
@@ -173,7 +176,10 @@ export class PiAiAdapter extends LlmAdapter {
if (options.stop !== undefined) {
throw new LlmError('llm-pi-ai does not support GenerateOptions.stop', 'UNSUPPORTED_OPTION')
}
const profile = this.profiles.get(options.provider)
// 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')
}
@@ -182,6 +188,8 @@ export class PiAiAdapter extends LlmAdapter {
model,
options.reasoningEffort ?? profile.reasoning,
)
const apiKey = await this.config.resolveApiKey(options.provider, profile)
const consumer = new AbortController()
const upstream = options.signal === undefined
? consumer.signal
@@ -194,7 +202,7 @@ export class PiAiAdapter extends LlmAdapter {
if (containsImage && !model.input.includes('image')) {
throw new LlmError(`pi-ai model "${model.id}" does not support image input`, 'UNSUPPORTED_CONTENT')
}
const attachments = containsImage ? this.resolveAttachments() : undefined
const attachments = containsImage ? this.config.resolveAttachments?.() : undefined
if (containsImage && attachments === undefined) {
throw new LlmError('pi-ai image input requires the durable attachment service', 'UNSUPPORTED_CONTENT')
}
@@ -202,7 +210,7 @@ export class PiAiAdapter extends LlmAdapter {
? toPiContext(options)
: await toPiContext(options, attachments)
const events = streamSimple(model, context, {
...profileOptions(profile, reasoning),
...profileOptions(profile, reasoning, apiKey),
...options.temperature === undefined ? {} : { temperature: options.temperature },
...options.maxTokens === undefined ? {} : { maxTokens: options.maxTokens },
...options.sessionId === undefined ? {} : { sessionId: String(options.sessionId) },
+57 -35
View File
@@ -1,5 +1,7 @@
/**
* Configuration schema and provider-profile validation for the pi-ai adapter.
* 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.
*
* @module dsh-llm-pi-ai/config
*/
@@ -7,6 +9,8 @@
import { getBuiltinProviders } from '@earendil-works/pi-ai/providers/all'
import type { CacheRetention, ModelThinkingLevel, 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'
@@ -14,12 +18,12 @@ import type { ResolvedRetryPolicy, RetryPolicyConfig } from '@deepseek-ai/dsh-ll
/** Default maximum idle interval while an adapter stream read is outstanding. */
export const DEFAULT_STREAM_IDLE_TIMEOUT_MS = 300_000
/** Configuration for one pi-ai provider route. */
/** Configuration for one pi-ai provider route; the `providers` dict key IS the route. */
export interface PiAiProviderProfile {
/** pi-ai provider catalog name and Harness route key. */
provider: string
/** Provider credential; when absent pi-ai uses its provider-native ambient discovery. */
/** 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. */
baseURL?: string
/** Provider request headers; Harness attribution wins reserved names. */
@@ -42,18 +46,26 @@ export interface PiAiProviderProfile {
retryPolicy?: RetryPolicyConfig
}
/** Validated profile with every adapter-owned default resolved. */
export interface ResolvedPiAiProviderProfile extends Omit<PiAiProviderProfile, 'retryPolicy'> {
/** 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). */
provider: 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
}
/** Plugin configuration: the non-empty provider profiles this instance owns. */
/** Plugin configuration: the provider routes this instance owns. */
export interface Config {
/** Non-empty set of pi-ai provider routes this adapter instance owns. */
providers: PiAiProviderProfile[]
/**
* pi-ai provider routes, keyed by provider. An empty (or omitted) dict is
* the dormant settings-driven posture: the adapter mounts with no routes
* and registers them the moment a settings section supplies profiles.
*/
providers?: Record<string, PiAiProviderProfile>
}
const thinkingBudgets = z.object({
@@ -64,8 +76,8 @@ const thinkingBudgets = z.object({
})
const profile = z.object({
provider: z.string().required(),
apiKey: z.string(),
apiKey: z.string().role('secret'),
apiKeyEnv: z.string(),
baseURL: z.string(),
headers: z.dict(z.string()),
reasoning: z.union(['off', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max']),
@@ -80,54 +92,64 @@ const profile = z.object({
/** Runtime schema for {@link Config}. */
export const Config: z<Config> = z.object({
providers: z.array(profile).required(),
providers: z.dict(profile).default({}),
})
/**
* Validate profiles against the installed pi-ai catalog and return a detached
* shallow copy suitable for adapter construction.
* @param profiles - configured provider profiles.
* 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.
* @param providers - configured provider profiles keyed by route.
* @returns validated profiles in configuration order.
*/
export function resolveProfiles(profiles: readonly PiAiProviderProfile[]): ResolvedPiAiProviderProfile[] {
if (profiles.length === 0) throw new Error('llm-pi-ai: providers must contain at least one profile')
export function resolveProfiles(
providers: Readonly<Record<string, PiAiProviderProfile>> | undefined,
): Map<string, ResolvedPiAiProviderProfile> {
if (Array.isArray(providers)) {
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 seen = new Set<string>()
return profiles.map((source) => {
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')
}
if (source.provider.length === 0) throw new Error('llm-pi-ai: provider names must be non-empty')
if (!supported.has(source.provider)) throw new Error(`llm-pi-ai: unknown pi-ai provider "${source.provider}"`)
if (seen.has(source.provider)) throw new Error(`llm-pi-ai: duplicate provider profile "${source.provider}"`)
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 "${source.provider}" has an empty apiKey; omit it to use ambient authentication`)
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 "${source.provider}" has an empty baseURL`)
throw new Error(`llm-pi-ai: provider "${provider}" has an empty baseURL`)
}
const streamIdleTimeoutMs = source.streamIdleTimeoutMs ?? DEFAULT_STREAM_IDLE_TIMEOUT_MS
if (!Number.isFinite(streamIdleTimeoutMs)
|| streamIdleTimeoutMs <= 0
|| streamIdleTimeoutMs > MAX_TIMER_DELAY_MS) {
throw new Error(
`llm-pi-ai: provider "${source.provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
`llm-pi-ai: provider "${provider}" streamIdleTimeoutMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`,
)
}
seen.add(source.provider)
return {
...source,
const { apiKeyEnv, retryPolicy, ...rest } = source
resolved.set(provider, {
...rest,
provider,
...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
streamIdleTimeoutMs,
retryPolicy: resolveRetryPolicy(
source.retryPolicy,
`llm-pi-ai: provider "${source.provider}" retryPolicy`,
),
...source.headers === undefined ? {} : { headers: { ...source.headers } },
...source.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...source.thinkingBudgets } },
}
})
retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
...rest.headers === undefined ? {} : { headers: { ...rest.headers } },
...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
})
}
return resolved
}
+122 -14
View File
@@ -1,22 +1,27 @@
/**
* Generic pi-ai-backed LLM adapter plugin. One plugin instance registers an
* explicit set of provider profiles; requests select a profile by provider and
* resolve the model dynamically from pi-ai's installed catalog.
* 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:
* - provider: openai
* apiKey: !!js process.env.OPENAI_API_KEY
* openai:
* apiKeyEnv: OPENAI_API_KEY
* retryPolicy:
* mode: normal
* maxRetries: 2
* - provider: anthropic
* apiKey: !!js process.env.ANTHROPIC_API_KEY
* - provider: openrouter
* apiKey: !!js process.env.OPENROUTER_API_KEY
* anthropic:
* apiKeyEnv: ANTHROPIC_API_KEY
* openrouter:
* apiKeyEnv: OPENROUTER_API_KEY
* baseURL: https://proxy.example.com/v1
* ```
*
@@ -24,24 +29,127 @@
*/
import type { Context } from 'cordis'
import type {} from '@deepseek-ai/dsh-llm'
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 } 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 {
const profiles = resolveProfiles(config.providers)
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> => {
if (profile.apiKey !== undefined) return profile.apiKey
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, read exactly the named variable so a plain
// cordis.yml composition works from the environment alone.
: process.env[ref]
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: config.providers,
profiles,
resolveApiKey,
resolveAttachments: () => ctx.get('attachments'),
})
ctx.llm.registerAdapter(profiles.map(entry => entry.provider), adapter)
// 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,
})
}