fix(llm): keep request generations coherent
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-deepseek/README.md
|
||||
README.md: 8532dab4731e25b4af777217ad7c5ffad521d924
|
||||
README.zh.md: 922fb50ae063ae0a1f90db3b915dc55096448a5c
|
||||
README.md: 9d3014c9d4b3eb4fc2d02920c0cd4577e7977c89
|
||||
README.zh.md: d7685749376f58012c7d834047dd2eae754e27b8
|
||||
|
||||
@@ -47,9 +47,9 @@ The same exact-model result exposes ordered `off`, `high`, and `max` efforts und
|
||||
|
||||
## Dynamic configuration (settings + credentials)
|
||||
|
||||
Request facts are not frozen at load. `resolveAdapterOptions` is the one explicit resolve step from raw config to validated facts, and the adapter re-reads them through a thunk **once per operation**: base URL, catalog, request defaults, and idle budget take effect on the next operation, while an in-flight stream keeps the facts it started with. The `deepseek` route and its retry policy remain fixed by the plugin composition. Two optional seams feed the request facts:
|
||||
`resolveAdapterOptions` is the explicit resolve step from raw config to validated facts. The adapter reads live connection, credential, and request-transport facts through a thunk once per stream, so base URL, key, and idle budget changes reach the next request while an in-flight stream keeps its starting facts. The provider route, model catalog, context limits, thinking policy, reasoning default, and retry policy are composition-fixed. Two optional seams feed the live facts:
|
||||
|
||||
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`. Without a mounted settings service the entry config alone drives the adapter. A live settings snapshot that passes the schema but fails a beyond-schema bound (a duplicate catalog id, a broken thinking/effort pair) keeps the last good request facts and logs the failure; the entry config itself still fails plugin load.
|
||||
- **`ctx.settings`** — the plugin registers the `llm-deepseek` namespace with this same `Config` schema and its `cordis.yml` entry as the composition `base`. Without a mounted settings service the entry config alone drives the adapter. A live snapshot that changes a composition-fixed fact or fails a resolver bound is rejected as a whole generation: it contributes neither its changed connection nor credential. The entry config itself still fails plugin load.
|
||||
- **`ctx.credentials`** — the API key resolves per stream call, from the *same* resolved snapshot that supplies the endpoint: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the credential seam (`$DSH_HOME/.env` under the live environment), then — only without a mounted seam — the raw environment variable. Because credential facts travel with the connection facts, a rejected settings snapshot contributes neither its endpoint nor its key. A request with no key anywhere fails with `MISSING_CREDENTIAL`; after the operator supplies the named environment or dotenv value, the next request resolves it without a restart.
|
||||
|
||||
`ctx.llm.providerRetryPolicy('deepseek')` reports the policy captured from the composition entry at registration.
|
||||
@@ -72,7 +72,7 @@ Non-2xx responses throw `LlmError` with stable codes: `AUTH` (401/403), `QUOTA`
|
||||
|
||||
## Testing
|
||||
|
||||
Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers (next-request base-URL/key pickup, literal precedence, keyless onboarding, last-good snapshots, and composition-fixed retry policy), and `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document.
|
||||
Unit suites run against a local `node:http` mock SSE server (no network), including dynamic `high`/`off`/`max` selection, structured HTTP facts, malformed/truncated streams, caller abort, connection failure, and proof that idle timeout aborts the actual body. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers, including next-request base-URL/key pickup and a change landing between capability resolution and dispatch; the latter proves a generation that changes composition facts cannot contribute a newer endpoint or key. `tests/loader-composition.spec.ts` boots the full chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage lives in `tests/adapter.e2e.ts` (`pnpm run test:e2e`, key-gated): V4 Flash + V4 Pro across thinking enabled/disabled and both official effort levels, including the thinking+tools round trip with reasoning passback and a request whose key exists only in a credentials-local document.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -106,7 +106,7 @@ Loop-retained response blocks append to the next request and preserve its earlie
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **A settings `models` list replaces the composition list wholesale** — settings-layer merging is per-field, and arrays are one field; per-entry catalog merging would need a keyed shape.
|
||||
- **Settings cannot change model/capability defaults** — catalog, context limits, thinking policy, reasoning default, and retry policy belong to composition; a settings generation that changes one is rejected whole.
|
||||
- **`Config.apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields.
|
||||
- **`tool_choice` is not mapped** — not part of the core vocabulary (MVP cut, shared with the pi-ai twin).
|
||||
- **Requests use raw `fetch`, not `@cordisjs/plugin-http`** — no shared proxy/interception configuration; adoption is deferred until a second adapter wants it (`TODO(http)`).
|
||||
|
||||
@@ -47,9 +47,9 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
|
||||
## 动态配置(settings + credentials)
|
||||
|
||||
请求事实不在加载时冻结。`resolveAdapterOptions` 是从原始配置到已校验事实的唯一显式 resolve 步骤,适配器经由一个 thunk **每操作重读一次**:base URL、catalog、请求默认值与 idle 预算都在下一次操作生效,进行中的流则保持其起始事实。`deepseek` 路由及其重试策略始终由插件组合固定。两个可选 seam 为请求事实供值:
|
||||
`resolveAdapterOptions` 是从原始配置到已校验事实的显式 resolve 步骤。适配器经由一个 thunk 每个流读取一次实时连接、凭据与请求传输事实,因此 base URL、密钥与 idle 预算变更会作用于下一次请求,进行中的流则保持其起始事实。提供方路由、模型 catalog、上下文限制、思考策略、推理默认值与重试策略由组合固定。两个可选 seam 为实时事实供值:
|
||||
|
||||
- **`ctx.settings`**:插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`。未挂载 settings 服务时,仅由 entry 配置驱动适配器。存活 settings 快照若通过 schema 却违反 schema 之外的约束(重复的 catalog id、无法成立的 thinking/推理强度组合),则保留最后可用的请求事实并记录失败;entry 配置本身仍会使插件加载失败。
|
||||
- **`ctx.settings`**:插件用同一份 `Config` schema 注册 `llm-deepseek` namespace,并以其 `cordis.yml` 条目为组合 `base`。未挂载 settings 服务时,仅由 entry 配置驱动适配器。存活快照若更改由组合固定的事实或违反 resolver 约束,会整代被拒绝:其变更后的连接与凭据均不会被采用。entry 配置本身仍会使插件加载失败。
|
||||
- **`ctx.credentials`**:API 密钥按每次 stream 调用解析,取自与端点*同一*份解析后的快照:非空的字面 `apiKey` 优先,其次经凭据 seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`),最后仅在未挂载 seam 时读取原始环境变量。由于凭据事实与连接事实同行,被拒绝的 settings 快照既不贡献自己的端点,也不贡献自己的密钥。任何地方都没有密钥的请求以 `MISSING_CREDENTIAL` 失败;操作者为点名的环境变量或 dotenv 值供值后,下一次请求无需重启即可解析它。
|
||||
|
||||
`ctx.llm.providerRetryPolicy('deepseek')` 报告注册时从组合配置项捕获的策略。
|
||||
@@ -72,7 +72,7 @@ harness LLM(大语言模型)seam 的 DeepSeek chat-completions 适配器:
|
||||
|
||||
## 测试
|
||||
|
||||
单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider(下一请求即生效的 base-URL/密钥拾取、字面值优先、无密钥上手、最后可用快照,以及由组合固定的重试策略),`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。
|
||||
单元套件使用本地 `node:http` mock SSE 服务器(无网络),覆盖动态 `high`/`off`/`max` 选择、结构化 HTTP 事实、格式错误/截断流、调用方 abort、连接失败,以及 idle 超时确实会 abort 实际 body 的证明。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider,覆盖下一请求即生效的 base-URL/密钥拾取,以及落在能力解析与派发之间的变更;后者证明,更改组合事实的一代设置无法贡献更新的端点或密钥。`tests/loader-composition.spec.ts` 则从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起完整链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖位于 `tests/adapter.e2e.ts`(`pnpm run test:e2e`,需有 key 才会运行):V4 Flash + V4 Pro,覆盖思考启用/禁用与两种官方 effort 级别,包括思考 + 工具往返与推理回传,以及密钥仅存在于 credentials-local 文档中的请求。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -106,7 +106,7 @@ loop 保留的响应块会追加到下一个请求,并保留其较早可复用
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **settings 的 `models` 列表会整体替换组合列表**:settings 层按字段合并,而数组是单个字段;按条目合并 catalog 需要带键的形状。
|
||||
- **settings 无法更改模型/能力默认值**:catalog、上下文限制、思考策略、推理默认值与重试策略归组合所有;settings 若更改其中一项,整代设置都会被拒绝。
|
||||
- **`Config.apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。
|
||||
- **未映射 `tool_choice`**:它不属于核心词汇(MVP 取舍,与 pi-ai twin 共享)。
|
||||
- **请求使用原始 `fetch`,而非 `@cordisjs/plugin-http`**:没有共享 proxy/拦截配置;采用暂缓到第二个适配器需要该功能时(`TODO(http)`)。
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
/**
|
||||
* `DeepSeekAdapter`: fetch + SSE against a DeepSeek (OpenAI-compatible)
|
||||
* chat-completions endpoint, emitting harness StreamChunks. The adapter is
|
||||
* transport-only: connection facts arrive through a thunk resolved once per
|
||||
* operation and the bearer token through a per-request resolver, so the
|
||||
* registering plugin owns validation, layering, and credential policy.
|
||||
* transport-only: live connection facts arrive through a thunk resolved once
|
||||
* per stream, composition-fixed capability/default facts arrive separately,
|
||||
* and the bearer token comes from a per-request resolver.
|
||||
*
|
||||
* @module dsh-llm-deepseek/adapter
|
||||
*/
|
||||
@@ -38,10 +38,9 @@ export interface DeepSeekCatalogModel {
|
||||
}
|
||||
|
||||
/**
|
||||
* Validated connection facts for one operation. The plugin's
|
||||
* `resolveAdapterOptions` is the one explicit resolve step producing this
|
||||
* shape; the adapter trusts it and re-reads it per operation, which is what
|
||||
* makes a configuration change reach the next request without re-registration.
|
||||
* Validated adapter facts. The plugin's `resolveAdapterOptions` is the explicit
|
||||
* resolve step producing this shape; the adapter receives one composition
|
||||
* snapshot plus a per-stream current snapshot.
|
||||
*/
|
||||
export interface DeepSeekConnectionOptions {
|
||||
/** Endpoint base; `/chat/completions` is appended. */
|
||||
@@ -54,22 +53,24 @@ export interface DeepSeekConnectionOptions {
|
||||
apiKey?: string
|
||||
/** Credential reference of this same resolution, resolved per request when no literal key exists. */
|
||||
apiKeyEnv: CredentialRef
|
||||
/** Request defaults applied to every call (thinking mode, effort). */
|
||||
/** Composition-fixed request defaults applied to every call (thinking mode, effort). */
|
||||
defaults: RequestDefaults
|
||||
/** Positive context capacity used when the selected model has no exact value. */
|
||||
/** Composition-fixed positive context capacity used when the selected model has no exact value. */
|
||||
defaultContextWindow?: number
|
||||
/** Advisory models exposed to discovery consumers; requests remain unrestricted. */
|
||||
/** Composition-fixed advisory models exposed to discovery consumers; requests remain unrestricted. */
|
||||
models: readonly DeepSeekCatalogModel[]
|
||||
/** Maximum provider idle time while one stream read is outstanding. */
|
||||
streamIdleTimeoutMs: number
|
||||
/** Provider-owned model-request retry policy, already resolved. */
|
||||
/** Composition-fixed provider-owned model-request retry policy, already resolved. */
|
||||
retryPolicy: ResolvedRetryPolicy
|
||||
}
|
||||
|
||||
/** Constructor options for {@link DeepSeekAdapter}: the two resolution seams the plugin owns. */
|
||||
/** Constructor inputs for {@link DeepSeekAdapter}: live, composition, and credential facts. */
|
||||
export interface DeepSeekAdapterOptions {
|
||||
/** Current validated connection facts; called once per operation. */
|
||||
/** Current validated request facts; called once per stream. */
|
||||
options: () => DeepSeekConnectionOptions
|
||||
/** Composition snapshot owning catalog, capability/default, context, and retry facts. */
|
||||
composition: DeepSeekConnectionOptions
|
||||
/**
|
||||
* Resolve the bearer token for the connection facts of one request. The
|
||||
* snapshot is passed in — never re-read — so the key can only ever come
|
||||
@@ -154,11 +155,11 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
}
|
||||
|
||||
override providerRetryPolicy(_provider: string): ResolvedRetryPolicy {
|
||||
return this.config.options().retryPolicy
|
||||
return this.config.composition.retryPolicy
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
return Promise.resolve(this.config.options().models.map(model => modelInfo(provider, model)))
|
||||
return Promise.resolve(this.config.composition.models.map(model => modelInfo(provider, model)))
|
||||
}
|
||||
|
||||
override resolveModel(
|
||||
@@ -166,16 +167,16 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
model: string,
|
||||
_signal?: AbortSignal,
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
const connection = this.config.options()
|
||||
const configured = connection.models.find(entry => entry.id === model)
|
||||
const composition = this.config.composition
|
||||
const configured = composition.models.find(entry => entry.id === model)
|
||||
const contextWindow = configured?.contextWindow
|
||||
?? connection.defaultContextWindow
|
||||
?? composition.defaultContextWindow
|
||||
return Promise.resolve({
|
||||
...configured === undefined
|
||||
? { provider, id: model, name: model }
|
||||
: modelInfo(provider, configured),
|
||||
...contextWindow === undefined ? {} : { context: { contextWindow } },
|
||||
...connection.defaults.thinking === 'disabled'
|
||||
...composition.defaults.thinking === 'disabled'
|
||||
? {
|
||||
reasoning: {
|
||||
efforts: OFF_ONLY_REASONING_EFFORTS,
|
||||
@@ -185,9 +186,9 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
: {
|
||||
reasoning: {
|
||||
efforts: REASONING_EFFORTS,
|
||||
defaultEffort: connection.defaults.reasoningEffort === 'off'
|
||||
defaultEffort: composition.defaults.reasoningEffort === 'off'
|
||||
? OFF_REASONING_EFFORT
|
||||
: connection.defaults.reasoningEffort === 'max'
|
||||
: composition.defaults.reasoningEffort === 'max'
|
||||
? MAX_REASONING_EFFORT
|
||||
: HIGH_REASONING_EFFORT,
|
||||
},
|
||||
@@ -196,9 +197,9 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
}
|
||||
|
||||
async * stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
|
||||
// One resolution per stream call: connection facts 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.
|
||||
// One live resolution per stream call: connection, credential, and
|
||||
// transport facts freeze here and hold for the request. Model capability
|
||||
// and default facts come from the composition snapshot above.
|
||||
// The key resolves *from this snapshot*, so an endpoint and the secret
|
||||
// sent to it can never come from different configuration generations.
|
||||
const connection = this.config.options()
|
||||
@@ -250,7 +251,7 @@ export class DeepSeekAdapter extends LlmAdapter {
|
||||
connection: DeepSeekConnectionOptions,
|
||||
apiKey: string,
|
||||
): AsyncIterable<StreamChunk> {
|
||||
const body = serializeRequest(options, connection.defaults)
|
||||
const body = serializeRequest(options, this.config.composition.defaults)
|
||||
// Prepared outside the try so the TRANSPORT label below covers exactly the
|
||||
// transport boundary, never a serialization failure.
|
||||
const payload = JSON.stringify(body)
|
||||
|
||||
@@ -4,9 +4,9 @@
|
||||
* load: the plugin layers its `cordis.yml` entry config under the optional
|
||||
* `llm-deepseek` user-settings section (`ctx.settings`) and resolves the API
|
||||
* key through the optional credential seam (`ctx.credentials`), so a changed
|
||||
* base URL, catalog, or key reaches the very next request without restarting
|
||||
* anything, while an in-flight stream keeps the facts it started with. The
|
||||
* registration-captured facts stay composition-fixed.
|
||||
* base URL, key, or request-transport control reaches the next request without
|
||||
* restart. Catalog, capability/default, context, and retry facts stay fixed by
|
||||
* composition.
|
||||
* @module @deepseek-ai/dsh-llm-deepseek
|
||||
*/
|
||||
|
||||
@@ -15,7 +15,7 @@ import z from 'schemastery'
|
||||
import { LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm'
|
||||
import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm'
|
||||
import { credentialRef } from '@deepseek-ai/dsh-credentials'
|
||||
import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { deepEqualJson, installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { DEFAULT_STREAM_IDLE_TIMEOUT_MS, DeepSeekAdapter } from './adapter.ts'
|
||||
import type { DeepSeekCatalogModel, DeepSeekConnectionOptions } from './adapter.ts'
|
||||
@@ -53,17 +53,17 @@ export interface Config {
|
||||
apiKeyEnv?: string
|
||||
/** Endpoint base; falls back to $DEEPSEEK_BASE_URL, then the public API. */
|
||||
baseURL?: string
|
||||
/** Deployment thinking policy; `disabled` limits every conversation request to `off`. */
|
||||
/** Composition-fixed thinking policy; `disabled` limits every conversation request to `off`. */
|
||||
thinking?: 'enabled' | 'disabled'
|
||||
/** Default thinking effort (default `high`); `off` disables thinking per request. */
|
||||
/** Composition-fixed default thinking effort (default `high`); `off` disables thinking per request. */
|
||||
reasoningEffort?: 'off' | 'high' | 'max'
|
||||
/** Positive context capacity used when the selected model has no exact value. */
|
||||
/** Composition-fixed positive context capacity used when the selected model has no exact value. */
|
||||
defaultContextWindow?: number
|
||||
/** Advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
|
||||
/** Composition-fixed advisory models shown by discovery consumers; defaults to V4 Flash and V4 Pro. */
|
||||
models?: DeepSeekCatalogModel[]
|
||||
/** Maximum provider idle time while one stream read is outstanding (default five minutes). */
|
||||
streamIdleTimeoutMs?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
/** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
@@ -90,10 +90,9 @@ export const Config: z<Config> = z.object({
|
||||
export const PUBLIC_BASE_URL = 'https://api.deepseek.com'
|
||||
|
||||
/**
|
||||
* One resolution's complete request facts. Connection and credential facts
|
||||
* are one value on purpose: a snapshot the resolver rejects keeps the whole
|
||||
* previous generation, so a request can never pair a stale endpoint with a
|
||||
* newer key.
|
||||
* One resolution's complete adapter facts. Connection and credential facts
|
||||
* stay one value, while catalog, capability/default, context, and retry facts
|
||||
* must equal the composition snapshot.
|
||||
*/
|
||||
export type ResolvedDeepSeekOptions = DeepSeekConnectionOptions
|
||||
|
||||
@@ -165,8 +164,21 @@ export function resolveAdapterOptions(config: Config): ResolvedDeepSeekOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/** Facts that must stay identical to the plugin composition for the route's lifetime. */
|
||||
function compositionFacts(options: ResolvedDeepSeekOptions): unknown {
|
||||
return {
|
||||
defaults: options.defaults,
|
||||
...options.defaultContextWindow === undefined
|
||||
? {}
|
||||
: { defaultContextWindow: options.defaultContextWindow },
|
||||
models: options.models,
|
||||
retryPolicy: options.retryPolicy,
|
||||
}
|
||||
}
|
||||
|
||||
export function apply(ctx: Context, config: Config): void {
|
||||
const compositionOptions = resolveAdapterOptions(config)
|
||||
const fixedFacts = compositionFacts(compositionOptions)
|
||||
let current: () => Config = () => config
|
||||
let lastRaw: Config = config
|
||||
let lastGood = compositionOptions
|
||||
@@ -175,12 +187,17 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (raw === lastRaw) return lastGood
|
||||
try {
|
||||
const next = resolveAdapterOptions(raw)
|
||||
if (!deepEqualJson(compositionFacts(next), fixedFacts)) {
|
||||
throw new Error(
|
||||
'llm-deepseek: model catalog, capability defaults, context limits, and retry policy are composition-fixed',
|
||||
)
|
||||
}
|
||||
lastRaw = raw
|
||||
lastGood = next
|
||||
return next
|
||||
} catch (error) {
|
||||
// Static composition resolves before anything registers, so this branch
|
||||
// only sees a live settings snapshot failing a beyond-schema bound:
|
||||
// only sees an invalid live snapshot or one that changes a fixed fact:
|
||||
// keep serving the last good facts and say so once per bad snapshot.
|
||||
lastRaw = raw
|
||||
ctx.logger.error('llm-deepseek: keeping the last good configuration after an invalid settings section')
|
||||
@@ -211,7 +228,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
)
|
||||
}
|
||||
|
||||
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
|
||||
const adapter = new DeepSeekAdapter({ options, composition: compositionOptions, resolveApiKey })
|
||||
ctx.llm.registerAdapter([PROVIDER], adapter)
|
||||
|
||||
installSettingsSection(ctx, NS, Config, config, {
|
||||
|
||||
@@ -34,8 +34,10 @@ async function harness(baseURL: string, config: object = {}) {
|
||||
/** Direct adapter over the plugin's real resolve step, with a static key. */
|
||||
function adapterOf(config: Partial<LlmDeepSeek.Config> & { apiKey?: string } = {}): DeepSeekAdapter {
|
||||
const { apiKey, ...rest } = config
|
||||
const composition = resolveAdapterOptions(rest)
|
||||
return new DeepSeekAdapter({
|
||||
options: () => resolveAdapterOptions(rest),
|
||||
options: () => composition,
|
||||
composition,
|
||||
resolveApiKey: () => Promise.resolve(apiKey ?? 'k'),
|
||||
})
|
||||
}
|
||||
@@ -882,9 +884,10 @@ describe('plugin registration and config', () => {
|
||||
|
||||
it('resolves connection facts and the credential exactly once per stream call', async () => {
|
||||
const server = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const options = vi.fn(() => resolveAdapterOptions({ baseURL: server.url }))
|
||||
const composition = resolveAdapterOptions({ baseURL: server.url })
|
||||
const options = vi.fn(() => composition)
|
||||
const resolveApiKey = vi.fn(() => Promise.resolve('per-request-key'))
|
||||
const adapter = new DeepSeekAdapter({ options, resolveApiKey })
|
||||
const adapter = new DeepSeekAdapter({ options, composition, resolveApiKey })
|
||||
|
||||
for await (const _chunk of adapter.stream({ provider: 'deepseek', model: 'm', messages: [] })) { /* drain */ }
|
||||
|
||||
|
||||
@@ -19,6 +19,7 @@ afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
await closeMockServers()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function home(): Promise<string> {
|
||||
@@ -99,15 +100,13 @@ describe('request-level dynamic configuration', () => {
|
||||
expect(server.headers[0]?.authorization).toBe('Bearer sk-arrived')
|
||||
})
|
||||
|
||||
it('advertises a live settings catalog without re-registration', async () => {
|
||||
it('keeps the model catalog composition-fixed', async () => {
|
||||
const dir = await home()
|
||||
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
|
||||
await ctx.settings.update(NS, { models: [{ id: 'settings-model', name: 'From Settings' }] })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'settings-model', name: 'From Settings' },
|
||||
])
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
|
||||
})
|
||||
|
||||
it('keeps the registration retry policy composition-fixed', async () => {
|
||||
@@ -133,33 +132,19 @@ describe('request-level dynamic configuration', () => {
|
||||
expect(ctx.llm.listProviders()).toEqual([{ id: 'deepseek', name: 'DeepSeek' }])
|
||||
})
|
||||
|
||||
it('keeps the last good options when a settings snapshot fails beyond-schema validation', async () => {
|
||||
const dir = await home()
|
||||
const { ctx } = await boot(dir, { apiKey: 'k', baseURL: 'http://127.0.0.1:1' })
|
||||
|
||||
// Schema-valid but resolver-invalid: duplicate catalog ids pass the array
|
||||
// schema and fail the explicit resolve step.
|
||||
await ctx.settings.update(NS, { models: [{ id: 'dup' }, { id: 'dup' }] })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toHaveLength(2)
|
||||
await ctx.settings.update(NS, { models: [{ id: 'recovered' }] })
|
||||
await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([
|
||||
{ provider: 'deepseek', id: 'recovered', name: 'recovered' },
|
||||
])
|
||||
})
|
||||
|
||||
it('sends the whole last-good snapshot when a rejected one changed both the key and the URL', async () => {
|
||||
it('rejects a settings generation that combines new composition and connection facts', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const dir = await home()
|
||||
const good = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const rejected = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx } = await boot(dir, { apiKey: 'good-key', baseURL: good.url })
|
||||
|
||||
// One snapshot moves the endpoint AND the literal key, and fails the
|
||||
// resolve step beyond the schema (duplicate catalog ids).
|
||||
// One schema-valid snapshot moves the endpoint and key while also trying
|
||||
// to replace the composition-owned catalog.
|
||||
await ctx.settings.update(NS, {
|
||||
apiKey: 'rejected-key',
|
||||
baseURL: rejected.url,
|
||||
models: [{ id: 'dup' }, { id: 'dup' }],
|
||||
models: [{ id: 'settings-model' }],
|
||||
})
|
||||
|
||||
await prompt(ctx)
|
||||
@@ -170,6 +155,42 @@ describe('request-level dynamic configuration', () => {
|
||||
expect(good.headers[0]?.authorization).toBe('Bearer good-key')
|
||||
})
|
||||
|
||||
it('cannot mix earlier capability facts with a later settings connection', async () => {
|
||||
const dir = await home()
|
||||
const first = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const second = await mockServer([{ kind: 'sse', events: textEvents }])
|
||||
const { ctx } = await boot(dir, {
|
||||
apiKey: 'first-key',
|
||||
baseURL: first.url,
|
||||
thinking: 'disabled',
|
||||
reasoningEffort: 'off',
|
||||
})
|
||||
const resolveModel = vi.spyOn(LlmDeepSeek.DeepSeekAdapter.prototype, 'resolveModel')
|
||||
resolveModel.mockImplementation(async function (
|
||||
this: LlmDeepSeek.DeepSeekAdapter,
|
||||
provider,
|
||||
model,
|
||||
signal,
|
||||
) {
|
||||
resolveModel.mockRestore()
|
||||
const resolved = await this.resolveModel(provider, model, signal)
|
||||
// Land a complete settings generation after capability resolution but
|
||||
// before stream dispatch. Its changed composition fact rejects it whole.
|
||||
await ctx.settings.update(NS, {
|
||||
apiKey: 'second-key',
|
||||
baseURL: second.url,
|
||||
thinking: 'enabled',
|
||||
reasoningEffort: 'max',
|
||||
})
|
||||
return resolved
|
||||
})
|
||||
|
||||
await prompt(ctx)
|
||||
expect(second.requests).toHaveLength(0)
|
||||
expect(first.headers[0]?.authorization).toBe('Bearer first-key')
|
||||
expect(first.requests[0]).toMatchObject({ thinking: { type: 'disabled' } })
|
||||
})
|
||||
|
||||
it('falls back to the composition entry when settings detach', async () => {
|
||||
vi.stubEnv('DEEPSEEK_API_KEY', '')
|
||||
const dir = await home()
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write packages/llm/llm-pi-ai/README.md
|
||||
README.md: e8b7adf122946fc22f231fafb521866cbacdc652
|
||||
README.zh.md: 5bbf034267f6e276bc6552c5e731f6369cd97aee
|
||||
README.md: 972dcaed2ca18e0bef9c7c91ba1d3f518236a756
|
||||
README.zh.md: 40d5794b6d4dac3bf31e54bd6e570407b72eaa9f
|
||||
|
||||
@@ -39,15 +39,15 @@ Each dict key must exist in pi-ai's installed catalog; the dict shape makes dupl
|
||||
|
||||
## Dynamic configuration (settings + credentials)
|
||||
|
||||
The adapter reads its profiles through a thunk **once per operation** instead of freezing request facts at construction. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`. The user layer can override request-level fields of a composition route, such as its endpoint, credential reference, headers, or transport controls, effective on the next operation. Provider routes and retry policies remain composition-fixed; a settings snapshot that changes either is rejected as one generation. Without a mounted settings service the entry config alone drives the adapter.
|
||||
The adapter reads live connection, credential, and request-transport facts through a thunk once per stream. The plugin registers the `llm-pi-ai` namespace on the optional `ctx.settings` seam with this same `Config` schema and its `cordis.yml` entry as the composition `base`. The user layer can override a composition route's endpoint, credential reference, headers, budgets, cache/transport choices, and timeouts for the next request. Provider routes, installed model capabilities, reasoning defaults, and retry policies remain composition-fixed; a settings snapshot that changes a fixed fact is rejected as one generation. Without a mounted settings service the entry config alone drives the adapter.
|
||||
|
||||
Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. A live settings snapshot that changes registration facts, names an unknown provider, or fails another resolver bound keeps the last good profiles and logs the failure; the entry config itself fails plugin load.
|
||||
Credentials resolve per stream call: a non-empty literal `apiKey` wins, then `apiKeyEnv` through the optional `ctx.credentials` seam (`$DSH_HOME/.env` under the live environment; exactly that variable without a mounted seam). A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. A live settings snapshot that changes a fixed fact, names an unknown provider, or fails another resolver bound keeps the last good profiles and logs the failure; none of its connection or credential facts leak into a request. The entry config itself fails plugin load.
|
||||
|
||||
The adapter exposes each configured provider's installed pi-ai models through `ctx.llm.listModels(provider)`. This is provider-neutral selector metadata derived from `getModels(provider)`; request-time resolution still performs the authoritative catalog lookup, so discovery does not create a second model registry. `ctx.llm.resolveModelInfo(provider, model)` performs that exact descriptor lookup once and returns its identity, context window, and selectable thinking levels, keeping authoritative metadata on the route-owning adapter rather than its consumers.
|
||||
|
||||
The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The profile `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`.
|
||||
The `reasoning.efforts` list is pi-ai's ordered `getSupportedThinkingLevels(model)` result without filtering or normalization, including `off` and the model-specific availability of `xhigh` or `max`. The Harness exposes each canonical pi-ai level as an opaque ID; provider/model wire spellings remain inside pi-ai's `thinkingLevelMap`. A non-reasoning model therefore exposes pi-ai's `off` choice. The composition profile's `reasoning` value, including `off`, is the deployment default when configured; omitting it preserves the provider default. Per-request `GenerateOptions.reasoningEffort` takes precedence, and any explicit value absent from the exact model capability fails with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. pi-ai's common stream options represent `off` by omitting `reasoning`.
|
||||
|
||||
Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's optional retry policy is captured with that provider route; omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
|
||||
Supported profile fields are `apiKey`, `apiKeyEnv`, `baseURL`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. `reasoning` and `retryPolicy` are composition facts; the other fields are live request facts. Each optional retry policy is captured with its provider route, and omission uses bounded normal defaults. The stream-idle interval is a positive finite Node timer delay, defaults to five minutes, and covers only an outstanding provider read, not consumer think time. Harness app attribution wins a conflicting configured header name.
|
||||
|
||||
The adapter forces pi-ai's SDK `maxRetries` to zero so one `stream()` call makes one provider request. The removed profile fields `maxRetries` and `maxRetryDelayMs` fail load instead of silently multiplying or hiding the separately composed agent-level retry budget. Idle expiry aborts the SDK's stable request signal and surfaces `TIMEOUT`; an earlier caller abort remains `ABORTED`.
|
||||
|
||||
@@ -77,7 +77,7 @@ pi-ai installs several provider SDKs and lazy-loads the one selected by the cata
|
||||
|
||||
## Testing
|
||||
|
||||
Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: endpoint and `apiKeyEnv` changes reach later requests while routes and retry policy stay composition-fixed. `tests/loader-composition.spec.ts` boots that chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage remains key-gated under `pnpm run test:e2e`.
|
||||
Unit tests use pi-ai catalog models redirected to local mock servers and cover provider/profile routing, one wire request per adapter call, idle-timeout response termination, caller abort, native API selection, endpoint overrides, attribution, conversion, replay-state validation, and cross-provider/model replay within one adapter instance. `tests/dynamic-config.spec.ts` drives real settings-local and credentials-local providers: endpoint and credential changes reach later requests, while a change landing between capability resolution and dispatch cannot combine an earlier reasoning default with a newer endpoint or key. `tests/loader-composition.spec.ts` boots that chain from a test-only `cordis.yml` through the actual Loader and edits `settings.yaml`/`.env` on disk. Real-API coverage remains key-gated under `pnpm run test:e2e`.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -111,7 +111,7 @@ Recorded response content appends to the next request and does not invalidate it
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Settings cannot add or remove routes** — provider ownership and retry policy are composition facts; the user layer can only change request-level fields of existing routes.
|
||||
- **Settings cannot change routes or model defaults** — provider ownership, installed model capabilities, reasoning defaults, and retry policy are composition facts; the user layer can only change connection, credential, and request-transport fields of existing routes.
|
||||
- **`apiKey` is schema-tagged `role('secret')` but not masked by `ctx.settings.describe()`** — do not expose that envelope to an untrusted UI without redacting secret-role fields.
|
||||
- **Catalog membership is required** — custom model ids that are absent from the installed pi-ai catalog fail with `UNKNOWN_MODEL`, even when a provider profile supplies a custom endpoint.
|
||||
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
|
||||
|
||||
@@ -39,15 +39,15 @@
|
||||
|
||||
## 动态配置(settings + credentials)
|
||||
|
||||
适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结请求事实。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`。用户层可以覆盖组合路由的请求级字段,例如端点、凭据引用、标头或传输控制项,并在下一次操作生效。提供方路由与重试策略始终由组合固定;settings 快照若更改任一项,就会整代被拒绝。未挂载 settings 服务时,仅由 entry 配置驱动适配器。
|
||||
适配器经由一个 thunk 每个流读取一次实时连接、凭据与请求传输事实。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`。用户层可以为下一次请求覆盖组合路由的端点、凭据引用、标头、预算、缓存/传输选项与超时。提供方路由、已安装模型的能力、推理(reasoning)默认值与重试策略始终由组合固定;settings 快照若更改固定事实,就会整代被拒绝。未挂载 settings 服务时,仅由 entry 配置驱动适配器。
|
||||
|
||||
凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile(仅限这一种情况),才交给 pi-ai 的环境发现。存活 settings 快照若更改注册事实、点名未知提供方或违反其他 resolver 约束,则保留最后可用 profile 并记录失败;entry 配置本身会使插件加载失败。
|
||||
凭据按每次 stream 调用解析:非空的字面 `apiKey` 优先,其次经可选的 `ctx.credentials` seam 解析 `apiKeyEnv`(活跃环境之下的 `$DSH_HOME/.env`;未挂载 seam 时恰好读取该环境变量)。只有完全没有点名任何凭据的 profile(仅限这一种情况),才交给 pi-ai 的环境发现。存活 settings 快照若更改固定事实、点名未知提供方或违反其他 resolver 约束,则保留最后可用 profile 并记录失败;其中的连接与凭据事实一概不会泄漏进请求。entry 配置本身会使插件加载失败。
|
||||
|
||||
适配器通过 `ctx.llm.listModels(provider)` 公开每个已配置提供方已安装的 pi-ai 模型。这是从 `getModels(provider)` 派生的提供方无关 selector 元数据;请求时解析仍会执行权威 catalog 查找,因此发现不会创建第二个模型注册表。`ctx.llm.resolveModelInfo(provider, model)` 会执行一次精确 descriptor 查找,并返回其身份、上下文窗口和可选思考级别,让权威元数据保留在拥有路由的适配器上,而非消费方。
|
||||
|
||||
`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理(reasoning)能力的模型也会公开 pi-ai 的 `off` 选项。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。
|
||||
`reasoning.efforts` 列表是 pi-ai 有序的 `getSupportedThinkingLevels(model)` 结果,不经筛选或规范化,其中包括 `off`,以及模型对 `xhigh` 或 `max` 的特定支持。Harness 将每个规范 pi-ai 级别公开为不透明 ID;提供方/模型在协议格式中的表示仍保留在 pi-ai 的 `thinkingLevelMap` 中。因此,不具备推理能力的模型也会公开 pi-ai 的 `off` 选项。组合 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;任何未出现在确切模型能力中的显式值都会在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。
|
||||
|
||||
受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
|
||||
受支持的 profile 字段是 `apiKey`、`apiKeyEnv`、`baseURL`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。`reasoning` 与 `retryPolicy` 属于组合事实,其他字段属于实时请求事实。每个可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
|
||||
|
||||
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。
|
||||
|
||||
@@ -77,7 +77,7 @@ pi-ai 会安装多个提供方 SDK,并延迟加载 catalog 模型所选的 SDK
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:端点与 `apiKeyEnv` 变更会作用于后续请求,而路由与重试策略始终由组合固定。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起该链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。
|
||||
单元测试使用重定向到本地 mock 服务器的 pi-ai catalog 模型,覆盖提供方/profile 路由、每次适配器调用只发起一个协议请求、idle-timeout 响应终止、调用方 abort、原生 API 选择、端点覆盖、归因、转换、回放状态验证,以及一个适配器实例内的跨提供方/模型回放。`tests/dynamic-config.spec.ts` 驱动真实的 settings-local 与 credentials-local provider:端点与凭据变更会作用于后续请求,而落在能力解析与派发之间的变更无法把较早一代的推理默认值与较新一代的端点或密钥拼接起来。`tests/loader-composition.spec.ts` 从仅测试用的 `cordis.yml` 出发,经真实 Loader 拉起该链路,并在磁盘上编辑 `settings.yaml`/`.env`。真实 API 覆盖仍需 key 才会启用,并通过 `pnpm run test:e2e` 运行。
|
||||
|
||||
## 模型体验
|
||||
|
||||
@@ -111,7 +111,7 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
|
||||
|
||||
## 已知限制与暂缓事项
|
||||
|
||||
- **settings 无法新增或移除路由**:提供方所有权与重试策略属于组合事实;用户层只能更改现有路由的请求级字段。
|
||||
- **settings 无法更改路由或模型默认值**:提供方所有权、已安装模型的能力、推理默认值与重试策略属于组合事实;用户层只能更改现有路由的连接、凭据与请求传输字段。
|
||||
- **`apiKey` 已在 schema 中标注 `role('secret')`,但未由 `ctx.settings.describe()` 脱敏**:在对 secret 角色字段脱敏之前,不要向不受信任的 UI 暴露该信封。
|
||||
- **必须属于 catalog**:已安装 pi-ai catalog 中不存在的自定义模型 id 会以 `UNKNOWN_MODEL` 失败,即使提供方 profile 配置了自定义端点。
|
||||
- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。
|
||||
|
||||
@@ -34,10 +34,12 @@ import type { ResolvedPiAiProviderProfile } from './config.ts'
|
||||
import { toPiContext } from './context.ts'
|
||||
import { toStreamChunks } from './stream.ts'
|
||||
|
||||
/** Constructor options for {@link PiAiAdapter}: the two resolution seams the plugin owns. */
|
||||
/** Constructor inputs for {@link PiAiAdapter}: live, composition, and credential facts. */
|
||||
export interface PiAiAdapterOptions {
|
||||
/** Current validated profiles by provider route; called once per operation. */
|
||||
/** Current validated request profiles by provider route; called once per stream. */
|
||||
profiles: () => ReadonlyMap<string, ResolvedPiAiProviderProfile>
|
||||
/** Composition snapshot owning routes, model capabilities, reasoning defaults, and retry policies. */
|
||||
compositionProfiles: 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
|
||||
@@ -117,11 +119,11 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
}
|
||||
|
||||
override providerRetryPolicy(provider: string): ResolvedRetryPolicy | undefined {
|
||||
return this.config.profiles().get(provider)?.retryPolicy
|
||||
return this.config.compositionProfiles.get(provider)?.retryPolicy
|
||||
}
|
||||
|
||||
override listModels(provider: string): Promise<readonly LlmModelInfo[]> {
|
||||
const profile = this.config.profiles().get(provider)
|
||||
const profile = this.config.compositionProfiles.get(provider)
|
||||
if (profile === undefined) {
|
||||
return Promise.reject(new LlmError(`pi-ai adapter does not own provider "${provider}"`, 'NO_ADAPTER'))
|
||||
}
|
||||
@@ -137,7 +139,7 @@ export class PiAiAdapter extends LlmAdapter {
|
||||
model: string,
|
||||
_signal?: AbortSignal,
|
||||
): Promise<LlmResolvedModelInfo> {
|
||||
const profile = this.config.profiles().get(provider)
|
||||
const profile = this.config.compositionProfiles.get(provider)
|
||||
if (profile === undefined) {
|
||||
return Promise.reject(new LlmError(
|
||||
`pi-ai adapter does not own provider "${provider}"`,
|
||||
@@ -170,17 +172,18 @@ 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.
|
||||
// One live resolution per stream call: connection, credential, and
|
||||
// transport facts freeze here and hold for the request. Capability and
|
||||
// default reasoning facts come from the composition snapshot.
|
||||
const profile = this.config.profiles().get(options.provider)
|
||||
if (profile === undefined) {
|
||||
const compositionProfile = this.config.compositionProfiles.get(options.provider)
|
||||
if (profile === undefined || compositionProfile === undefined) {
|
||||
throw new LlmError(`pi-ai adapter does not own provider "${options.provider}"`, 'NO_ADAPTER')
|
||||
}
|
||||
const model = resolvePiModel(profile, options.model)
|
||||
const reasoning = resolveReasoningLevel(
|
||||
model,
|
||||
options.reasoningEffort ?? profile.reasoning,
|
||||
options.reasoningEffort ?? compositionProfile.reasoning,
|
||||
)
|
||||
const apiKey = await this.config.resolveApiKey(options.provider, profile)
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ export interface PiAiProviderProfile {
|
||||
baseURL?: string
|
||||
/** Provider request headers; Harness attribution wins reserved names. */
|
||||
headers?: Record<string, string>
|
||||
/** Provider-neutral pi-ai reasoning level. */
|
||||
/** Composition-fixed provider-neutral pi-ai reasoning default. */
|
||||
reasoning?: ModelThinkingLevel
|
||||
/** Token budgets used by reasoning providers that support them. */
|
||||
thinkingBudgets?: ThinkingBudgets
|
||||
@@ -42,7 +42,7 @@ export interface PiAiProviderProfile {
|
||||
websocketConnectTimeoutMs?: number
|
||||
/** Maximum provider idle time while one stream read is outstanding. */
|
||||
streamIdleTimeoutMs?: number
|
||||
/** Provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
/** Composition-fixed provider-owned model-request retry policy; omission uses normal defaults. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
|
||||
@@ -3,9 +3,9 @@
|
||||
* 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 request knob
|
||||
* reaches the next request without a restart. Provider routes and retry
|
||||
* policies stay composition-fixed.
|
||||
* optional credential seam, so a changed key, endpoint, or request-transport
|
||||
* knob reaches the next request without a restart. Provider routes, model
|
||||
* capabilities, reasoning defaults, and retry policies stay composition-fixed.
|
||||
*
|
||||
* ```yaml
|
||||
* - id: llm
|
||||
@@ -45,20 +45,24 @@ export const inject = ['llm']
|
||||
const NS = settingsNamespace('llm-pi-ai')
|
||||
|
||||
/**
|
||||
* The registry captures these per route; a change here must re-register.
|
||||
* Composition captures these per route; a settings change cannot alter them.
|
||||
* 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 {
|
||||
function compositionFacts(profiles: ReadonlyMap<string, ResolvedPiAiProviderProfile>): unknown {
|
||||
return [...profiles.entries()]
|
||||
.map(([provider, profile]) => ({ provider, retryPolicy: profile.retryPolicy }))
|
||||
.map(([provider, profile]) => ({
|
||||
provider,
|
||||
reasoning: profile.reasoning,
|
||||
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 compositionProfiles = resolveProfiles(config.providers)
|
||||
const compositionFacts = registrationFacts(compositionProfiles)
|
||||
const fixedFacts = compositionFacts(compositionProfiles)
|
||||
let current: () => Config = () => config
|
||||
let lastRaw: Config = config
|
||||
let lastGood: ReadonlyMap<string, ResolvedPiAiProviderProfile> = compositionProfiles
|
||||
@@ -67,15 +71,15 @@ export function apply(ctx: Context, config: Config): void {
|
||||
if (raw === lastRaw) return lastGood
|
||||
try {
|
||||
const next = resolveProfiles(raw.providers)
|
||||
if (!deepEqualJson(registrationFacts(next), compositionFacts)) {
|
||||
throw new Error('llm-pi-ai: provider routes and retry policies are composition-fixed')
|
||||
if (!deepEqualJson(compositionFacts(next), fixedFacts)) {
|
||||
throw new Error('llm-pi-ai: provider routes, reasoning defaults, and retry policies are composition-fixed')
|
||||
}
|
||||
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:
|
||||
// only sees an invalid live snapshot or one that changes a fixed fact:
|
||||
// keep serving the last good profiles and say so once per bad snapshot.
|
||||
lastRaw = raw
|
||||
ctx.logger.error('llm-pi-ai: keeping the last good profiles after an invalid settings section')
|
||||
@@ -111,7 +115,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
)
|
||||
}
|
||||
|
||||
const adapter = new PiAiAdapter({ profiles, resolveApiKey })
|
||||
const adapter = new PiAiAdapter({ profiles, compositionProfiles, resolveApiKey })
|
||||
ctx.llm.registerAdapter([...compositionProfiles.keys()], adapter)
|
||||
|
||||
installSettingsSection(ctx, NS, Config, config, {
|
||||
|
||||
@@ -25,8 +25,10 @@ async function harness(baseURL: string, overrides: Record<string, unknown> = {})
|
||||
|
||||
/** Direct adapter over the real profile resolver, with literal-key resolution. */
|
||||
function adapterOf(providers: Record<string, LlmPiAi.PiAiProviderProfile>): PiAiAdapter {
|
||||
const compositionProfiles = resolveProfiles(providers)
|
||||
return new PiAiAdapter({
|
||||
profiles: () => resolveProfiles(providers),
|
||||
profiles: () => compositionProfiles,
|
||||
compositionProfiles,
|
||||
resolveApiKey: (_provider, profile) => Promise.resolve(profile.apiKey),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ afterEach(async () => {
|
||||
while (cleanups.length > 0) await cleanups.pop()!()
|
||||
await closeMockServers()
|
||||
vi.unstubAllEnvs()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
async function home(): Promise<string> {
|
||||
@@ -97,4 +98,45 @@ describe('request-level dynamic profiles', () => {
|
||||
jitterRatio: 0.2,
|
||||
})
|
||||
})
|
||||
|
||||
it('cannot mix earlier capability facts with a later settings connection', async () => {
|
||||
const dir = await home()
|
||||
const first = await mockServer([{ events: textEvents }])
|
||||
const second = await mockServer([{ events: textEvents }])
|
||||
const ctx = await boot(dir, {
|
||||
providers: {
|
||||
deepseek: {
|
||||
apiKey: 'first-key',
|
||||
baseURL: first.url,
|
||||
reasoning: 'off',
|
||||
},
|
||||
},
|
||||
})
|
||||
const resolveModel = vi.spyOn(LlmPiAi.PiAiAdapter.prototype, 'resolveModel')
|
||||
resolveModel.mockImplementation(async function (
|
||||
this: LlmPiAi.PiAiAdapter,
|
||||
provider,
|
||||
model,
|
||||
signal,
|
||||
) {
|
||||
resolveModel.mockRestore()
|
||||
const resolved = await this.resolveModel(provider, model, signal)
|
||||
// Land a complete settings generation after capability resolution but
|
||||
// before stream dispatch. Its changed reasoning default rejects it whole.
|
||||
await ctx.settings.update(NS, {
|
||||
providers: {
|
||||
deepseek: {
|
||||
apiKey: 'second-key',
|
||||
baseURL: second.url,
|
||||
reasoning: 'max',
|
||||
},
|
||||
},
|
||||
})
|
||||
return resolved
|
||||
})
|
||||
|
||||
await assemble(ctx, { provider: 'deepseek', model: 'deepseek-v4-flash', messages: [] })
|
||||
expect(second.requests).toHaveLength(0)
|
||||
expect(first.headers[0]?.authorization).toBe('Bearer first-key')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -22,8 +22,10 @@ describe('pi-ai SDK retry boundary', () => {
|
||||
throw failure
|
||||
},
|
||||
})
|
||||
const compositionProfiles = resolveProfiles({ openai: { apiKey: 'test-key' } })
|
||||
const adapter = new PiAiAdapter({
|
||||
profiles: () => resolveProfiles({ openai: { apiKey: 'test-key' } }),
|
||||
profiles: () => compositionProfiles,
|
||||
compositionProfiles,
|
||||
resolveApiKey: () => Promise.resolve('test-key'),
|
||||
})
|
||||
const drain = async (): Promise<void> => {
|
||||
|
||||
Reference in New Issue
Block a user