mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-08-29 04:26:38 +00:00
feat(web): default model retries to five
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 .agents/notes/implemented/feature/2026-07-24-provider-retry-policies.md
|
||||
2026-07-24-provider-retry-policies.md: 1831ce6b96178d11e7c9927ceccbe07ea578cd2c
|
||||
2026-07-24-provider-retry-policies.zh.md: 22f18badcb47e4ce086ff2c68c7011612b2eabba
|
||||
2026-07-24-provider-retry-policies.md: d85a6eb285e3c4dd47c5d0d3fa27fbbb51490449
|
||||
2026-07-24-provider-retry-policies.zh.md: e353624801252d4fc5e083852800bd6c58a7f01d
|
||||
|
||||
@@ -12,7 +12,7 @@ Provider policy must follow the request that actually failed, including a route
|
||||
|
||||
## Decision
|
||||
|
||||
Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration. The adapter validates and resolves the policy, and `ctx.llm` captures it when that exact provider route registers. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. A provider without `retryPolicy` uses the normal defaults.
|
||||
Each concrete adapter accepts an optional `retryPolicy` inside its provider configuration. The adapter validates and resolves the policy, and `ctx.llm` captures it when that exact provider route registers. A multi-provider adapter may also expose a composition default that it resolves into every route without an explicit policy; `llm-pi-ai` names this field `defaultRetryPolicy`, and a profile policy wins over it. This remains route-owned registration state rather than a global retry-executor setting. When a call enters its final adapter boundary, `ctx.llm` binds the serving registration's immutable policy to that call; the agent loop passes it to closed-step recovery even if the route is disposed or replaced while the request is in flight. `@deepseek-ai/dsh-llm-retry` combines that call-local policy with the failed step's durable provider identity. A call that never reaches a final adapter has no serving policy and delegates. A provider without either policy uses the normal defaults of two retries. The shipped Web bundle configures its `deepseek-official` route and the pi-ai adapter default for five normal-mode retries; this deployment choice tolerates longer transient gateway outages while remaining bounded. The `llm-deepseek` user-settings layer or a pi-ai profile policy can override its corresponding default. Other profiles and provider routes retain their own policies.
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
@@ -50,16 +50,20 @@ Each scheduled retry appends a non-surface `llm/retry` event with the failed pro
|
||||
|
||||
**A very large finite retry count** — rejected because it eventually violates the requested keep-retrying contract and serializes an arbitrary operational limit as if it were meaningful.
|
||||
|
||||
**Raise the provider omission default to five** — rejected because it would also increase request cost and latency for headless and custom profiles. The Web bundle's provider configuration expresses the interactive deployment's larger bounded budget without changing other profiles.
|
||||
|
||||
**Stamp five retries into profiles when the Web UI writes them** — rejected because existing profiles and settings written outside that UI would retain two retries. The deployment default belongs in the Web composition and is inherited uniformly.
|
||||
|
||||
**Provider-SDK retries** — rejected because hidden attempts multiply agent-level budgets, cannot use the closed-step durability boundary, and may splice or discard streamed output without a reconstructable retry record.
|
||||
|
||||
**Put the error into model context** — rejected because a transport or provider diagnostic is operational state, not conversation content. It can expose sensitive provider details and changes the retried request instead of repeating the failed request.
|
||||
|
||||
## Verification
|
||||
|
||||
Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits.
|
||||
Adapter tests validate nested policies at provider load, prove registration captures configured and default policies, and retain the serving policy across in-flight route replacement. The pi-ai tests prove its adapter default reaches omitted profiles, explicit profile policies win, invalid defaults fail even while dormant, and settings changes atomically re-register the route set while changing only inherited policy values. Unit tests select policies from the failed request's serving registration, separate provider and changed-policy histories, exercise always mode beyond the normal budget, pin jitter and delay caps, prove downstream recovery ordering, prove cancellation and disposal drain delegated recovery before reaching quiescence, and prove both abort active backoff waits. Request-level coverage compares the complete messages of failed and retried attempts and rejects both provider error text and discarded partial output. A keyless headless `stream-json` snapshot runs failure, retry, and success through the assembled app, pins the complete `llm/retry` record, and rejects any model-message change between attempts. The shipped Web composition snapshot pins both the resolved `deepseek-official` policy and a settings-added pi-ai route at five retries. JSONL and SQLite tests round-trip an always event without `Infinity`; invariant tests bind provider identity to the request header, validate failure and mode-specific timer bounds, and bind retry numbers to provider-policy keys; TUI tests render finite and infinite limits.
|
||||
|
||||
## Consequences
|
||||
|
||||
Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. Retry state stays observable and durable without becoming model-visible, and serving-registration capture prevents adapter lifecycle changes from retroactively changing an in-flight request's recovery contract.
|
||||
Normal mode remains a finite default, while an explicit always policy can spend unbounded requests and time on permanent authentication, quota, invalid-request, protocol, or context failures. Operators must pair always mode with a cancellable caller and provider-specific cost controls. A Web model route using the deployment default may spend up to three more requests and their backoff time than a route using core omission defaults, in exchange for recovering from longer transient outages. Retry state stays observable and durable without becoming model-visible, and serving-registration capture prevents adapter lifecycle changes from retroactively changing an in-flight request's recovery contract.
|
||||
|
||||
This decision extends the closed-step recovery, single visible adapter attempt, structured failure, and durable status design in [bounded recovery for transient LLM request failures](../architecture/2026-06-21-bounded-llm-request-recovery.md).
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
## 决策
|
||||
|
||||
每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该特定提供方路由注册时捕获策略。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。未配置 `retryPolicy` 的提供方使用 normal 默认值。
|
||||
每个具体适配器都在其提供方配置中接受可选的 `retryPolicy`。适配器负责校验并解析策略,`ctx.llm` 则在该特定提供方路由注册时捕获策略。多提供方适配器还可以公开一个组合默认值,并将它解析进每条未显式配置策略的路由;`llm-pi-ai` 将该字段命名为 `defaultRetryPolicy`,profile 策略优先于它。这仍然是路由拥有的注册状态,而不是全局重试执行器设置。当调用进入最终适配器边界时,`ctx.llm` 会把实际提供服务的注册项所持不可变策略绑定到该调用;即使路由在请求进行期间被 dispose(资源释放)或替换,agent loop(智能体循环)仍会把该策略传给已关闭步骤恢复。`@deepseek-ai/dsh-llm-retry` 会把绑定到该调用的策略与失败步骤的持久化提供方标识结合起来。未到达最终适配器的调用没有实际提供服务的策略,因而会委托后续处理。两层策略都未配置的提供方使用两次重试的 normal 默认值。随附的 Web bundle 为自己的 `deepseek-official` 路由和 pi-ai 适配器默认值配置五次 normal 模式重试;这项部署选择能容忍更长的短暂 gateway 故障,同时仍然有界。`llm-deepseek` 用户设置层或 pi-ai profile 策略可以覆盖各自对应的默认值。其他 profile 与提供方路由保留各自的策略。
|
||||
|
||||
```yaml
|
||||
providers:
|
||||
@@ -50,16 +50,20 @@ always 模式先请求下游恢复,使上下文溢出压缩(compaction)之
|
||||
|
||||
**设置很大的有限重试次数**:不予采纳,因为它最终仍会违反持续重试的约定,并把任意选取的运维上限序列化成看似有意义的数值。
|
||||
|
||||
**将提供方省略配置时的默认值提高到五次**:不予采纳,因为这也会增加 headless 与自定义 profile 的请求成本和延迟。Web bundle 的提供方配置能表达互动式部署所需的更大有界预算,而不改变其他 profile。
|
||||
|
||||
**在 Web UI 写入 profile 时把五次重试写死进去**:不予采纳,因为现有 profile 与从该 UI 之外写入的 settings 仍会保留两次重试。部署默认值应位于 Web 组合中,并被统一继承。
|
||||
|
||||
**使用提供方 SDK 重试**:不予采纳,因为隐藏尝试会叠加 agent 层预算,无法利用已关闭步骤的持久性边界,还可能在没有可重建重试记录的情况下拼接或丢弃流式输出。
|
||||
|
||||
**把错误放入模型上下文**:不予采纳,因为传输或提供方诊断信息属于运维状态,而非对话内容。它可能暴露敏感的提供方细节,并会改变重试请求,无法重复原本失败的请求。
|
||||
|
||||
## 验证
|
||||
|
||||
适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。
|
||||
适配器测试会在提供方加载时校验嵌套策略,证明注册流程会捕获已配置策略和默认策略,并证明请求进行期间替换路由后仍会保留实际提供服务的策略。pi-ai 测试会证明适配器默认值抵达省略策略的 profile、显式 profile 策略优先、无效默认值即使在休眠状态也会失败,以及 settings 变更会原子地重新注册路由集合,同时只改变继承的策略值。单元测试根据失败请求实际使用的注册项选择策略、分离不同提供方和策略变更后的重试历史、验证 always 模式可越过 normal 预算、固定抖动和延迟上限、证明下游恢复顺序、证明取消与 dispose 会先排空已委托的恢复再达到完全停稳,并证明二者都会停止正在进行的退避等待。请求级覆盖会比较失败尝试与重试尝试的完整消息,并排除提供方错误文本和丢弃的部分输出。一个无密钥 headless `stream-json` 快照会通过组装后的应用执行失败、重试与成功流程,固定完整的 `llm/retry` 记录,并拒绝各次尝试之间出现任何模型消息变化。随附的 Web 组合快照将解析后的 `deepseek-official` 策略和一条由 settings 新增的 pi-ai 路由都固定为五次重试。JSONL 与 SQLite 测试会往返读写不含 `Infinity` 的 always 事件;不变式测试会将提供方标识绑定到请求头、校验失败事实和各模式的计时器边界,并将重试编号绑定到提供方策略键;TUI 测试会渲染有限和无限上限。
|
||||
|
||||
## 后果
|
||||
|
||||
normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。重试状态保持可观察且会持久化,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复约定。
|
||||
normal 模式仍是有限的默认策略;显式的 always 策略可能在永久性的身份验证、配额、无效请求、协议或上下文错误上耗费无限次请求和无限时间。运维方必须为 always 模式配备可取消的调用方和针对提供方的成本控制。使用该部署默认值的 Web 模型路由比使用核心省略默认值的路由最多多花费三次请求及其退避时间,以此换取从更长短暂故障中恢复的能力。重试状态保持可观察且会持久化,但不会对模型可见;捕获实际提供服务的注册项,也能防止适配器生命周期变化反过来改变进行中请求的恢复约定。
|
||||
|
||||
本决策扩展了[瞬态 LLM(大语言模型)请求失败的有界恢复](../architecture/2026-06-21-bounded-llm-request-recovery.md)中确定的已关闭步骤恢复、单次可见适配器尝试、结构化失败与持久化状态设计。
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Boots the shipped Web composition over the built dist this lane already uses
|
||||
// and asserts what that composition produces: the model-visible tool catalog
|
||||
// and file-reference guidance plus the sandbox/approval knobs it ships with.
|
||||
// and file-reference guidance plus its retry, sandbox, and approval defaults.
|
||||
// No browser and no model call — these are composition facts, and the browser
|
||||
// scenarios in this lane cover the surface itself.
|
||||
import { readFileSync } from 'node:fs'
|
||||
@@ -10,6 +10,7 @@ import { afterEach, expect, it } from 'vitest'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { canonicalPath, writableRoots } from '@deepseek-ai/dsh-sandbox'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
||||
// Empty type imports carry the tools/sandboxPolicy/approval Context merges.
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
import type {} from '@deepseek-ai/dsh-sandbox-policy'
|
||||
@@ -73,9 +74,44 @@ afterEach(async () => {
|
||||
scaffold = undefined
|
||||
})
|
||||
|
||||
it('assembles the shipped Web catalog, file-reference guidance, and confined access default', async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
it('assembles the shipped Web catalog, file-reference guidance, retry policy, and confined access default', async () => {
|
||||
scaffold = await launchWebScaffold({ deepSeekMissingCredential: true })
|
||||
const ctx = scaffold.ctx
|
||||
expect(ctx.llm.providerRetryPolicy('deepseek-official')).toMatchInlineSnapshot(`
|
||||
{
|
||||
"initialDelayMs": 500,
|
||||
"jitterRatio": 0.1,
|
||||
"maxDelayMs": 10000,
|
||||
"maxRetries": 5,
|
||||
"mode": "normal",
|
||||
"retryableCodes": [
|
||||
"EMPTY_RESPONSE",
|
||||
"RATE_LIMIT",
|
||||
"SERVER",
|
||||
"TIMEOUT",
|
||||
"TRANSPORT",
|
||||
],
|
||||
}
|
||||
`)
|
||||
await ctx.settings.update(settingsNamespace('llm-pi-ai'), {
|
||||
providers: { openai: {} },
|
||||
})
|
||||
expect(ctx.llm.providerRetryPolicy('openai')).toMatchInlineSnapshot(`
|
||||
{
|
||||
"initialDelayMs": 500,
|
||||
"jitterRatio": 0.1,
|
||||
"maxDelayMs": 10000,
|
||||
"maxRetries": 5,
|
||||
"mode": "normal",
|
||||
"retryableCodes": [
|
||||
"EMPTY_RESPONSE",
|
||||
"RATE_LIMIT",
|
||||
"SERVER",
|
||||
"TIMEOUT",
|
||||
"TRANSPORT",
|
||||
],
|
||||
}
|
||||
`)
|
||||
// The catalog belongs to an AGENT, not to the process: every model-facing row
|
||||
// now lives in a preset mounted under one session's scope, so the global
|
||||
// layer holds nothing and a caller must name the agent to see anything. This
|
||||
|
||||
@@ -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 docs/config-catalog.md
|
||||
config-catalog.md: 4f22ed3da7de81f94d6fc5ee55a305d117c126e7
|
||||
config-catalog.zh.md: 7054ec8b52a8c46bc1ace97112f086119a61cfec
|
||||
config-catalog.md: b3dc82000c5b11be0dccf6e0132911a283547513
|
||||
config-catalog.zh.md: 4165d92956fdbfe6b6de6ec98289ceee5362d2ca
|
||||
|
||||
@@ -898,8 +898,13 @@ Source: [`packages/llm/llm-deepseek/src/index.ts:62`](../packages/llm/llm-deepse
|
||||
Requires: `llm`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin configuration: the provider routes this instance owns. */
|
||||
/** Plugin configuration: the provider routes this instance owns and their shared defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Retry policy inherited by every provider profile that omits its own
|
||||
* `retryPolicy`; omission here uses the bounded normal defaults.
|
||||
*/
|
||||
defaultRetryPolicy?: RetryPolicyConfig
|
||||
/**
|
||||
* pi-ai provider routes, keyed by provider. An empty (or omitted) dict is
|
||||
* the dormant settings-driven posture: the adapter mounts with no routes
|
||||
@@ -983,7 +988,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. */
|
||||
/** Provider-owned model-request retry policy; omission inherits the adapter default, then normal defaults. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
@@ -1079,7 +1084,7 @@ type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template'
|
||||
|
||||
Depends on: `Api` (`@earendil-works/pi-ai`) · `CacheRetention` (`@earendil-works/pi-ai`) · `Model` (`@earendil-works/pi-ai`) · `ModelThinkingLevel` (`@earendil-works/pi-ai`) · `OpenAICompletionsCompat` (`@earendil-works/pi-ai`) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets` (`@earendil-works/pi-ai`) · `Transport` (`@earendil-works/pi-ai`)
|
||||
|
||||
Source: [`packages/llm/llm-pi-ai/src/config.ts:172`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
Source: [`packages/llm/llm-pi-ai/src/config.ts:174`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
|
||||
<a id="deepseek-aidsh-llm-replay"></a>
|
||||
|
||||
|
||||
@@ -900,8 +900,13 @@ export interface DeepSeekCatalogModel {
|
||||
需要:`llm`
|
||||
|
||||
```ts config-catalog
|
||||
/** Plugin configuration: the provider routes this instance owns. */
|
||||
/** Plugin configuration: the provider routes this instance owns and their shared defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Retry policy inherited by every provider profile that omits its own
|
||||
* `retryPolicy`; omission here uses the bounded normal defaults.
|
||||
*/
|
||||
defaultRetryPolicy?: RetryPolicyConfig
|
||||
/**
|
||||
* pi-ai provider routes, keyed by provider. An empty (or omitted) dict is
|
||||
* the dormant settings-driven posture: the adapter mounts with no routes
|
||||
@@ -985,7 +990,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. */
|
||||
/** Provider-owned model-request retry policy; omission inherits the adapter default, then normal defaults. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
@@ -1081,7 +1086,7 @@ type WithheldThinkingFormat = 'chat-template' | 'qwen-chat-template'
|
||||
|
||||
依赖:`Api`(`@earendil-works/pi-ai`)· `CacheRetention`(`@earendil-works/pi-ai`)· `Model`(`@earendil-works/pi-ai`)· `ModelThinkingLevel`(`@earendil-works/pi-ai`)· `OpenAICompletionsCompat`(`@earendil-works/pi-ai`)· [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) · `ThinkingBudgets`(`@earendil-works/pi-ai`)· `Transport`(`@earendil-works/pi-ai`)
|
||||
|
||||
来源:[`packages/llm/llm-pi-ai/src/config.ts:172`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
来源:[`packages/llm/llm-pi-ai/src/config.ts:174`](../packages/llm/llm-pi-ai/src/config.ts)
|
||||
|
||||
<a id="deepseek-aidsh-llm-replay"></a>
|
||||
|
||||
|
||||
@@ -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/bundle/web-app/README.md
|
||||
README.md: 90d1566b5a7a25f6a827079c4a8ab776e05cc7a3
|
||||
README.zh.md: b7156bcab66b53964bb2f0e1c87d9e9a806e3f75
|
||||
README.md: 1593b801154bcd43625f8aadbf7ca2a0f6df69a2
|
||||
README.zh.md: 605d207f3c7870fad9cf1174f8873d1904684c39
|
||||
|
||||
@@ -4,6 +4,10 @@ English | [中文](README.zh.md)
|
||||
|
||||
The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, the always-on client-plugin reload chain ([`dsh-client-hmr`](../../client/hmr/README.md), idle until a rebuild watcher rewrites client bundles), and mounts this package's `web-runtime` glue plugin (config `{printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-web-frontend`'s exports, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL` runtime variable when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. It rejects `--host 0.0.0.0` before publishing that service because the CLI intentionally does not support all-interfaces binding yet. Flag-configured rows inject the service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle.
|
||||
|
||||
## Model retry defaults
|
||||
|
||||
The Web layer configures normal-mode retries with `maxRetries: 5`, allowing at most five eligible retries after the initial request. This applies to the shipped `deepseek-official` route and to every pi-ai provider profile that omits its own `retryPolicy`. The `llm-deepseek` user-settings section, a pi-ai profile-level policy, or a later profile patch may replace the corresponding deployment default; non-Web profiles retain their own policies.
|
||||
|
||||
## Model Experience
|
||||
|
||||
### Harness-source and Web-surface context
|
||||
|
||||
@@ -4,6 +4,10 @@
|
||||
|
||||
dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)、浏览器插件名录与始终挂载的客户端插件重载链([`dsh-client-hmr`](../../client/hmr/README.md),在重建 watcher 改写客户端 bundle 之前保持空闲),并挂载本包的 `web-runtime` 粘合插件(配置为 `{printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-web-frontend` 的 exports 解析已构建的前端 dist,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。它会在发布该服务前拒绝 `--host 0.0.0.0`,因为 CLI 目前有意不支持绑定所有网络接口。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。
|
||||
|
||||
## 模型重试默认值
|
||||
|
||||
Web 层会配置 normal 模式重试与 `maxRetries: 5`,使其在初次请求后最多再重试 5 次符合条件的失败。该默认值适用于随附的 `deepseek-official` 路由,以及每个未自行配置 `retryPolicy` 的 pi-ai 提供方 profile。`llm-deepseek` 用户设置分节、pi-ai profile 级策略或更后面的 profile patch 可以替换对应的部署默认值;非 Web profile 仍使用各自的策略。
|
||||
|
||||
## 模型体验
|
||||
|
||||
### Harness 源码与 Web 表层上下文
|
||||
|
||||
@@ -40,6 +40,21 @@
|
||||
# once the web UI owns the choice per session.
|
||||
mode: !!js process.env.DSH_TOOLS_MODE
|
||||
|
||||
# The Web deployment gives its model routes a larger bounded transient-failure
|
||||
# budget than the provider omission default. DeepSeek settings can override
|
||||
# its policy, and each pi-ai provider profile can override the inherited one.
|
||||
- id: llm-deepseek
|
||||
config:
|
||||
retryPolicy:
|
||||
mode: normal
|
||||
maxRetries: 5
|
||||
|
||||
- id: llm-pi-ai
|
||||
config:
|
||||
defaultRetryPolicy:
|
||||
mode: normal
|
||||
maxRetries: 5
|
||||
|
||||
# ── web-only host rows, the transport layer, and the browser roster ─────────
|
||||
|
||||
# `dsh.client` rows are the browser roster the modules node half scans into
|
||||
|
||||
@@ -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: d775e72616822ce0deee063ac0f3fc453af1a126
|
||||
README.zh.md: 621d67d1c181c6d4c78ea0078f521acccce92653
|
||||
README.md: d67db4014fabf0139df938c1a68132cadb7ad118
|
||||
README.zh.md: 12808defab58b651b5329d8bb19a15f67ea1963e
|
||||
|
||||
@@ -8,12 +8,16 @@ The package root exposes the Cordis plugin contract, `PiAiAdapter`, and `support
|
||||
|
||||
## Config
|
||||
|
||||
Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route.
|
||||
Configure credentials, the model catalog, and deployment-specific transport settings per provider, keyed by the provider route itself. An optional top-level `defaultRetryPolicy` applies to every profile that does not configure `retryPolicy`; when both are omitted, the core bounded normal defaults apply. `apiKeyEnv` is a credential *reference* resolved per request, so no secret enters this file. Omitting it leaves the route unauthenticated, which for an installed catalog route means pi-ai's provider-native ambient discovery; a configured reference that resolves to nothing fails the request with `MISSING_CREDENTIAL` instead, because falling through would authenticate with whatever unrelated key the environment happens to hold. One credential serves every model on its route.
|
||||
|
||||
```yaml
|
||||
- id: llm
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai'
|
||||
config:
|
||||
# Deployment default inherited by profiles without retryPolicy.
|
||||
defaultRetryPolicy:
|
||||
mode: normal
|
||||
maxRetries: 5
|
||||
providers:
|
||||
# Catalog route: endpoint, protocol, and models all come from pi-ai.
|
||||
openai:
|
||||
@@ -103,7 +107,7 @@ Resolution still fails loud, naming the offending route and model, when a route
|
||||
|
||||
## Dynamic configuration (settings + credentials)
|
||||
|
||||
The adapter reads its profiles through a thunk **once per operation** instead of freezing them 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`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. Without a mounted settings service the entry config alone drives the adapter, unchanged.
|
||||
The adapter reads its profiles through a thunk **once per operation** instead of freezing them 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`, and because `providers` is a dict, the base and the user's `llm-pi-ai:` settings section merge **per provider**: a user can add a route, override one field of a composition route, or point a route at another proxy, all effective on the next request with no restart. The top-level `defaultRetryPolicy` participates in the same layering; changing it changes every inheriting route's registration fact and atomically replaces the adapter registration, while a profile-level policy's resolved value remains unchanged. Without a mounted settings service the entry config alone drives the adapter, unchanged.
|
||||
|
||||
Credentials resolve per stream call through `apiKeyEnv` and the optional `ctx.credentials` seam; without that seam, the adapter reads exactly the referenced environment variable. A profile naming no credential at all — and only that case — defers to pi-ai's ambient discovery. Every resolved key is trimmed and format-checked before use, so a value no HTTP header can carry is refused instead of surfacing as an opaque `fetch` `TypeError`; the refusal throws `LlmError('INVALID_CREDENTIAL')` naming the failing route and credential reference but never any part of the key. The route set and each route's captured retry policy are the registration-level facts: when either changes, the plugin replaces its registration atomically (same adapter instance, candidate set validated first), so a route another adapter already owns leaves the previous routes serving and reverting to a working configuration re-applies. Provider key order never counts as a change. A section this adapter could not serve is refused where it is written — the registered `validate` resolves the whole profile set, so `ctx.settings.mutate` rejects with the resolver's own error (the wire surface reports it as `settings-rejected`) and nothing is stored. A stored section that becomes unserviceable some other way — an external edit of `settings.yaml` — keeps the namespace's last good value at the settings seam and warns. The entry config itself still fails plugin load, and a route the llm registry refuses (one another adapter family already owns) is logged while the previously registered routes keep serving.
|
||||
|
||||
@@ -113,7 +117,7 @@ A model that carries reasoning metadata — from the installed catalog or from i
|
||||
|
||||
A model **without** that metadata — a hand-declared one whose entry declares no `reasoningEfforts`, and a catalog model pi-ai marks as non-reasoning — exposes no `reasoning` at all. pi-ai reports such a model as supporting the single level `off`, but `off` is translated to *omitting* the reasoning option, which is byte-for-byte the request that naming no effort already produces: selecting it could not disable anything, so a provider whose own default is to think would keep thinking with `off` shown as selected. Reporting the capability as unavailable leaves a surface offering the provider's default and nothing that misrepresents it. 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 a level absent from the exact model capability fails the REQUEST with `UNSUPPORTED_REASONING_EFFORT` before network I/O instead of being clamped. Describing a model never fails that way: the models under one provider disagree about which levels they accept, so `resolveModel` reports a profile level the exact model cannot take as no default at all rather than throwing. A throw there would take the whole provider out of every model catalog built over it — one mis-set profile field hiding even the models that do support the level — so a bad configuration surfaces where it is acted on, not where it is described. pi-ai's common stream options represent `off` by omitting `reasoning`.
|
||||
|
||||
Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `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.
|
||||
The top-level configuration supports `defaultRetryPolicy`. Supported profile fields are `apiKeyEnv`, `displayName`, `api`, `baseURL`, `models`, `modelOverrides`, `compat`, `defaultContextWindow`, `defaultMaxTokens`, `defaultInput`, `headers`, `reasoning`, `thinkingBudgets`, `cacheRetention`, `transport`, `timeoutMs`, `websocketConnectTimeoutMs`, `streamIdleTimeoutMs`, and `retryPolicy`. Each profile's resolved retry policy is captured with that provider route: its own policy wins, then the adapter default, then the bounded normal omission 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`.
|
||||
|
||||
@@ -201,4 +205,4 @@ Recorded response content appends to the next request and does not invalidate it
|
||||
- **`GenerateOptions.stop` is unsupported** — pi-ai's common stream options cannot guarantee stop-sequence behavior across providers, so the adapter rejects the field.
|
||||
- **In-history `system` messages use pi-ai's common context conversion** — provider-specific placement follows pi-ai rather than a harness-owned wire override.
|
||||
- **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers; failures expose only stable harness error codes.
|
||||
- **Retry policy is provider-owned, not an SDK retry** — each provider profile may configure nested `retryPolicy`, which `dsh-llm-retry` executes at the agent failed-step extension point; pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt.
|
||||
- **Retry policy is provider-owned, not an SDK retry** — the adapter may supply `defaultRetryPolicy`, and each provider profile may override it with nested `retryPolicy`; the resolved route policy is what `dsh-llm-retry` executes at the agent failed-step extension point. pi-ai SDK retries stay disabled so durable agent steps and `llm/retry` events own every visible attempt, and direct `ctx.llm.stream()` calls remain single-attempt.
|
||||
|
||||
@@ -8,12 +8,16 @@
|
||||
|
||||
## 配置
|
||||
|
||||
按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。
|
||||
按提供方配置凭据、模型 catalog 与部署特定传输设置,并以提供方路由本身为键。可选的顶层 `defaultRetryPolicy` 会应用于每个未配置 `retryPolicy` 的 profile;两者都省略时使用核心层有界的 normal 默认值。`apiKeyEnv` 是按请求解析的凭据*引用*,因此机密不进入该文件。省略它会让该路由处于未认证状态;对已安装 catalog 路由而言,这意味着交给 pi-ai 的提供方原生环境发现。已配置却解析不出任何值的引用则相反,会让请求以 `MISSING_CREDENTIAL` 失败,因为放行下去就会用环境里恰好持有的某个无关密钥完成认证。一条凭据服务该路由下的全部模型。
|
||||
|
||||
```yaml
|
||||
- id: llm
|
||||
name: '@deepseek-ai/dsh-llm-pi-ai'
|
||||
config:
|
||||
# Deployment default inherited by profiles without retryPolicy.
|
||||
defaultRetryPolicy:
|
||||
mode: normal
|
||||
maxRetries: 5
|
||||
providers:
|
||||
# Catalog route: endpoint, protocol, and models all come from pi-ai.
|
||||
openai:
|
||||
@@ -104,7 +108,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
## 动态配置(settings + credentials)
|
||||
|
||||
适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。
|
||||
适配器经由一个 thunk **每操作读取一次** profile,而非在构造期冻结。插件在可选的 `ctx.settings` seam 上用同一份 `Config` schema 注册 `llm-pi-ai` namespace,并以其 `cordis.yml` 条目为组合 `base`;由于 `providers` 是字典,base 与用户的 `llm-pi-ai:` settings 分节**按提供方**合并:用户可以新增路由、覆盖组合路由的单个字段,或把路由指向另一个 proxy,全部在下一次请求生效,无需重启。顶层 `defaultRetryPolicy` 参与同一套分层;修改它会改变每条继承路由的注册事实,并原子地替换适配器注册,而 profile 级策略的解析值保持不变。未挂载 settings 服务时,仅由 entry 配置驱动适配器,行为不变。
|
||||
|
||||
凭据在每次流调用时通过 `apiKeyEnv` 与可选的 `ctx.credentials` seam 解析;未挂载该 seam 时,适配器只读取该引用指向的环境变量。只有完全没有点名任何凭据的 profile——仅限这一种情况——才交给 pi-ai 的环境发现。每个解析出的密钥都会在使用前去除首尾空白并校验格式,因此 HTTP 标头无法承载的值会被拒绝,而不是以语义不明的 `fetch` `TypeError` 形式浮现;这种拒绝会抛出 `LlmError('INVALID_CREDENTIAL')`,点名失败的路由与凭据引用,但绝不透露密钥的任何部分。路由集合与每条路由捕获的重试策略是注册级事实:两者任一变化时,插件都会原子地替换自己的注册(同一适配器实例,候选集合先经校验),因此某条路由若已被另一适配器占有,先前的路由会继续服务,而改回可用配置时注册会重新生效。提供方键的顺序绝不算作变化。本适配器无法服务的分节会在写入处被拒——注册的 `validate` 会解析整份 profile 集合,因此 `ctx.settings.mutate` 以 resolver 自身的错误拒绝(该协议将其报为 `settings-rejected`),什么都不会存储。已存储分节若因其他途径变得不可服务——比如外部编辑了 `settings.yaml`——则由 settings seam 保留该 namespace 最后可用的值并告警。entry 配置本身仍会使插件加载失败;而 llm 注册表拒绝的路由(已被另一适配器族占有的那种)会被记录下来,先前注册的路由继续服务。
|
||||
|
||||
@@ -114,7 +118,7 @@ profile 的 `models` 列表是*替换*该路由已安装 catalog,而不是扩
|
||||
|
||||
**没有**这份元数据的模型——条目未声明 `reasoningEfforts` 的手工声明模型,以及 pi-ai 标记为不具备推理能力的 catalog 模型——完全不公开 `reasoning`。pi-ai 会把这类模型报告为只支持 `off` 一档,但 `off` 会被翻译成*省略* reasoning 选项,而那与「不点名任何档位」产出的请求逐字节相同:选它关不掉任何东西,于是自身默认就在思考的提供方,会在界面显示 `off` 被选中的同时继续思考。把该能力报告为不可用,界面就只剩提供方默认这一项,不会再出现自相矛盾的控件。配置 profile 的 `reasoning` 值(包括 `off`)在存在时是部署默认值;省略它会保留提供方默认值。每次请求的 `GenerateOptions.reasoningEffort` 优先;未出现在确切模型能力中的档位会让**请求**在网络 I/O 前以 `UNSUPPORTED_REASONING_EFFORT` 失败,而不会被自动调整。**描述**一个模型则从不这样失败:同一提供方下各模型接受的档位并不一致,因此 `resolveModel` 对该模型拿不下的 profile 档位报告为「没有默认值」,而不是抛错。在那里抛错会让整个提供方从任何基于它构建的模型目录中消失——一个配错的 profile 字段连支持该档位的模型也一并藏起来——所以坏配置暴露在被执行处,而不是被描述处。pi-ai 的通用流选项通过省略 `reasoning` 表示 `off`。
|
||||
|
||||
受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 的可选重试策略都会与该提供方路由一同捕获;省略时使用有界的常规默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
|
||||
顶层配置支持 `defaultRetryPolicy`。受支持的 profile 字段是 `apiKeyEnv`、`displayName`、`api`、`baseURL`、`models`、`modelOverrides`、`compat`、`defaultContextWindow`、`defaultMaxTokens`、`defaultInput`、`headers`、`reasoning`、`thinkingBudgets`、`cacheRetention`、`transport`、`timeoutMs`、`websocketConnectTimeoutMs`、`streamIdleTimeoutMs` 和 `retryPolicy`。每个 profile 解析后的重试策略都会与该提供方路由一同捕获:自身策略优先,其次是适配器默认值,最后是有界的 normal 省略默认值。流空闲间隔必须是正的有限 Node 定时器延迟,默认为五分钟,且只覆盖未完成提供方读取,不包括消费方思考时间。若已配置标头中有同名项,则以 Harness 应用归因为准。
|
||||
|
||||
适配器强制 pi-ai SDK `maxRetries` 为零,因此一次 `stream()` 调用只会发起一次提供方请求。已移除 profile 字段 `maxRetries` 和 `maxRetryDelayMs` 会使加载失败,而不是静默倍增或隐藏单独组合的 agent(智能体)级重试预算。空闲超时会 abort SDK 的稳定请求信号,并以 `TIMEOUT` 呈现;较早的调用方 abort 仍为 `ABORTED`。
|
||||
|
||||
@@ -202,4 +206,4 @@ pi-ai 事件会变为 harness 推理、文本、工具调用、usage 与 finish
|
||||
- **不支持 `GenerateOptions.stop`**:pi-ai 的通用流选项无法保证所有提供方都支持 stop sequence,因此适配器会拒绝该字段。
|
||||
- **历史中的 `system` 消息使用 pi-ai 通用上下文转换**:提供方特定位置由 pi-ai 决定,而非由 harness 拥有的协议覆盖决定。
|
||||
- **无法获取提供方 HTTP 状态**:pi-ai 错误事件不会在所有提供方上公开稳定 HTTP 状态;失败只公开稳定 harness 错误 code。
|
||||
- **重试策略由提供方持有,而不是 SDK 重试**:每个提供方 profile 都可以配置嵌套的 `retryPolicy`,由 `dsh-llm-retry` 在 agent 的失败步骤扩展点上执行;pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。
|
||||
- **重试策略由提供方持有,而不是 SDK 重试**:适配器可以提供 `defaultRetryPolicy`,每个提供方 profile 也可以用嵌套的 `retryPolicy` 覆盖它;`dsh-llm-retry` 在 agent 的失败步骤扩展点上执行解析后的路由策略。pi-ai SDK 重试仍保持禁用,因此持久化的 agent 步骤与 `llm/retry` 事件记录每次可见尝试,直接 `ctx.llm.stream()` 调用仍只尝试一次。
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
/**
|
||||
* 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.
|
||||
* user-settings layer merge per provider and the route set is structural. An
|
||||
* adapter-level retry default lets a deployment choose one policy for every
|
||||
* route that does not override it without changing the core omission default.
|
||||
*
|
||||
* A route key is not required to name an installed pi-ai provider. When it does,
|
||||
* that provider's endpoint, protocol, display name, and model catalog are the
|
||||
@@ -136,7 +138,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. */
|
||||
/** Provider-owned model-request retry policy; omission inherits the adapter default, then normal defaults. */
|
||||
retryPolicy?: RetryPolicyConfig
|
||||
}
|
||||
|
||||
@@ -168,8 +170,13 @@ export interface ResolvedPiAiProviderProfile
|
||||
configuredMaxTokens: ReadonlyMap<string, number>
|
||||
}
|
||||
|
||||
/** Plugin configuration: the provider routes this instance owns. */
|
||||
/** Plugin configuration: the provider routes this instance owns and their shared defaults. */
|
||||
export interface Config {
|
||||
/**
|
||||
* Retry policy inherited by every provider profile that omits its own
|
||||
* `retryPolicy`; omission here uses the bounded normal defaults.
|
||||
*/
|
||||
defaultRetryPolicy?: RetryPolicyConfig
|
||||
/**
|
||||
* pi-ai provider routes, keyed by provider. An empty (or omitted) dict is
|
||||
* the dormant settings-driven posture: the adapter mounts with no routes
|
||||
@@ -253,6 +260,7 @@ const profile = z.object({
|
||||
|
||||
/** Runtime schema for {@link Config}. */
|
||||
export const Config: z<Config> = z.object({
|
||||
defaultRetryPolicy: RetryPolicySchema,
|
||||
providers: z.dict(profile).default({}),
|
||||
})
|
||||
|
||||
@@ -269,7 +277,7 @@ export const Config: z<Config> = z.object({
|
||||
* @throws Error naming the route and model that cannot be served.
|
||||
*/
|
||||
export function assertServiceable(config: Config): void {
|
||||
resolveProfiles(config.providers)
|
||||
resolveProfiles(config.providers, config.defaultRetryPolicy)
|
||||
}
|
||||
|
||||
/** Reject removed pre-release profile fields and name their replacements. */
|
||||
@@ -294,16 +302,23 @@ function rejectRemovedFields(provider: string, source: PiAiProviderProfile): voi
|
||||
* Validate profiles and return a detached 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, and each route's models and pi-ai provider are materialized once.
|
||||
* fallback, and each route's models, retry policy, and pi-ai provider are
|
||||
* materialized once.
|
||||
* @param providers - configured provider profiles keyed by route.
|
||||
* @param defaultRetryPolicy - adapter policy inherited by profiles that omit one.
|
||||
* @returns validated profiles in configuration order.
|
||||
*/
|
||||
export function resolveProfiles(
|
||||
providers: Readonly<Record<string, PiAiProviderProfile>> | undefined,
|
||||
defaultRetryPolicy?: RetryPolicyConfig,
|
||||
): 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 resolvedDefaultRetryPolicy = resolveRetryPolicy(
|
||||
defaultRetryPolicy,
|
||||
'llm-pi-ai: defaultRetryPolicy',
|
||||
)
|
||||
const entries = Object.entries(providers ?? {})
|
||||
const resolved = new Map<string, ResolvedPiAiProviderProfile>()
|
||||
for (const [provider, source] of entries) {
|
||||
@@ -354,7 +369,9 @@ export function resolveProfiles(
|
||||
displayName,
|
||||
...apiKeyEnv === undefined ? {} : { apiKeyEnv: credentialRef(apiKeyEnv) },
|
||||
streamIdleTimeoutMs,
|
||||
retryPolicy: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
|
||||
retryPolicy: retryPolicy === undefined
|
||||
? resolvedDefaultRetryPolicy
|
||||
: resolveRetryPolicy(retryPolicy, `llm-pi-ai: provider "${provider}" retryPolicy`),
|
||||
...rest.headers === undefined ? {} : { headers: { ...rest.headers } },
|
||||
...rest.thinkingBudgets === undefined ? {} : { thinkingBudgets: { ...rest.thinkingBudgets } },
|
||||
configuredMaxTokens: catalog.configuredMaxTokens,
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
* pi-ai does not ship is declared outright. Profile facts resolve per request
|
||||
* over the optional `llm-pi-ai` user-settings section and the optional
|
||||
* credential seam, so a changed key, endpoint, model, or knob reaches the next
|
||||
* request without a restart; a changed *route set* (or a route's
|
||||
* request without a restart; a changed *route set* (or a route's resolved,
|
||||
* registration-captured retry policy) re-registers the same adapter instance
|
||||
* in place.
|
||||
*
|
||||
@@ -13,6 +13,10 @@
|
||||
* - id: llm
|
||||
* name: '@deepseek-ai/dsh-llm-pi-ai'
|
||||
* config:
|
||||
* # Optional deployment default; a provider profile may override it.
|
||||
* defaultRetryPolicy:
|
||||
* mode: normal
|
||||
* maxRetries: 5
|
||||
* providers:
|
||||
* # Catalog route: everything but the credential comes from pi-ai.
|
||||
* openai:
|
||||
@@ -165,7 +169,7 @@ export function apply(ctx: Context, config: Config): void {
|
||||
const profiles = (): ReadonlyMap<string, ResolvedPiAiProviderProfile> => {
|
||||
const raw = current()
|
||||
if (raw === lastRaw && memoized !== undefined) return memoized
|
||||
const next = resolveProfiles(raw.providers)
|
||||
const next = resolveProfiles(raw.providers, raw.defaultRetryPolicy)
|
||||
lastRaw = raw
|
||||
memoized = next
|
||||
return next
|
||||
|
||||
@@ -752,6 +752,27 @@ describe('provider profile lifecycle', () => {
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('inherits the adapter retry default unless a provider profile overrides it', () => {
|
||||
const profiles = resolveProfiles({
|
||||
openai: {},
|
||||
anthropic: { retryPolicy: { mode: 'normal', maxRetries: 1 } },
|
||||
}, { mode: 'normal', maxRetries: 5 })
|
||||
|
||||
expect(profiles.get('openai')?.retryPolicy).toMatchObject({ mode: 'normal', maxRetries: 5 })
|
||||
expect(profiles.get('anthropic')?.retryPolicy).toMatchObject({ mode: 'normal', maxRetries: 1 })
|
||||
})
|
||||
|
||||
it('rejects an invalid adapter retry default even while dormant', async () => {
|
||||
const invalid = { mode: 'normal', maxRetries: -1 } as const
|
||||
expect(() => resolveProfiles(undefined, invalid)).toThrow(/defaultRetryPolicy/)
|
||||
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(LlmRuntime)
|
||||
await expect(ctx.plugin(LlmPiAi, { defaultRetryPolicy: invalid }))
|
||||
.rejects.toThrow(/defaultRetryPolicy|retryPolicy/)
|
||||
expect(ctx.llm.listProviders()).toEqual([])
|
||||
})
|
||||
|
||||
it('constructs the adapter directly and rejects routes it does not own', async () => {
|
||||
const adapter = adapterOf({ openai: {} })
|
||||
await expect(adapter.listModels('anthropic')).rejects.toMatchObject({ code: 'NO_ADAPTER' })
|
||||
|
||||
@@ -156,6 +156,26 @@ describe('request-level dynamic profiles', () => {
|
||||
expect(ctx.llm.listProviders().map(provider => provider.id)).toEqual(['openai'])
|
||||
})
|
||||
|
||||
it('re-registers inherited policies when the adapter retry default changes', async () => {
|
||||
const dir = await home()
|
||||
const ctx = await boot(dir, {
|
||||
defaultRetryPolicy: { mode: 'normal', maxRetries: 5 },
|
||||
providers: {
|
||||
openai: {},
|
||||
anthropic: { retryPolicy: { mode: 'normal', maxRetries: 1 } },
|
||||
},
|
||||
})
|
||||
|
||||
expect(ctx.llm.providerRetryPolicy('openai')).toMatchObject({ mode: 'normal', maxRetries: 5 })
|
||||
expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({ mode: 'normal', maxRetries: 1 })
|
||||
|
||||
await ctx.settings.update(NS, {
|
||||
defaultRetryPolicy: { mode: 'normal', maxRetries: 4 },
|
||||
})
|
||||
expect(ctx.llm.providerRetryPolicy('openai')).toMatchObject({ mode: 'normal', maxRetries: 4 })
|
||||
expect(ctx.llm.providerRetryPolicy('anthropic')).toMatchObject({ mode: 'normal', maxRetries: 1 })
|
||||
})
|
||||
|
||||
it('refuses a settings write this adapter could not serve, leaving its routes alone', async () => {
|
||||
const dir = await home()
|
||||
const ctx = await boot(dir, { providers: { openai: {} } })
|
||||
|
||||
Reference in New Issue
Block a user