From 692ad119a22fce4bde41add0b35954afdc517116 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 23 Oct 2026 11:52:19 +0800 Subject: [PATCH 001/106] feat(ui-settings-models): edit the provider retry policy in Models settings Refs #7005 --- .../cards.expected.md | 6 + .../catalog-inputs.expected.md | 6 + .../models-settings/declared-edit.expected.md | 6 + .../models-settings/image-only.expected.md | 6 + .../default-models.expected.md | 6 + .../models.expected.md | 6 + .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 4 +- .../client/ui-settings-models/README.zh.md | 4 +- .../src/client/ModelsSection.module.css | 11 + .../src/client/ProviderEditor.tsx | 43 ++- .../src/client/RetryPolicyEditor.tsx | 254 ++++++++++++++++++ .../ui-settings-models/src/client/locales.ts | 48 ++++ .../tests/retry-policy.client.spec.tsx | 206 ++++++++++++++ 14 files changed, 602 insertions(+), 8 deletions(-) create mode 100644 packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx create mode 100644 packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 120b33549a..4fd9a6d3aa 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -23,6 +23,12 @@ - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 + - group "重试策略": + - text: 重试策略 + - combobox "重试模式": + - option "默认(仅重试临时错误)" [selected] + - option "仅重试临时错误" + - option "重试所有错误" - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - button "调整模型顺序 1" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index fd9cc97859..953d1bbb90 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -28,6 +28,12 @@ - text: 自定义设置 API 地址 - textbox "API 地址": - /placeholder: https://api.openai.com/v1 + - group "重试策略": + - text: 重试策略 + - combobox "重试模式": + - option "默认(仅重试临时错误)" [selected] + - option "仅重试临时错误" + - option "重试所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index be77f7ceee..2af0ca2ea7 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -36,6 +36,12 @@ - option "OpenAI Chat Completions" [selected] - option "OpenAI Responses" - option "Anthropic Messages" + - group "重试策略": + - text: 重试策略 + - combobox "重试模式": + - option "默认(仅重试临时错误)" [selected] + - option "仅重试临时错误" + - option "重试所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index ea3046ce5b..37cf5f5f1d 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -36,6 +36,12 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - group "重试策略": + - text: 重试策略 + - combobox "重试模式": + - option "默认(仅重试临时错误)" [selected] + - option "仅重试临时错误" + - option "重试所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index bde82cec8f..190c712ce6 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -23,6 +23,12 @@ - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 + - group "重试策略": + - text: 重试策略 + - combobox "重试模式": + - option "默认(仅重试临时错误)" [selected] + - option "仅重试临时错误" + - option "重试所有错误" - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - button "调整模型顺序 1" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 55526bebec..77e10d704d 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -23,6 +23,12 @@ - textbox "API 地址": - /placeholder: https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL) - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 + - group "重试策略": + - text: 重试策略 + - combobox "重试模式": + - option "默认(仅重试临时错误)" [selected] + - option "仅重试临时错误" + - option "重试所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 53f1ab1e77..71fe226184 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,8 +21,8 @@ en: f4817eb2e3537ed9 zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: a4ac56de00a7e59b - zh: 9b3f914dc43393c7 + en: ac1fc6f822d8c779 + zh: 0587c714d3af21f0 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: c0b04b2b11af60d3 zh: d195eec281e445cb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 0a65e5a7d2..0427c8d357 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -55,7 +55,9 @@ An endpoint without a fixed catalog default shows the provider's `baseURLEnv` co The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. There is no provider-scoped reasoning-effort field: effort is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, input types, and, on a hand-declared pi-ai route, reasoning levels; unrelated model fields survive edits. -The DeepSeek card edits the `llm-deepseek` endpoint, credentials, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. +The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Retry transient errors** or **Retry every error** writes a user-layer policy. Transient mode edits the maximum retry count and the comma-separated retryable error codes; both modes edit the initial delay, maximum delay, and jitter ratio. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode edits under the inherited mode. Choosing the default again removes the user-layer policy. + +The DeepSeek card edits the `llm-deepseek` endpoint, credentials, retry policy, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. Drag a model’s reorder handle to change its position within the provider, or focus the handle and use the up and down arrow keys. Apply saves the order; Cancel discards it. Each model keeps its settings, expanded options, and unfinished capacity text when moved. The full-row drag preview and reduced-motion-aware row animations share primitives with workspace reordering; before/after insertion markers follow the same style. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index a99154c6db..4a4e929429 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -55,7 +55,9 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。这里没有提供商级的推理等级字段:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens`、输入类型,以及手工声明的 pi-ai 路由上的推理等级;无关的模型字段在编辑后仍会保留。 -`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 +同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅重试临时错误**或**重试所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和逗号分隔的可重试错误码;两种模式都编辑初始延迟、最大延迟和抖动比例。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略按继承的模式编辑。重新选择默认会移除用户层策略。 + +`llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据、重试策略和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 拖动模型的排序手柄可调整它在当前提供商内的位置,也可以聚焦手柄后使用上下方向键。保存会保留顺序,取消会放弃修改。移动时,每个模型的参数、已展开选项和未完成的容量输入都会随模型保留。整行拖拽预览及遵循减少动态效果设置的行动画,与工作区排序共用基础组件;前后插入标记沿用相同样式。 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index 51995fc1ae..cb2316af7e 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -623,6 +623,17 @@ padding: 8px 4px 2px; } +/* Retry-policy fields: the full-width codes, then the numeric fields in as many columns as fit. */ +.retryFields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + gap: 8px; +} + +.retryWide { + grid-column: 1 / -1; +} + .modelField { display: flex; flex-direction: column; diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 262f8c9d55..a0f65f61d7 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -6,9 +6,9 @@ * has none. The pi-ai profile records that derivation as `apiKeyEnv` only when * a key is entered; a blank key materializes a reference-free profile for * provider-native authentication); - * the collapsed 自定义设置 area carries the per-family extras (`baseURL` for - * both families, DeepSeek's id/name/context-window model catalog, and the - * display name and wire protocol of a pi-ai route the adapter does not ship — + * the collapsed 自定义设置 area carries the per-family extras (`baseURL` and + * `retryPolicy` for both families, DeepSeek's id/name/context-window model + * catalog, and the display name and wire protocol of a pi-ai route the adapter does not ship — * the two fields the create card asked that route for, editable here for the * same reason). * Reasoning effort is deliberately absent: it is a per-MODEL capability, and @@ -41,6 +41,7 @@ import type { ModelsOperations } from './operations.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import type { en } from './locales.ts' import { ApiKeyInput } from './ApiKeyInput.tsx' +import { RetryPolicyEditor, retryPolicyDefaults, retryPolicyFailure } from './RetryPolicyEditor.tsx' import styles from './ModelsSection.module.css' /** Per-adapter-family curated field sets (unknown namespaces get the hint alone). */ @@ -185,6 +186,7 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const { onBusyChange } = props useEffect(() => { onBusyChange?.(busy || listBusy) }, [busy, listBusy, onBusyChange]) const [failure, setFailure] = useState(undefined) + const [customizedOpen, setCustomizedOpen] = useState(false) // A settings success advances both retry baselines immediately. Keeping the // derived fields in the draft prevents a pushed namespace refresh from // turning them into deletions when the following credential write is retried. @@ -235,6 +237,13 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const validateModels = (value: unknown): DeepSeekModelsValidationFailure | ReasoningEffortsFailure | undefined => validateDeepSeekModels(value) ?? (editsReasoning ? validateReasoningEfforts(value) : undefined) const modelFailure = validateModels(schema.getPath(draft, ['models'])) + const retryNode = useMemo(() => schema.nodeAtPath(root, [...settingsPath, 'retryPolicy']), [root, schema, settingsPath]) + const retryDefaults = retryNode === undefined + ? undefined + : retryPolicyDefaults(schema, retryNode, schema.getPath(namespace.base, [...settingsPath, 'retryPolicy'])) + const retryFailure = retryNode === undefined || retryDefaults === undefined || props.credentialOnly === true + ? undefined + : retryPolicyFailure(schema, retryNode, schema.getPath(draft, ['retryPolicy']), retryDefaults, t) const keyFailure = apiKeyFailure(keyDraft) // What a probe or a write must carry: the typed key with paste whitespace // removed. A blank field yields an empty string, which both call sites read @@ -437,7 +446,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { {credentialHint === undefined ? null :

{credentialHint}

} {shownKeyFailure === undefined ? null :

{t(shownKeyFailure)}

} } - {props.credentialOnly === true ? null :
+ {props.credentialOnly === true ? null :
{ setCustomizedOpen(event.currentTarget.open) }} + > {t('customized')}
{/* The name and the protocol are the create card's two remaining @@ -511,6 +523,24 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode {
) : null} + {retryDefaults === undefined + ? null + : ( + { + setDraft(current => next === undefined + ? schema.deletePath(current, ['retryPolicy']) + : schema.setPath(current, ['retryPolicy'], next)) + }} + /> + )} {/* Both families edit the same rows through the same contract; only the extras differ — DeepSeek's inherited capacities, pi-ai's endpoint interrogation. */} @@ -565,11 +595,16 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { {`${t('model')} ${String(modelFailure.index + 1)}: ${t(modelFailure.key)}`}

)} + {/* A closed fold hides the fields, so a refused stored policy still explains a disabled Apply. */} + {retryFailure === undefined || customizedOpen + ? null + :

{retryFailure.message}

} > + +const CODES = ['retryableCodes'] as const +const MAX_RETRIES = ['maxRetries'] as const +const INITIAL_DELAY = ['backoff', 'initialDelayMs'] as const +const MAX_DELAY = ['backoff', 'maxDelayMs'] as const +const JITTER = ['backoff', 'jitterRatio'] as const + +type FieldPath = typeof CODES | typeof MAX_RETRIES | typeof INITIAL_DELAY | typeof MAX_DELAY | typeof JITTER + +/** Numeric fields after the codes, as `[policy path, label]`; `maxRetries` applies only to normal mode. */ +const NUMBER_FIELDS: readonly (readonly [FieldPath, ModelsKey])[] = [ + [MAX_RETRIES, 'retryMaxRetries'], + [INITIAL_DELAY, 'retryInitialDelay'], + [MAX_DELAY, 'retryMaxDelay'], + [JITTER, 'retryJitter'], +] + +/** Plain decimal text; prefixed forms such as `0x10` stay invalid text. */ +const DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu + +/** The inherited policy beneath the user layer, plus the schema bounds of its fields. */ +export interface RetryPolicyDefaults { + /** Mode in effect when the user layer names none. */ + mode: 'normal' | 'always' + /** Read the inherited value of one policy field: the composition layer, then the schema default. */ + value: (path: readonly string[]) => unknown + /** Read the schema maximum of one numeric policy field. */ + max: (path: readonly string[]) => number | undefined +} + +/** + * Resolve the inherited policy beneath the user layer. + * @param schema - settings schema operations. + * @param node - the `retryPolicy` schema union. + * @param inherited - the policy the composition layer sets, if any. + * @returns the inherited mode, field values, and field bounds. + */ +export function retryPolicyDefaults( + schema: SettingsSchemaOperations, + node: SchemaNode, + inherited: unknown, +): RetryPolicyDefaults { + const normal = node.list?.find(branch => branch.dict?.['mode']?.value === 'normal') + const field = (path: readonly string[]) => normal === undefined ? undefined : schema.nodeAtPath(normal, path) + return { + mode: schema.getPath(inherited, ['mode']) === 'always' ? 'always' : 'normal', + value: (path) => { + const value = schema.getPath(inherited, path) + if (value !== undefined) return value + const fallback: unknown = field(path)?.meta.default + return fallback + }, + max: (path) => { + const max: unknown = field(path)?.meta.max + return typeof max === 'number' ? max : undefined + }, + } +} + +/** A refused drafted policy: the message and, when one field causes it, that field. */ +export interface RetryPolicyFailure { + message: string + path?: readonly string[] +} + +/** + * The first refused field of a drafted policy, checked as `resolveRetryPolicy` + * checks the merged policy: fields the draft leaves empty, including `mode`, inherit. + * @param schema - settings schema operations. + * @param node - the `retryPolicy` schema union, which bounds every field. + * @param policy - the drafted user-layer policy; undefined inherits. + * @param defaults - the inherited policy beneath the draft. + * @param t - section copy. + * @returns the failure, or undefined when the policy is valid. + */ +export function retryPolicyFailure( + schema: SettingsSchemaOperations, + node: SchemaNode, + policy: unknown, + defaults: RetryPolicyDefaults, + t: (key: ModelsKey) => string, +): RetryPolicyFailure | undefined { + if (policy === undefined) return undefined + const fail = (key: ModelsKey, path: readonly string[]): RetryPolicyFailure => ({ message: t(key), path }) + const number = (path: readonly string[]): number => { + const value = schema.getPath(policy, path) ?? defaults.value(path) + return typeof value === 'number' ? value : NaN + } + const mode = schema.getPath(policy, ['mode']) ?? defaults.mode + if (mode === 'normal') { + const retries = number(MAX_RETRIES) + if (!Number.isSafeInteger(retries) || retries < 0) return fail('retryMaxRetriesInvalid', MAX_RETRIES) + const codes = schema.getPath(policy, CODES) + if (Array.isArray(codes) && new Set(codes).size !== codes.length) return fail('retryCodesDuplicate', CODES) + } + for (const path of [INITIAL_DELAY, MAX_DELAY]) { + const delay = number(path) + const max = defaults.max(path) ?? Infinity + if (!(delay > 0 && delay <= max)) return { message: t('retryDelayInvalid').replace('{max}', () => String(max)), path } + } + if (number(INITIAL_DELAY) > number(MAX_DELAY)) return fail('retryDelayOrder', INITIAL_DELAY) + const jitter = number(JITTER) + if (!(jitter >= 0 && jitter <= 1)) return fail('retryJitterInvalid', JITTER) + const message = schema.validate(node, { ...policy as Record, mode }) + return message === undefined ? undefined : { message } +} + +/** Props of {@link RetryPolicyEditor}. */ +export interface RetryPolicyEditorProps { + /** Drafted user-layer policy; undefined inherits. */ + policy: unknown + /** Inherited policy beneath the draft. */ + defaults: RetryPolicyDefaults + /** Failure from {@link retryPolicyFailure}. */ + failure: RetryPolicyFailure | undefined + /** Id of the element showing the failure message. */ + failureId: string + /** Show the failure message below the fields; otherwise the owner shows it. */ + showFailure: boolean + /** Section copy. */ + t: (key: ModelsKey) => string + /** Disable edits. */ + disabled: boolean + /** Replace the drafted policy; undefined removes the user-layer policy. */ + onChange: (next: Record | undefined) => void +} + +function textOf(value: unknown): string { + if (Array.isArray(value)) return value.join(', ') + return typeof value === 'number' || typeof value === 'string' ? String(value) : '' +} + +/** Set or remove one nested field of a plain policy object, dropping emptied parents. */ +function withField(policy: Record, path: readonly string[], value: unknown): Record { + const [key, ...rest] = path as [string, ...string[]] + const next = { ...policy } + const child = rest.length === 0 + ? value + : withField(typeof next[key] === 'object' && next[key] !== null ? next[key] as Record : {}, rest, value) + if (child === undefined || (typeof child === 'object' && child !== null && Object.keys(child).length === 0)) { + Reflect.deleteProperty(next, key) + } else { + next[key] = child + } + return next +} + +/** + * Render the retry mode and its policy fields. + * @param props - drafted policy, inherited values, and the draft writer. + * @returns the retry-policy field group. + */ +export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode { + const { t, disabled, defaults, failure } = props + const policy = typeof props.policy === 'object' && props.policy !== null + ? props.policy as Record + : undefined + const current = policy ?? {} + const mode = current['mode'] + // A stored policy without a mode inherits the mode beneath it. + const shownMode = mode ?? (policy === undefined ? undefined : defaults.mode) + // Raw text keeps partial input such as `0.` visible while it is typed. + const [texts, setTexts] = useState>({}) + const textAt = (path: FieldPath): string => texts[path.join('.')] ?? textOf(path.reduce( + (value, key) => typeof value === 'object' && value !== null ? (value as Record)[key] : undefined, current, + )) + const edit = (path: FieldPath, text: string): void => { + const trimmed = text.trim() + setTexts(previous => ({ ...previous, [path.join('.')]: trimmed.length === 0 ? '' : text })) + const value = path === CODES + ? trimmed.split(',').map(code => code.trim()).filter(code => code.length > 0) + : DECIMAL.test(trimmed) ? Number(trimmed) : trimmed + const empty = Array.isArray(value) ? value.length === 0 : value === '' + props.onChange(withField(current, path, empty ? undefined : value)) + } + const field = (path: FieldPath, label: ModelsKey): ReactNode => { + const invalid = failure?.path?.join('.') === path.join('.') + return ( + + ) + } + const modeLabel = (value: 'normal' | 'always'): string => t(value === 'normal' ? 'retryModeNormal' : 'retryModeAlways') + return ( +
+ {t('retryPolicy')} + + {shownMode === 'always' ? {t('retryAlwaysHint')} : null} + {shownMode === 'normal' || shownMode === 'always' + ? ( +
+ {shownMode === 'normal' ? field(CODES, 'retryCodes') : null} + {NUMBER_FIELDS.filter(([path]) => shownMode === 'normal' || path !== MAX_RETRIES).map(([path, label]) => field(path, label))} +
+ ) + : null} + {failure === undefined || !props.showFailure + ? null + :

{failure.message}

} +
+ ) +} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index d1bf8aceb3..bd5c3638c2 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -56,6 +56,22 @@ export const en = { baseUrlPlaceholder: '{url}', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Default to use DEEPSEEK_BASE_URL)', deepSeekEndpointHint: 'Use an API endpoint compatible with Anthropic Messages.', + retryPolicy: 'Retry policy', + retryMode: 'Retry mode', + retryModeDefault: 'Default ({mode})', + retryModeNormal: 'Retry transient errors', + retryModeAlways: 'Retry every error', + retryAlwaysHint: 'Failed requests retry until they succeed or the turn is stopped.', + retryMaxRetries: 'Max retries', + retryCodes: 'Retryable error codes', + retryInitialDelay: 'Initial delay (ms)', + retryMaxDelay: 'Max delay (ms)', + retryJitter: 'Jitter ratio', + retryMaxRetriesInvalid: 'Max retries must be a whole number, 0 or more.', + retryCodesDuplicate: 'Each error code may appear once.', + retryDelayInvalid: 'Delays must be positive and at most {max} ms.', + retryDelayOrder: 'Initial delay cannot exceed max delay.', + retryJitterInvalid: 'Jitter ratio must be between 0 and 1.', models: 'Models', modelsInherited: 'Using the adapter defaults', modelsCustomized: 'Customized model catalog', @@ -200,6 +216,22 @@ export const zh: { [Key in keyof typeof en]: string } = { baseUrlPlaceholder: '{url}', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic(默认使用环境 DEEPSEEK_BASE_URL)', deepSeekEndpointHint: '请填写兼容 Anthropic Messages 协议的 API 地址。', + retryPolicy: '重试策略', + retryMode: '重试模式', + retryModeDefault: '默认({mode})', + retryModeNormal: '仅重试临时错误', + retryModeAlways: '重试所有错误', + retryAlwaysHint: '失败的请求会持续重试,直到成功或本轮被停止。', + retryMaxRetries: '最大重试次数', + retryCodes: '可重试的错误码', + retryInitialDelay: '初始延迟 (ms)', + retryMaxDelay: '最大延迟 (ms)', + retryJitter: '抖动比例', + retryMaxRetriesInvalid: '最大重试次数必须是不小于 0 的整数。', + retryCodesDuplicate: '每个错误码只能出现一次。', + retryDelayInvalid: '延迟必须是正数,且不超过 {max} ms。', + retryDelayOrder: '初始延迟不能大于最大延迟。', + retryJitterInvalid: '抖动比例必须在 0 到 1 之间。', models: '模型目录', modelsInherited: '正在使用适配器默认模型', modelsCustomized: '已自定义模型目录', @@ -341,6 +373,22 @@ export const ja = { 'baseUrlPlaceholder': '{url}', 'deepSeekBaseUrl': 'https://api.deepseek.com/anthropic (デフォルトはDEEPSEEK_BASE_URLを使用)', 'deepSeekEndpointHint': 'Anthropic Messagesと互換性のあるAPIエンドポイントを使用してください。', + 'retryPolicy': '再試行ポリシー', + 'retryMode': '再試行モード', + 'retryModeDefault': 'デフォルト ({mode})', + 'retryModeNormal': '一時的なエラーのみ再試行', + 'retryModeAlways': 'すべてのエラーを再試行', + 'retryAlwaysHint': '失敗したリクエストは、成功するかターンが停止されるまで再試行されます。', + 'retryMaxRetries': '最大再試行回数', + 'retryCodes': '再試行対象のエラーコード', + 'retryInitialDelay': '初期遅延 (ms)', + 'retryMaxDelay': '最大遅延 (ms)', + 'retryJitter': 'ジッター比率', + 'retryMaxRetriesInvalid': '最大再試行回数は0以上の整数で指定してください。', + 'retryCodesDuplicate': '各エラーコードは1回のみ指定できます。', + 'retryDelayInvalid': '遅延は {max} ms 以下の正の値で指定してください。', + 'retryDelayOrder': '初期遅延は最大遅延以下にしてください。', + 'retryJitterInvalid': 'ジッター比率は0から1の範囲で指定してください。', 'models': 'モデル', 'modelsInherited': 'アダプターのデフォルトを使用', 'modelsCustomized': 'カスタマイズされたモデルカタログ', diff --git a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx new file mode 100644 index 0000000000..49d7bf6794 --- /dev/null +++ b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx @@ -0,0 +1,206 @@ +// @vitest-environment jsdom +/** Provider retry-policy fields of the Models editor card. */ +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' +import Schema from '@deepseek-ai/schemastery' +import type { SettingsNamespaceView } from '@deepseek-ai/dsh-api-remotes/client' +import type { JsonValue } from '@deepseek-ai/dsh-util-values' +import { ProviderEditor } from '../src/client/ProviderEditor.tsx' +import type { ModelsOperations } from '../src/client/operations.ts' +import { en } from '../src/client/locales.ts' +import { settingsSchema } from './settings-schema.client.ts' + +afterEach(cleanup) + +// The `retryPolicy` schema both adapters embed (`RetryPolicySchema` in dsh-llm). +const backoff = Schema.object({ + initialDelayMs: Schema.number().max(2_147_483_647).default(500), + maxDelayMs: Schema.number().max(2_147_483_647).default(10_000), + jitterRatio: Schema.number().min(0).max(1).default(0.1), +}) +const RetryPolicy = Schema.union([ + Schema.object({ + mode: Schema.const('normal').required(), + maxRetries: Schema.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(5), + retryableCodes: Schema.array(Schema.string()).default(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']), + backoff, + }), + Schema.object({ mode: Schema.const('always').required(), backoff }), +]) + +const json = (schema: Schema): JsonValue => JSON.parse(JSON.stringify(schema.toJSON())) as JsonValue + +function deepSeekNamespace(layers: { base?: JsonValue; user?: JsonValue } = {}): SettingsNamespaceView { + return { + ns: 'llm-deepseek', + schema: json(Schema.object({ + baseURL: Schema.string(), + models: Schema.array(Schema.object({ id: Schema.string() })), + retryPolicy: RetryPolicy, + })), + value: { models: [{ id: 'deepseek-v4-flash' }] }, + base: layers.base ?? {}, + user: layers.user ?? {}, + autoGenerate: true, applies: 'live', secrets: [], revision: 3, + } +} + +function piAiNamespace(): SettingsNamespaceView { + const providers = { openai: { apiKeyEnv: 'OPENAI_API_KEY' } } + return { + ns: 'llm-pi-ai', + schema: json(Schema.object({ providers: Schema.dict(Schema.object({ apiKeyEnv: Schema.string(), retryPolicy: RetryPolicy })) })), + value: { providers }, user: { providers }, + autoGenerate: true, applies: 'live', secrets: [], revision: 0, + } +} + +function setup(namespace: SettingsNamespaceView, settingsPath: string[] = []) { + const writeSettings = vi.fn(() => Promise.resolve({ kind: 'written', view: namespace })) + const operations: ModelsOperations = { + storeCredential: vi.fn(() => Promise.resolve(undefined)), + removeCredential: vi.fn(() => Promise.resolve(undefined)), + writeSettings, + discoverModels: vi.fn(() => Promise.resolve({ kind: 'found', models: [] })), + } + const onClose = vi.fn() + render( en[key]} readOnly={false} onClose={onClose} />) + return { writeSettings, onClose } +} + +const mode = () => screen.getByLabelText(en.retryMode) +const input = (label: string) => screen.getByLabelText(label) +const apply = () => screen.getByText(en.apply) + +describe('retry policy fields', () => { + it('inherits by default and writes an explicitly chosen normal policy', async () => { + const { writeSettings, onClose } = setup(deepSeekNamespace()) + expect(mode().value).toBe('') + expect(mode().selectedOptions[0]?.textContent).toBe(`Default (${en.retryModeNormal})`) + expect(screen.queryByLabelText(en.retryMaxRetries)).toBeNull() + + fireEvent.change(mode(), { target: { value: 'normal' } }) + expect(input(en.retryMaxRetries).placeholder).toBe('5') + expect(input(en.retryCodes).placeholder).toBe('EMPTY_RESPONSE, RATE_LIMIT, SERVER, TIMEOUT, TRANSPORT') + expect(input(en.retryInitialDelay).placeholder).toBe('500') + expect(input(en.retryMaxDelay).placeholder).toBe('10000') + expect(input(en.retryJitter).placeholder).toBe('0.1') + + fireEvent.change(input(en.retryMaxRetries), { target: { value: '3' } }) + fireEvent.change(input(en.retryCodes), { target: { value: ' RATE_LIMIT, ,SERVER ' } }) + fireEvent.change(input(en.retryJitter), { target: { value: '0.' } }) + expect(input(en.retryJitter).value).toBe('0.') + fireEvent.change(input(en.retryJitter), { target: { value: '0.25' } }) + fireEvent.click(apply()) + + await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) + expect(writeSettings).toHaveBeenCalledWith('llm-deepseek', [{ + op: 'set', + path: ['retryPolicy'], + value: { mode: 'normal', maxRetries: 3, retryableCodes: ['RATE_LIMIT', 'SERVER'], backoff: { jitterRatio: 0.25 } }, + }], 3) + }) + + const delayInvalid = en.retryDelayInvalid.replace('{max}', '2147483647') + it.each([ + [en.retryMaxRetries, '1.5', en.retryMaxRetriesInvalid], + [en.retryMaxRetries, 'many', en.retryMaxRetriesInvalid], + [en.retryCodes, 'SERVER, SERVER', en.retryCodesDuplicate], + [en.retryInitialDelay, '0', delayInvalid], + [en.retryInitialDelay, '0x10', delayInvalid], + [en.retryMaxDelay, '3000000000', delayInvalid], + [en.retryInitialDelay, '20000', en.retryDelayOrder], + [en.retryJitter, '2', en.retryJitterInvalid], + ])('refuses %s = %s', (label, value, message) => { + setup(deepSeekNamespace()) + fireEvent.change(mode(), { target: { value: 'normal' } }) + fireEvent.change(input(label), { target: { value } }) + expect(screen.getByText(message)).toBeTruthy() + expect(input(label).getAttribute('aria-invalid')).toBe('true') + expect(input(label).getAttribute('aria-describedby')).toBe('route-retry-failure') + const other = label === en.retryJitter ? en.retryMaxRetries : en.retryJitter + expect(input(other).getAttribute('aria-invalid')).toBe('false') + expect(apply().disabled).toBe(true) + fireEvent.change(input(label), { target: { value: '' } }) + expect(screen.queryByText(message)).toBeNull() + expect(apply().disabled).toBe(false) + }) + + it('explains a refused stored policy beside the fields, or outside the fold while it is closed', async () => { + setup(deepSeekNamespace({ user: { retryPolicy: { mode: 'sometimes' } } })) + expect(apply().disabled).toBe(true) + expect(mode().getAttribute('aria-describedby')).toBe('route-retry-failure') + expect(document.getElementById('route-retry-failure')?.closest('details')).toBeNull() + fireEvent.click(screen.getByText(en.customized)) + await waitFor(() => { expect(document.getElementById('route-retry-failure')?.closest('details')).not.toBeNull() }) + }) + + it('edits a stored policy without a mode under the inherited mode', () => { + setup(deepSeekNamespace({ base: { retryPolicy: { mode: 'normal' } }, user: { retryPolicy: { backoff: { maxDelayMs: 2_000 } } } })) + expect(mode().value).toBe('') + expect(input(en.retryMaxDelay).value).toBe('2000') + expect(input(en.retryMaxRetries).placeholder).toBe('5') + expect(apply().disabled).toBe(false) + }) + + it('drops an uncorrectable retry count when switching to always', async () => { + const { writeSettings } = setup(deepSeekNamespace()) + fireEvent.change(mode(), { target: { value: 'normal' } }) + fireEvent.change(input(en.retryMaxRetries), { target: { value: 'many' } }) + fireEvent.change(input(en.retryJitter), { target: { value: ' ' } }) + expect(input(en.retryJitter).value).toBe('') + fireEvent.change(mode(), { target: { value: 'always' } }) + expect(apply().disabled).toBe(false) + fireEvent.click(apply()) + await waitFor(() => { expect(writeSettings).toHaveBeenCalled() }) + expect(writeSettings.mock.calls[0]?.[1]).toEqual([{ op: 'set', path: ['retryPolicy'], value: { mode: 'always' } }]) + }) + + it('shows inherited values and removes the user policy when Default is chosen', async () => { + const { writeSettings } = setup(deepSeekNamespace({ + base: { retryPolicy: { mode: 'always', backoff: { maxDelayMs: 2_000 } } }, + user: { retryPolicy: { mode: 'normal', maxRetries: 1 } }, + })) + expect(mode().value).toBe('normal') + expect(input(en.retryMaxRetries).value).toBe('1') + expect(input(en.retryMaxDelay).placeholder).toBe('2000') + expect(screen.getByRole('option', { name: `Default (${en.retryModeAlways})` })).toBeTruthy() + + fireEvent.change(mode(), { target: { value: 'always' } }) + expect(screen.getByText(en.retryAlwaysHint)).toBeTruthy() + expect(screen.queryByLabelText(en.retryMaxRetries)).toBeNull() + + fireEvent.change(mode(), { target: { value: '' } }) + expect(screen.queryByLabelText(en.retryMaxDelay)).toBeNull() + fireEvent.click(apply()) + await waitFor(() => { expect(writeSettings).toHaveBeenCalled() }) + expect(writeSettings.mock.calls[0]?.[1]).toEqual([{ op: 'unset', path: ['retryPolicy'] }]) + }) + + it('writes a pi-ai route policy under its profile path', async () => { + const { writeSettings } = setup(piAiNamespace(), ['providers', 'openai']) + fireEvent.change(mode(), { target: { value: 'always' } }) + fireEvent.change(input(en.retryInitialDelay), { target: { value: '250' } }) + fireEvent.change(input(en.retryInitialDelay), { target: { value: '' } }) + fireEvent.click(apply()) + await waitFor(() => { expect(writeSettings).toHaveBeenCalled() }) + expect(writeSettings.mock.calls[0]?.[1]).toEqual([ + { op: 'set', path: ['providers', 'openai', 'retryPolicy'], value: { mode: 'always' } }, + ]) + }) + + it('leaves placeholders empty when the schema declares no normal mode', () => { + const namespace = deepSeekNamespace() + const always = Schema.union([Schema.object({ mode: Schema.const('always').required() })]) + setup({ ...namespace, schema: json(Schema.object({ retryPolicy: always })) }) + fireEvent.change(mode(), { target: { value: 'always' } }) + expect(input(en.retryMaxDelay).placeholder).toBe('') + }) + + it('renders no retry fields when the namespace schema has no retry policy', () => { + const namespace = deepSeekNamespace() + setup({ ...namespace, schema: json(Schema.object({ baseURL: Schema.string() })) }) + expect(screen.queryByRole('group', { name: en.retryPolicy })).toBeNull() + }) +}) From 839918c5ff798dfcbcb8c8b3e1d01a12ada5c24a Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 23 Oct 2026 12:08:03 +0800 Subject: [PATCH 002/106] feat(ui-settings-models): translate retry-policy copy into the new locales --- .../ui-settings-models/src/client/locales.ts | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 86e04c3303..01df4731c1 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -530,6 +530,22 @@ export const es = { baseUrlPlaceholder: '{url}', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Uso predeterminado DEEPSEEK_BASE_URL)', deepSeekEndpointHint: 'Utilice un punto final API compatible con Anthropic Messages.', + retryPolicy: 'Política de reintentos', + retryMode: 'Modo de reintento', + retryModeDefault: 'Predeterminado ({mode})', + retryModeNormal: 'Reintentar errores transitorios', + retryModeAlways: 'Reintentar todos los errores', + retryAlwaysHint: 'Las solicitudes fallidas se reintentan hasta que tienen éxito o se detiene el turno.', + retryMaxRetries: 'Reintentos máximos', + retryCodes: 'Códigos de error reintentables', + retryInitialDelay: 'Retraso inicial (ms)', + retryMaxDelay: 'Retraso máximo (ms)', + retryJitter: 'Proporción de variación aleatoria', + retryMaxRetriesInvalid: 'Los reintentos máximos deben ser un número entero igual o mayor que 0.', + retryCodesDuplicate: 'Cada código de error solo puede aparecer una vez.', + retryDelayInvalid: 'Los retrasos deben ser positivos y como máximo de {max} ms.', + retryDelayOrder: 'El retraso inicial no puede superar el retraso máximo.', + retryJitterInvalid: 'La proporción de variación aleatoria debe estar entre 0 y 1.', models: 'Modelos', modelsInherited: 'Usando los valores predeterminados del adaptador', modelsCustomized: 'Catálogo de modelos personalizado', @@ -671,6 +687,22 @@ export const fr = { baseUrlPlaceholder: '{url}', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (par défaut, DEEPSEEK_BASE_URL est utilisé)', deepSeekEndpointHint: 'Utilisez un point de terminaison API compatible avec Anthropic Messages.', + retryPolicy: 'Politique de nouvelle tentative', + retryMode: 'Mode de nouvelle tentative', + retryModeDefault: 'Par défaut ({mode})', + retryModeNormal: 'Réessayer les erreurs temporaires', + retryModeAlways: 'Réessayer toutes les erreurs', + retryAlwaysHint: "Les requêtes en échec sont réessayées jusqu'à ce qu'elles réussissent ou que le tour soit arrêté.", + retryMaxRetries: 'Nombre maximum de tentatives', + retryCodes: "Codes d'erreur à réessayer", + retryInitialDelay: 'Délai initial (ms)', + retryMaxDelay: 'Délai maximum (ms)', + retryJitter: 'Ratio de gigue', + retryMaxRetriesInvalid: 'Le nombre maximum de tentatives doit être un entier supérieur ou égal à 0.', + retryCodesDuplicate: "Chaque code d'erreur ne peut apparaître qu'une fois.", + retryDelayInvalid: 'Les délais doivent être positifs et ne pas dépasser {max} ms.', + retryDelayOrder: 'Le délai initial ne peut pas dépasser le délai maximum.', + retryJitterInvalid: 'Le ratio de gigue doit être compris entre 0 et 1.', models: 'Modèles', modelsInherited: "Utilisation des valeurs par défaut de l'adaptateur", modelsCustomized: 'Catalogue de modèles personnalisés', @@ -812,6 +844,22 @@ export const de = { baseUrlPlaceholder: '{url}', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (Standardmäßig wird DEEPSEEK_BASE_URL verwendet)', deepSeekEndpointHint: 'Verwenden Sie einen API-Endpunkt, der mit Anthropic Messages kompatibel ist.', + retryPolicy: 'Wiederholungsrichtlinie', + retryMode: 'Wiederholungsmodus', + retryModeDefault: 'Standard ({mode})', + retryModeNormal: 'Vorübergehende Fehler wiederholen', + retryModeAlways: 'Alle Fehler wiederholen', + retryAlwaysHint: 'Fehlgeschlagene Anfragen werden wiederholt, bis sie erfolgreich sind oder der Durchlauf gestoppt wird.', + retryMaxRetries: 'Maximale Anzahl an Wiederholungen', + retryCodes: 'Wiederholbare Fehlercodes', + retryInitialDelay: 'Anfängliche Verzögerung (ms)', + retryMaxDelay: 'Maximale Verzögerung (ms)', + retryJitter: 'Jitter-Anteil', + retryMaxRetriesInvalid: 'Die maximale Anzahl an Wiederholungen muss eine ganze Zahl ab 0 sein.', + retryCodesDuplicate: 'Jeder Fehlercode darf nur einmal vorkommen.', + retryDelayInvalid: 'Verzögerungen müssen positiv sein und dürfen höchstens {max} ms betragen.', + retryDelayOrder: 'Die anfängliche Verzögerung darf die maximale Verzögerung nicht überschreiten.', + retryJitterInvalid: 'Der Jitter-Anteil muss zwischen 0 und 1 liegen.', models: 'Modelle', modelsInherited: 'Verwendung der Adapterstandards', modelsCustomized: 'Maßgeschneiderter Modellkatalog', @@ -953,6 +1001,22 @@ export const ru = { baseUrlPlaceholder: '{url}', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (по умолчанию используется DEEPSEEK_BASE_URL)', deepSeekEndpointHint: 'Используйте конечную точку API, совместимую с антропными сообщениями.', + retryPolicy: 'Политика повторных попыток', + retryMode: 'Режим повторных попыток', + retryModeDefault: 'По умолчанию ({mode})', + retryModeNormal: 'Повторять при временных ошибках', + retryModeAlways: 'Повторять при любых ошибках', + retryAlwaysHint: 'Неудачные запросы повторяются до успеха или до остановки хода.', + retryMaxRetries: 'Максимальное количество повторных попыток', + retryCodes: 'Коды ошибок для повтора', + retryInitialDelay: 'Начальная задержка (ms)', + retryMaxDelay: 'Максимальная задержка (ms)', + retryJitter: 'Коэффициент джиттера', + retryMaxRetriesInvalid: 'Максимальное количество повторных попыток должно быть целым числом не меньше 0.', + retryCodesDuplicate: 'Каждый код ошибки может встречаться только один раз.', + retryDelayInvalid: 'Задержки должны быть положительными и не превышать {max} ms.', + retryDelayOrder: 'Начальная задержка не может превышать максимальную.', + retryJitterInvalid: 'Коэффициент джиттера должен быть от 0 до 1.', models: 'Модели', modelsInherited: 'Использование настроек адаптера по умолчанию', modelsCustomized: 'Индивидуальный каталог моделей', @@ -1094,6 +1158,22 @@ export const pt = { baseUrlPlaceholder: '{url}', deepSeekBaseUrl: 'https://api.deepseek.com/anthropic (padrão para usar DEEPSEEK_BASE_URL)', deepSeekEndpointHint: 'Use um endpoint API compatível com Anthropic Messages.', + retryPolicy: 'Política de novas tentativas', + retryMode: 'Modo de novas tentativas', + retryModeDefault: 'Padrão ({mode})', + retryModeNormal: 'Tentar novamente erros transitórios', + retryModeAlways: 'Tentar novamente todos os erros', + retryAlwaysHint: 'As solicitações com falha são repetidas até terem sucesso ou até o turno ser interrompido.', + retryMaxRetries: 'Máximo de novas tentativas', + retryCodes: 'Códigos de erro que permitem nova tentativa', + retryInitialDelay: 'Atraso inicial (ms)', + retryMaxDelay: 'Atraso máximo (ms)', + retryJitter: 'Proporção de jitter', + retryMaxRetriesInvalid: 'O máximo de novas tentativas deve ser um número inteiro maior ou igual a 0.', + retryCodesDuplicate: 'Cada código de erro só pode aparecer uma vez.', + retryDelayInvalid: 'Os atrasos devem ser positivos e de no máximo {max} ms.', + retryDelayOrder: 'O atraso inicial não pode exceder o atraso máximo.', + retryJitterInvalid: 'A proporção de jitter deve estar entre 0 e 1.', models: 'Modelos', modelsInherited: 'Usando os padrões do adaptador', modelsCustomized: 'Catálogo de modelos personalizados', From 0920903b472beb8d31c2fa81dcab89d1c46bae09 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 23 Oct 2026 13:32:29 +0800 Subject: [PATCH 003/106] fix(ui-settings-models): write retry-policy fields individually and refuse empty codes --- apps/web/tests/models-settings.e2e.ts | 7 +- .../ui-settings-models/README.i18n.yaml | 12 +-- packages/client/ui-settings-models/README.md | 6 +- .../client/ui-settings-models/README.zh.md | 6 +- .../src/client/ProviderEditor.tsx | 39 ++++++--- .../src/client/RetryPolicyEditor.tsx | 56 +++++++------ .../ui-settings-models/src/client/locales.ts | 8 ++ .../tests/components.client.spec.tsx | 4 + .../tests/retry-policy.client.spec.tsx | 80 ++++++++++++++++--- 9 files changed, 159 insertions(+), 59 deletions(-) diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index ac0de149af..fbaebbf240 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -6,8 +6,8 @@ // while the settings document records only that reference. Each saved row // appears after route topology invalidation without presenting liveness as // provider status. The customized-settings fold writes its curated fields — -// the endpoint, a declared route's own name and protocol, and the reasoning -// levels of its models — as merge patches against the stored profile. Zero +// the endpoint, the retry policy, a declared route's own name and protocol, and +// the reasoning levels of its models — as merge patches against the stored profile. Zero // model calls: configuration is pure settings/credentials/llm-domain traffic, // so there is no fixture and a stray stream would fail loud because the // adapter registry is empty. The provider under test is @@ -228,6 +228,8 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const url = dialog.getByLabel('API 地址') await url.waitFor({ timeout: 10_000 }) await url.fill('https://gateway.minimax.example/v1') + await dialog.getByLabel('重试模式', { exact: true }).selectOption('normal') + await dialog.getByLabel('最大重试次数', { exact: true }).fill('3') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The editor closes back to the row; the fold's write merged into the // stored profile beside the reference. @@ -236,6 +238,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const document = await readFile(join(scaffold.harnessHome, 'profiles', 'scaffold', 'cordis.patch.yml'), 'utf8') expect(document).toContain('baseURL: https://gateway.minimax.example/v1') expect(document).toContain('apiKeyEnv: MINIMAX_CN_API_KEY') + expect(document).toMatch(/retryPolicy:\n\s+mode: normal\n\s+maxRetries: 3\n/u) const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd) await compareOrRefreshGolden(CONFIGURED_EXPECTED, snapshot, MODE) expect(tripwire.pageErrors).toEqual([]) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 71fe226184..e4d09ffcb1 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,8 +21,8 @@ en: f4817eb2e3537ed9 zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: ac1fc6f822d8c779 - zh: 0587c714d3af21f0 + en: f18b4e60ceed5704 + zh: 757d5f4726eec4f2 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: c0b04b2b11af60d3 zh: d195eec281e445cb @@ -36,8 +36,8 @@ en: a87afbec5e339b36 zh: 6348c81af7e650e1 /deepseek-ai-dsh-client-ui-settings-models/understand-the-implementation/validation: - en: 74948a88fe1f9aac - zh: 8ad2200fa74c12c9 + en: 1695ec7430ad88e1 + zh: 1df96b8ae82c2e26 /deepseek-ai-dsh-client-ui-settings-models/understand-the-implementation/concurrency-and-credentials: en: 6e04c00818f014cd zh: 2d57f8d8944e67ed @@ -54,8 +54,8 @@ en: ca75c51c89c2c9b0 zh: 9779c98fff4c949a /deepseek-ai-dsh-client-ui-settings-models/known-limitations-and-deferred-work: - en: cc69c9934bf22302 - zh: 4ea896f4b54aa203 + en: 480bf2ddd4b76779 + zh: dfda0c29be636f84 /deepseek-ai-dsh-client-ui-settings-models/known-limitations-and-deferred-work/dev-note: en: e67f633dc6fe7773 zh: 3bb0c20c93f16f98 diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 0427c8d357..dfc0bea959 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -55,7 +55,7 @@ An endpoint without a fixed catalog default shows the provider's `baseURLEnv` co The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. There is no provider-scoped reasoning-effort field: effort is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, input types, and, on a hand-declared pi-ai route, reasoning levels; unrelated model fields survive edits. -The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Retry transient errors** or **Retry every error** writes a user-layer policy. Transient mode edits the maximum retry count and the comma-separated retryable error codes; both modes edit the initial delay, maximum delay, and jitter ratio. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode edits under the inherited mode. Choosing the default again removes the user-layer policy. +The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Retry transient errors** or **Retry every error** writes a user-layer policy. Transient mode edits the maximum retry count and the comma-separated retryable error codes; both modes edit the initial delay, maximum delay, and jitter ratio. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, an empty code list, a blank or non-text code, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or transient mode when none is inherited, and Apply saves that mode. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy. The DeepSeek card edits the `llm-deepseek` endpoint, credentials, retry policy, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. @@ -93,7 +93,7 @@ The page never holds a full settings section: it holds only the REDACTED descrip ### Validation -A typed API key is judged on its own field: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. +A typed API key is judged on its own field: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A drafted `retryPolicy` passes the checks of `resolveRetryPolicy` in `@deepseek-ai/dsh-llm`, mirrored for the same reason, because `llm-deepseek` only logs a warning for a live policy it refuses. Switching to always mode keeps the transient-only fields, which the adapter accepts and ignores, except a retry count that is not a number and could no longer be corrected. The card writes the policy field by field, so an unchanged inherited field stays inherited. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. ### Concurrency and credentials @@ -140,7 +140,7 @@ None; this package neither assembles nor sends a provider request. These limits define the editor's field coverage and the page's reach; they are current package constraints, not a settings roadmap. -- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout. Retry policy, timeouts, DeepSeek model descriptions, and other advanced fields remain in `cordis.patch.yml`; existing model fields the editor does not show are preserved. +- **Only the API key and curated fold fields are editable on the card** — the hand-written editor traded schema-generic field coverage for the mockup layout. Timeouts, DeepSeek model descriptions, and other advanced fields remain in `cordis.patch.yml`; existing model fields the editor does not show are preserved. - **Credential cleanup is intentionally narrow** — deleting a row removes the configured, writable credential only when its reference is the exact `_API_KEY` target this page derives. Custom references, environment credentials, and unidentifiable targets are retained because the row cannot prove ownership of them. - **Only pi-ai routes can be hand-declared** — the custom-API form writes into `llm-pi-ai`, the one namespace whose profiles describe a whole provider. A `llm-deepseek` route is a composition fact, not something this page can create. - **The catalog select lists route identifiers** — `moonshotai`, `zai`, and the other pi-ai catalog ids appear as they are, with no product names, aliases, or search. A searchable picker with the custom form as one pinned entry would let the mode switch go; it needs the directory to carry display names first. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 4a4e929429..0428c09088 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -55,7 +55,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。这里没有提供商级的推理等级字段:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens`、输入类型,以及手工声明的 pi-ai 路由上的推理等级;无关的模型字段在编辑后仍会保留。 -同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅重试临时错误**或**重试所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和逗号分隔的可重试错误码;两种模式都编辑初始延迟、最大延迟和抖动比例。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略按继承的模式编辑。重新选择默认会移除用户层策略。 +同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅重试临时错误**或**重试所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和逗号分隔的可重试错误码;两种模式都编辑初始延迟、最大延迟和抖动比例。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、空的错误码列表、空白或非文本的错误码、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示临时错误模式,应用时会保存该模式。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。 `llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据、重试策略和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 @@ -93,7 +93,7 @@ Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepS ### 校验 -键入的 API 密钥按其自身字段判定:去除首尾空白后必须非空,且每个字符都必须是可打印 ASCII(`[\x21-\x7E]`),这正是 HTTP 头值能够携带的字符集——与 `@deepseek-ai/dsh-llm` 中的 `normalizeApiKey` 互为镜像,此处复刻是因为源平面拆分禁止导入它。与粘贴的 `NAME=value` 环境行一致或包裹在匹配引号内的值,会作为同样的格式失败被拒绝。空 id、重复 id、空显式名称以及不可读、非正数或小数的容量都会在任何写入之前失败。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器先显示继承的有效行,直到第一次模型编辑把完整数组物化进用户层,重置则取消该覆盖。 +键入的 API 密钥按其自身字段判定:去除首尾空白后必须非空,且每个字符都必须是可打印 ASCII(`[\x21-\x7E]`),这正是 HTTP 头值能够携带的字符集——与 `@deepseek-ai/dsh-llm` 中的 `normalizeApiKey` 互为镜像,此处复刻是因为源平面拆分禁止导入它。与粘贴的 `NAME=value` 环境行一致或包裹在匹配引号内的值,会作为同样的格式失败被拒绝。空 id、重复 id、空显式名称以及不可读、非正数或小数的容量都会在任何写入之前失败。草稿中的 `retryPolicy` 须通过 `@deepseek-ai/dsh-llm` 中 `resolveRetryPolicy` 的检查,出于同样原因在此复刻,因为 `llm-deepseek` 对它拒绝的实时策略只记录警告。切换到重试所有错误模式时保留仅临时错误模式使用的字段,适配器接受并忽略它们;但不是数字的重试次数会被移除,因为切换后无法再修正。卡片按字段写入策略,因此未修改的继承字段保持继承。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器先显示继承的有效行,直到第一次模型编辑把完整数组物化进用户层,重置则取消该覆盖。 ### 并发与凭据 @@ -140,7 +140,7 @@ Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepS 这些限制定义编辑器的字段覆盖范围与本页的触达范围;它们是当前包约束,不是设置路线图。 -- **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。重试策略、超时、DeepSeek 模型说明及其他进阶字段仍留在 `cordis.patch.yml` 中;编辑器未展示的现有模型字段会予以保留。 +- **卡片上只有 API 密钥与精选折叠字段可编辑**:手写编辑器以 schema 通用字段覆盖换取了 mockup 布局。超时、DeepSeek 模型说明及其他进阶字段仍留在 `cordis.patch.yml` 中;编辑器未展示的现有模型字段会予以保留。 - **凭据清理范围刻意保持狭窄**:删除一行时,仅当其引用与页面派生的 `_API_KEY` 目标完全一致,才会清除已配置且可写的凭据。自定义引用、环境凭据与无法识别的目标会保留,因为该行无法证明自己拥有它们。 - **只有 pi-ai 路由可以手工声明**:自定义模型 API 表单写入 `llm-pi-ai`——唯一一个其 profile 描述整个提供商的 namespace。`llm-deepseek` 路由是组合面的事实,不是本页能创建的东西。 - **目录选择框列出的是路由标识符**:`moonshotai`、`zai` 等 pi-ai catalog id 原样显示,没有产品名、别名或搜索。一个把自定义表单作为置顶项的可搜索选择器可以取代方式切换;前提是目录先携带显示名称。 diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index a0f65f61d7..4b7fe7449d 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -8,9 +8,9 @@ * provider-native authentication); * the collapsed 自定义设置 area carries the per-family extras (`baseURL` and * `retryPolicy` for both families, DeepSeek's id/name/context-window model - * catalog, and the display name and wire protocol of a pi-ai route the adapter does not ship — - * the two fields the create card asked that route for, editable here for the - * same reason). + * catalog, and the display name and wire protocol of a pi-ai route the + * adapter does not ship — the two fields the create card asked that route + * for, editable here for the same reason). * Reasoning effort is deliberately absent: it is a per-MODEL capability, and * the models under one provider disagree about it, so a provider-scoped * control can only be set to a value some of them reject. The composer's @@ -122,11 +122,17 @@ function draftAt( return structuredClone(subtree) as Record } +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + /** * The minimal path ops carrying `after` over `before`, both as the card sees * them. Only keys the card observed are named; fields absent from both sides * produce no op, which is why edits are path-addressed rather than a rebuilt - * section. + * section. Objects are diffed field by field, so an omitted field keeps the + * value an unset restores from the inherited layer instead of a whole-object + * set discarding it. * @param base - path of the edited subtree inside the user section. * @param before - the subtree as loaded, or undefined when it is new. * @param after - the subtree as edited. @@ -137,13 +143,13 @@ export function pathOps( before: unknown, after: Record, ): SettingsPathOpView[] { - const previous = typeof before === 'object' && before !== null && !Array.isArray(before) - ? before as Record - : {} + const previous = isRecord(before) ? before : {} const ops: SettingsPathOpView[] = [] for (const [key, value] of Object.entries(after)) { - if (JSON.stringify(previous[key]) === JSON.stringify(value)) continue - ops.push({ op: 'set', path: [...base, key], value: value as JsonValue }) + const prior = previous[key] + if (JSON.stringify(prior) === JSON.stringify(value)) continue + if (isRecord(value) && (prior === undefined || isRecord(prior))) ops.push(...pathOps([...base, key], prior, value)) + else ops.push({ op: 'set', path: [...base, key], value: value as JsonValue }) } for (const key of Object.keys(previous)) { if (!(key in after)) ops.push({ op: 'unset', path: [...base, key] }) @@ -179,7 +185,15 @@ function refFor( */ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const { namespace, schema, settingsPath, operations, t } = props - const [draft, setDraft] = useState>(() => draftAt(schema, namespace, settingsPath)) + const [draft, setDraft] = useState>(() => { + const loaded = draftAt(schema, namespace, settingsPath) + const policy = loaded['retryPolicy'] + // A stored retry policy without a mode edits under the mode it inherits; + // the draft names it so a saved policy always carries one. + if (!isRecord(policy) || policy['mode'] !== undefined) return loaded + const mode = schema.getPath(namespace.base, [...settingsPath, 'retryPolicy', 'mode']) ?? 'normal' + return { ...loaded, retryPolicy: { ...policy, mode } } + }) const [keyDraft, setKeyDraft] = useState('') const [busy, setBusy] = useState(false) const [listBusy, setListBusy] = useState(false) @@ -237,7 +251,10 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { const validateModels = (value: unknown): DeepSeekModelsValidationFailure | ReasoningEffortsFailure | undefined => validateDeepSeekModels(value) ?? (editsReasoning ? validateReasoningEfforts(value) : undefined) const modelFailure = validateModels(schema.getPath(draft, ['models'])) - const retryNode = useMemo(() => schema.nodeAtPath(root, [...settingsPath, 'retryPolicy']), [root, schema, settingsPath]) + const retryNode = useMemo( + () => layout === 'unknown' ? undefined : schema.nodeAtPath(root, [...settingsPath, 'retryPolicy']), + [layout, root, schema, settingsPath], + ) const retryDefaults = retryNode === undefined ? undefined : retryPolicyDefaults(schema, retryNode, schema.getPath(namespace.base, [...settingsPath, 'retryPolicy'])) diff --git a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx index 075cc17b64..8a0bb2fb1f 100644 --- a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx +++ b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx @@ -81,7 +81,7 @@ export interface RetryPolicyFailure { /** * The first refused field of a drafted policy, checked as `resolveRetryPolicy` - * checks the merged policy: fields the draft leaves empty, including `mode`, inherit. + * checks the merged policy: fields the draft leaves empty inherit. * @param schema - settings schema operations. * @param node - the `retryPolicy` schema union, which bounds every field. * @param policy - the drafted user-layer policy; undefined inherits. @@ -97,17 +97,25 @@ export function retryPolicyFailure( t: (key: ModelsKey) => string, ): RetryPolicyFailure | undefined { if (policy === undefined) return undefined + if (typeof policy !== 'object' || policy === null || Array.isArray(policy)) { + const message = schema.validate(node, policy) + return message === undefined ? undefined : { message } + } const fail = (key: ModelsKey, path: readonly string[]): RetryPolicyFailure => ({ message: t(key), path }) const number = (path: readonly string[]): number => { const value = schema.getPath(policy, path) ?? defaults.value(path) return typeof value === 'number' ? value : NaN } - const mode = schema.getPath(policy, ['mode']) ?? defaults.mode - if (mode === 'normal') { + if (schema.getPath(policy, ['mode']) === 'normal') { const retries = number(MAX_RETRIES) if (!Number.isSafeInteger(retries) || retries < 0) return fail('retryMaxRetriesInvalid', MAX_RETRIES) const codes = schema.getPath(policy, CODES) - if (Array.isArray(codes) && new Set(codes).size !== codes.length) return fail('retryCodesDuplicate', CODES) + if (Array.isArray(codes)) { + if (codes.length === 0 || codes.some(code => typeof code !== 'string' || code.length === 0)) { + return fail('retryCodesInvalid', CODES) + } + if (new Set(codes).size !== codes.length) return fail('retryCodesDuplicate', CODES) + } } for (const path of [INITIAL_DELAY, MAX_DELAY]) { const delay = number(path) @@ -117,7 +125,7 @@ export function retryPolicyFailure( if (number(INITIAL_DELAY) > number(MAX_DELAY)) return fail('retryDelayOrder', INITIAL_DELAY) const jitter = number(JITTER) if (!(jitter >= 0 && jitter <= 1)) return fail('retryJitterInvalid', JITTER) - const message = schema.validate(node, { ...policy as Record, mode }) + const message = schema.validate(node, policy) return message === undefined ? undefined : { message } } @@ -146,19 +154,23 @@ function textOf(value: unknown): string { return typeof value === 'number' || typeof value === 'string' ? String(value) : '' } -/** Set or remove one nested field of a plain policy object, dropping emptied parents. */ -function withField(policy: Record, path: readonly string[], value: unknown): Record { - const [key, ...rest] = path as [string, ...string[]] - const next = { ...policy } - const child = rest.length === 0 - ? value - : withField(typeof next[key] === 'object' && next[key] !== null ? next[key] as Record : {}, rest, value) - if (child === undefined || (typeof child === 'object' && child !== null && Object.keys(child).length === 0)) { - Reflect.deleteProperty(next, key) - } else { - next[key] = child +/** Set or remove one field of a plain policy object, dropping an emptied `backoff`. */ +function withField(policy: Record, path: FieldPath, value: unknown): Record { + const assign = (target: Record, key: string, next: unknown): void => { + if (next === undefined) Reflect.deleteProperty(target, key) + else target[key] = next } - return next + const result = { ...policy } + if (path.length === 1) { + assign(result, path[0], value) + return result + } + const [parent, key] = path + const prior = result[parent] + const child = typeof prior === 'object' && prior !== null ? { ...prior as Record } : {} + assign(child, key, value) + assign(result, parent, Object.keys(child).length === 0 ? undefined : child) + return result } /** @@ -173,8 +185,6 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode { : undefined const current = policy ?? {} const mode = current['mode'] - // A stored policy without a mode inherits the mode beneath it. - const shownMode = mode ?? (policy === undefined ? undefined : defaults.mode) // Raw text keeps partial input such as `0.` visible while it is typed. const [texts, setTexts] = useState>({}) const textAt = (path: FieldPath): string => texts[path.join('.')] ?? textOf(path.reduce( @@ -237,12 +247,12 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode { - {shownMode === 'always' ? {t('retryAlwaysHint')} : null} - {shownMode === 'normal' || shownMode === 'always' + {mode === 'always' ? {t('retryAlwaysHint')} : null} + {mode === 'normal' || mode === 'always' ? (
- {shownMode === 'normal' ? field(CODES, 'retryCodes') : null} - {NUMBER_FIELDS.filter(([path]) => shownMode === 'normal' || path !== MAX_RETRIES).map(([path, label]) => field(path, label))} + {mode === 'normal' ? field(CODES, 'retryCodes') : null} + {NUMBER_FIELDS.filter(([path]) => mode === 'normal' || path !== MAX_RETRIES).map(([path, label]) => field(path, label))}
) : null} diff --git a/packages/client/ui-settings-models/src/client/locales.ts b/packages/client/ui-settings-models/src/client/locales.ts index 01df4731c1..1c9eed2b28 100644 --- a/packages/client/ui-settings-models/src/client/locales.ts +++ b/packages/client/ui-settings-models/src/client/locales.ts @@ -68,6 +68,7 @@ export const en = { retryMaxDelay: 'Max delay (ms)', retryJitter: 'Jitter ratio', retryMaxRetriesInvalid: 'Max retries must be a whole number, 0 or more.', + retryCodesInvalid: 'Error codes must be non-empty text.', retryCodesDuplicate: 'Each error code may appear once.', retryDelayInvalid: 'Delays must be positive and at most {max} ms.', retryDelayOrder: 'Initial delay cannot exceed max delay.', @@ -228,6 +229,7 @@ export const zh: { [Key in keyof typeof en]: string } = { retryMaxDelay: '最大延迟 (ms)', retryJitter: '抖动比例', retryMaxRetriesInvalid: '最大重试次数必须是不小于 0 的整数。', + retryCodesInvalid: '错误码必须是非空文本。', retryCodesDuplicate: '每个错误码只能出现一次。', retryDelayInvalid: '延迟必须是正数,且不超过 {max} ms。', retryDelayOrder: '初始延迟不能大于最大延迟。', @@ -385,6 +387,7 @@ export const ja = { 'retryMaxDelay': '最大遅延 (ms)', 'retryJitter': 'ジッター比率', 'retryMaxRetriesInvalid': '最大再試行回数は0以上の整数で指定してください。', + 'retryCodesInvalid': 'エラーコードは空でないテキストにしてください。', 'retryCodesDuplicate': '各エラーコードは1回のみ指定できます。', 'retryDelayInvalid': '遅延は {max} ms 以下の正の値で指定してください。', 'retryDelayOrder': '初期遅延は最大遅延以下にしてください。', @@ -542,6 +545,7 @@ export const es = { retryMaxDelay: 'Retraso máximo (ms)', retryJitter: 'Proporción de variación aleatoria', retryMaxRetriesInvalid: 'Los reintentos máximos deben ser un número entero igual o mayor que 0.', + retryCodesInvalid: 'Los códigos de error deben ser texto no vacío.', retryCodesDuplicate: 'Cada código de error solo puede aparecer una vez.', retryDelayInvalid: 'Los retrasos deben ser positivos y como máximo de {max} ms.', retryDelayOrder: 'El retraso inicial no puede superar el retraso máximo.', @@ -699,6 +703,7 @@ export const fr = { retryMaxDelay: 'Délai maximum (ms)', retryJitter: 'Ratio de gigue', retryMaxRetriesInvalid: 'Le nombre maximum de tentatives doit être un entier supérieur ou égal à 0.', + retryCodesInvalid: "Les codes d'erreur doivent être du texte non vide.", retryCodesDuplicate: "Chaque code d'erreur ne peut apparaître qu'une fois.", retryDelayInvalid: 'Les délais doivent être positifs et ne pas dépasser {max} ms.', retryDelayOrder: 'Le délai initial ne peut pas dépasser le délai maximum.', @@ -856,6 +861,7 @@ export const de = { retryMaxDelay: 'Maximale Verzögerung (ms)', retryJitter: 'Jitter-Anteil', retryMaxRetriesInvalid: 'Die maximale Anzahl an Wiederholungen muss eine ganze Zahl ab 0 sein.', + retryCodesInvalid: 'Fehlercodes müssen nicht leerer Text sein.', retryCodesDuplicate: 'Jeder Fehlercode darf nur einmal vorkommen.', retryDelayInvalid: 'Verzögerungen müssen positiv sein und dürfen höchstens {max} ms betragen.', retryDelayOrder: 'Die anfängliche Verzögerung darf die maximale Verzögerung nicht überschreiten.', @@ -1013,6 +1019,7 @@ export const ru = { retryMaxDelay: 'Максимальная задержка (ms)', retryJitter: 'Коэффициент джиттера', retryMaxRetriesInvalid: 'Максимальное количество повторных попыток должно быть целым числом не меньше 0.', + retryCodesInvalid: 'Коды ошибок должны быть непустым текстом.', retryCodesDuplicate: 'Каждый код ошибки может встречаться только один раз.', retryDelayInvalid: 'Задержки должны быть положительными и не превышать {max} ms.', retryDelayOrder: 'Начальная задержка не может превышать максимальную.', @@ -1170,6 +1177,7 @@ export const pt = { retryMaxDelay: 'Atraso máximo (ms)', retryJitter: 'Proporção de jitter', retryMaxRetriesInvalid: 'O máximo de novas tentativas deve ser um número inteiro maior ou igual a 0.', + retryCodesInvalid: 'Os códigos de erro devem ser texto não vazio.', retryCodesDuplicate: 'Cada código de erro só pode aparecer uma vez.', retryDelayInvalid: 'Os atrasos devem ser positivos e de no máximo {max} ms.', retryDelayOrder: 'O atraso inicial não pode exceder o atraso máximo.', diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 08ce75a428..c23e423256 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -825,6 +825,10 @@ describe('ModelsSection', () => { .toEqual([{ op: 'set', path: ['b'], value: 2 }, { op: 'set', path: ['d'], value: 3 }]) expect(pathOps([], undefined, {})).toEqual([]) expect(pathOps([], { a: 1 }, { a: 1 })).toEqual([]) + expect(pathOps([], { p: { m: 'x', n: 1 } }, { p: { m: 'y' } })) + .toEqual([{ op: 'set', path: ['p', 'm'], value: 'y' }, { op: 'unset', path: ['p', 'n'] }]) + expect(pathOps([], {}, { p: { m: 'x' } })).toEqual([{ op: 'set', path: ['p', 'm'], value: 'x' }]) + expect(pathOps([], { p: 'x' }, { p: { m: 'x' } })).toEqual([{ op: 'set', path: ['p'], value: { m: 'x' } }]) }) it('stores a typed key write-only from the setup card without touching settings', async () => { diff --git a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx index 49d7bf6794..3a25dc45af 100644 --- a/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/retry-policy.client.spec.tsx @@ -95,15 +95,17 @@ describe('retry policy fields', () => { fireEvent.click(apply()) await waitFor(() => { expect(onClose).toHaveBeenCalledWith(true) }) - expect(writeSettings).toHaveBeenCalledWith('llm-deepseek', [{ - op: 'set', - path: ['retryPolicy'], - value: { mode: 'normal', maxRetries: 3, retryableCodes: ['RATE_LIMIT', 'SERVER'], backoff: { jitterRatio: 0.25 } }, - }], 3) + expect(writeSettings).toHaveBeenCalledWith('llm-deepseek', [ + { op: 'set', path: ['retryPolicy', 'mode'], value: 'normal' }, + { op: 'set', path: ['retryPolicy', 'maxRetries'], value: 3 }, + { op: 'set', path: ['retryPolicy', 'retryableCodes'], value: ['RATE_LIMIT', 'SERVER'] }, + { op: 'set', path: ['retryPolicy', 'backoff', 'jitterRatio'], value: 0.25 }, + ], 3) }) const delayInvalid = en.retryDelayInvalid.replace('{max}', '2147483647') it.each([ + [en.retryMaxRetries, '-1', en.retryMaxRetriesInvalid], [en.retryMaxRetries, '1.5', en.retryMaxRetriesInvalid], [en.retryMaxRetries, 'many', en.retryMaxRetriesInvalid], [en.retryCodes, 'SERVER, SERVER', en.retryCodesDuplicate], @@ -136,14 +138,70 @@ describe('retry policy fields', () => { await waitFor(() => { expect(document.getElementById('route-retry-failure')?.closest('details')).not.toBeNull() }) }) - it('edits a stored policy without a mode under the inherited mode', () => { - setup(deepSeekNamespace({ base: { retryPolicy: { mode: 'normal' } }, user: { retryPolicy: { backoff: { maxDelayMs: 2_000 } } } })) - expect(mode().value).toBe('') + it.each([ + ['the inherited mode', { mode: 'always' }, 'always'], + ['normal mode when nothing is inherited', undefined, 'normal'], + ])('names %s for a stored policy without a mode', async (_name, inherited, expected) => { + const { writeSettings } = setup(deepSeekNamespace({ + ...inherited === undefined ? {} : { base: { retryPolicy: inherited } }, + user: { retryPolicy: { backoff: { maxDelayMs: 2_000 } } }, + })) + expect(mode().value).toBe(expected) expect(input(en.retryMaxDelay).value).toBe('2000') - expect(input(en.retryMaxRetries).placeholder).toBe('5') + fireEvent.click(apply()) + await waitFor(() => { expect(writeSettings).toHaveBeenCalled() }) + expect(writeSettings.mock.calls[0]?.[1]).toEqual([{ op: 'set', path: ['retryPolicy', 'mode'], value: expected }]) + }) + + it('removes a stored policy without a mode when Default is chosen', async () => { + const { writeSettings } = setup(deepSeekNamespace({ user: { retryPolicy: { backoff: { maxDelayMs: 2_000 } } } })) + fireEvent.change(mode(), { target: { value: '' } }) + fireEvent.click(apply()) + await waitFor(() => { expect(writeSettings).toHaveBeenCalled() }) + expect(writeSettings.mock.calls[0]?.[1]).toEqual([{ op: 'unset', path: ['retryPolicy'] }]) + }) + + it('unsets a cleared field so the inherited value applies again', async () => { + const { writeSettings } = setup(deepSeekNamespace({ + base: { retryPolicy: { mode: 'normal', maxRetries: 7 } }, + user: { retryPolicy: { mode: 'normal', maxRetries: 1 } }, + })) + fireEvent.change(input(en.retryMaxRetries), { target: { value: '' } }) + expect(input(en.retryMaxRetries).placeholder).toBe('7') + fireEvent.click(apply()) + await waitFor(() => { expect(writeSettings).toHaveBeenCalled() }) + expect(writeSettings.mock.calls[0]?.[1]).toEqual([{ op: 'unset', path: ['retryPolicy', 'maxRetries'] }]) + }) + + it.each([ + ['an empty code list', []], + ['a blank code', ['']], + ['a numeric code', [500]], + ])('refuses a stored policy with %s', (_name, codes) => { + setup(deepSeekNamespace({ user: { retryPolicy: { mode: 'normal', retryableCodes: codes } } })) + expect(screen.getByText(en.retryCodesInvalid)).toBeTruthy() + expect(apply().disabled).toBe(true) + }) + + it('refuses a stored policy that is not an object', () => { + setup(deepSeekNamespace({ user: { retryPolicy: 'sometimes' } })) + expect(mode().value).toBe('') + expect(apply().disabled).toBe(true) + }) + + it('treats a stored null policy as inheriting', () => { + setup(deepSeekNamespace({ user: { retryPolicy: null } })) + expect(mode().value).toBe('') expect(apply().disabled).toBe(false) }) + it('offers no retry fields on a namespace without a curated editor', () => { + const namespace = deepSeekNamespace({ user: { retryPolicy: { mode: 'sometimes' } } }) + setup({ ...namespace, ns: 'llm-plain' }) + expect(screen.queryByRole('group', { name: en.retryPolicy })).toBeNull() + expect(document.getElementById('route-retry-failure')).toBeNull() + }) + it('drops an uncorrectable retry count when switching to always', async () => { const { writeSettings } = setup(deepSeekNamespace()) fireEvent.change(mode(), { target: { value: 'normal' } }) @@ -154,7 +212,7 @@ describe('retry policy fields', () => { expect(apply().disabled).toBe(false) fireEvent.click(apply()) await waitFor(() => { expect(writeSettings).toHaveBeenCalled() }) - expect(writeSettings.mock.calls[0]?.[1]).toEqual([{ op: 'set', path: ['retryPolicy'], value: { mode: 'always' } }]) + expect(writeSettings.mock.calls[0]?.[1]).toEqual([{ op: 'set', path: ['retryPolicy', 'mode'], value: 'always' }]) }) it('shows inherited values and removes the user policy when Default is chosen', async () => { @@ -186,7 +244,7 @@ describe('retry policy fields', () => { fireEvent.click(apply()) await waitFor(() => { expect(writeSettings).toHaveBeenCalled() }) expect(writeSettings.mock.calls[0]?.[1]).toEqual([ - { op: 'set', path: ['providers', 'openai', 'retryPolicy'], value: { mode: 'always' } }, + { op: 'set', path: ['providers', 'openai', 'retryPolicy', 'mode'], value: 'always' }, ]) }) From d0238f6892a52e214ec28c372e4ecfbf57fb3ec3 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 23 Oct 2026 14:08:50 +0800 Subject: [PATCH 004/106] style(ui-settings-models): fold retry backoff fields and shorten retry copy --- .../cards.expected.md | 8 +- .../catalog-inputs.expected.md | 8 +- .../models-settings/declared-edit.expected.md | 8 +- .../models-settings/image-only.expected.md | 8 +- .../default-models.expected.md | 8 +- .../models.expected.md | 8 +- apps/web/tests/models-settings.e2e.ts | 2 +- .../ui-settings-models/README.i18n.yaml | 4 +- packages/client/ui-settings-models/README.md | 2 +- .../client/ui-settings-models/README.zh.md | 2 +- .../src/client/ModelsSection.module.css | 34 ++++++-- .../src/client/RetryPolicyEditor.tsx | 32 +++++--- .../ui-settings-models/src/client/locales.ts | 80 +++++++++---------- .../tests/retry-policy.client.spec.tsx | 2 +- 14 files changed, 117 insertions(+), 89 deletions(-) diff --git a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md index 4fd9a6d3aa..2edcaed7df 100644 --- a/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md +++ b/apps/web/tests/expected/deepseek-messages-settings/cards.expected.md @@ -25,10 +25,10 @@ - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - group "重试策略": - text: 重试策略 - - combobox "重试模式": - - option "默认(仅重试临时错误)" [selected] - - option "仅重试临时错误" - - option "重试所有错误" + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - button "调整模型顺序 1" diff --git a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md index 953d1bbb90..89195b2d47 100644 --- a/apps/web/tests/expected/models-settings/catalog-inputs.expected.md +++ b/apps/web/tests/expected/models-settings/catalog-inputs.expected.md @@ -30,10 +30,10 @@ - /placeholder: https://api.openai.com/v1 - group "重试策略": - text: 重试策略 - - combobox "重试模式": - - option "默认(仅重试临时错误)" [selected] - - option "仅重试临时错误" - - option "重试所有错误" + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/models-settings/declared-edit.expected.md b/apps/web/tests/expected/models-settings/declared-edit.expected.md index 2af0ca2ea7..5ccee13673 100644 --- a/apps/web/tests/expected/models-settings/declared-edit.expected.md +++ b/apps/web/tests/expected/models-settings/declared-edit.expected.md @@ -38,10 +38,10 @@ - option "Anthropic Messages" - group "重试策略": - text: 重试策略 - - combobox "重试模式": - - option "默认(仅重试临时错误)" [selected] - - option "仅重试临时错误" - - option "重试所有错误" + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/models-settings/image-only.expected.md b/apps/web/tests/expected/models-settings/image-only.expected.md index 37cf5f5f1d..fb2913a6c8 100644 --- a/apps/web/tests/expected/models-settings/image-only.expected.md +++ b/apps/web/tests/expected/models-settings/image-only.expected.md @@ -38,10 +38,10 @@ - option "Anthropic Messages" [selected] - group "重试策略": - text: 重试策略 - - combobox "重试模式": - - option "默认(仅重试临时错误)" [selected] - - option "仅重试临时错误" - - option "重试所有错误" + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md index 190c712ce6..07d84102bf 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/default-models.expected.md @@ -25,10 +25,10 @@ - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - group "重试策略": - text: 重试策略 - - combobox "重试模式": - - option "默认(仅重试临时错误)" [selected] - - option "仅重试临时错误" - - option "重试所有错误" + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 正在使用适配器默认模型 - button "调整模型顺序 1" diff --git a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md index 77e10d704d..750f3d2448 100644 --- a/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md +++ b/apps/web/tests/expected/onboarding-deepseek-config/models.expected.md @@ -25,10 +25,10 @@ - text: 请填写兼容 Anthropic Messages 协议的 API 地址。 - group "重试策略": - text: 重试策略 - - combobox "重试模式": - - option "默认(仅重试临时错误)" [selected] - - option "仅重试临时错误" - - option "重试所有错误" + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index fbaebbf240..819b9f8405 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -228,7 +228,7 @@ describe('web e2e: Models settings page configures a dormant provider', () => { const url = dialog.getByLabel('API 地址') await url.waitFor({ timeout: 10_000 }) await url.fill('https://gateway.minimax.example/v1') - await dialog.getByLabel('重试模式', { exact: true }).selectOption('normal') + await dialog.getByRole('combobox', { name: '重试策略', exact: true }).selectOption('normal') await dialog.getByLabel('最大重试次数', { exact: true }).fill('3') await dialog.getByRole('button', { name: '保存', exact: true }).click() // The editor closes back to the row; the fold's write merged into the diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index e4d09ffcb1..c571d9b3bc 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,8 +21,8 @@ en: f4817eb2e3537ed9 zh: e589c193fa85473f /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: f18b4e60ceed5704 - zh: 757d5f4726eec4f2 + en: 57b27798e605dcb2 + zh: 04bacb88db40a237 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: c0b04b2b11af60d3 zh: d195eec281e445cb diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index dfc0bea959..c8ae8f23ab 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -55,7 +55,7 @@ An endpoint without a fixed catalog default shows the provider's `baseURLEnv` co The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. There is no provider-scoped reasoning-effort field: effort is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, input types, and, on a hand-declared pi-ai route, reasoning levels; unrelated model fields survive edits. -The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Retry transient errors** or **Retry every error** writes a user-layer policy. Transient mode edits the maximum retry count and the comma-separated retryable error codes; both modes edit the initial delay, maximum delay, and jitter ratio. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, an empty code list, a blank or non-text code, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or transient mode when none is inherited, and Apply saves that mode. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy. +The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the comma-separated error codes and the maximum retry count; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, an empty code list, a blank or non-text code, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or transient mode when none is inherited, and Apply saves that mode. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy. The DeepSeek card edits the `llm-deepseek` endpoint, credentials, retry policy, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index 0428c09088..c97e567754 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -55,7 +55,7 @@ API 密钥输入框初始为空,并通过 `autocomplete="new-password"` 请求 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。这里没有提供商级的推理等级字段:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens`、输入类型,以及手工声明的 pi-ai 路由上的推理等级;无关的模型字段在编辑后仍会保留。 -同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅重试临时错误**或**重试所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和逗号分隔的可重试错误码;两种模式都编辑初始延迟、最大延迟和抖动比例。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、空的错误码列表、空白或非文本的错误码、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示临时错误模式,应用时会保存该模式。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。 +同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑逗号分隔的错误码和最大重试次数;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、空的错误码列表、空白或非文本的错误码、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示临时错误模式,应用时会保存该模式。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。 `llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据、重试策略和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.module.css b/packages/client/ui-settings-models/src/client/ModelsSection.module.css index cb2316af7e..c79a4deef0 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.module.css +++ b/packages/client/ui-settings-models/src/client/ModelsSection.module.css @@ -446,10 +446,17 @@ transition: transform 120ms ease; } -.customized[open] > .customizedSummary::before { +.customized[open] > .customizedSummary::before, +.retryFold[open] > .customizedSummary::before { transform: rotate(45deg) translate(-1px, -1px); } +/* The nested fold labels a part of the retry policy, so it reads at field-label weight. */ +.retryFold > .customizedSummary { + font-weight: 400; + color: var(--dsw-alias-label-tertiary); +} + @media (hover: hover) { .customizedSummary:hover { color: var(--dsw-alias-label-primary); @@ -623,15 +630,26 @@ padding: 8px 4px 2px; } -/* Retry-policy fields: the full-width codes, then the numeric fields in as many columns as fit. */ -.retryFields { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); - gap: 8px; +/* Retry-policy fields under the mode select: codes and count, then the folded backoff values. */ +.retryBody { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 4px; } -.retryWide { - grid-column: 1 / -1; +/* One backoff column wide, so the count lines up with the delay fields below. */ +.retryCount { + width: max(112px, calc((100% - 16px) / 3)); + max-width: 100%; +} + +.retryBackoff { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(112px, 1fr)); + align-items: end; + gap: 12px 8px; + padding-top: 8px; } .modelField { diff --git a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx index 8a0bb2fb1f..0f300121b4 100644 --- a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx +++ b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx @@ -23,9 +23,8 @@ const JITTER = ['backoff', 'jitterRatio'] as const type FieldPath = typeof CODES | typeof MAX_RETRIES | typeof INITIAL_DELAY | typeof MAX_DELAY | typeof JITTER -/** Numeric fields after the codes, as `[policy path, label]`; `maxRetries` applies only to normal mode. */ -const NUMBER_FIELDS: readonly (readonly [FieldPath, ModelsKey])[] = [ - [MAX_RETRIES, 'retryMaxRetries'], +/** Backoff fields of both modes, as `[policy path, label]`. */ +const BACKOFF_FIELDS: readonly (readonly [FieldPath, ModelsKey])[] = [ [INITIAL_DELAY, 'retryInitialDelay'], [MAX_DELAY, 'retryMaxDelay'], [JITTER, 'retryJitter'], @@ -187,6 +186,8 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode { const mode = current['mode'] // Raw text keeps partial input such as `0.` visible while it is typed. const [texts, setTexts] = useState>({}) + // Backoff starts folded unless the draft already customizes it. + const [backoffOpen] = useState(() => current['backoff'] !== undefined) const textAt = (path: FieldPath): string => texts[path.join('.')] ?? textOf(path.reduce( (value, key) => typeof value === 'object' && value !== null ? (value as Record)[key] : undefined, current, )) @@ -199,10 +200,10 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode { const empty = Array.isArray(value) ? value.length === 0 : value === '' props.onChange(withField(current, path, empty ? undefined : value)) } - const field = (path: FieldPath, label: ModelsKey): ReactNode => { + const field = (path: FieldPath, label: ModelsKey, className = styles['modelField']): ReactNode => { const invalid = failure?.path?.join('.') === path.join('.') return ( -
----- @@ -216,7 +219,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts #### 模型看到什么 -所选目录模型会收到系统消息文本、对话历史、工具以及支持的采样字段。目录模型声明 `supportsMidConvoSystemMessages` 时,适配器按历史顺序保留后续系统消息。否则,适配器在传给 pi-ai 之前,将每条非空的后续系统消息转换为以 `` 包裹的用户消息。包裹文本不会赋予系统角色优先级。单次调用的 `GenerateOptions.system` 放在初始历史提示词之前。重复的系统消息仍会重复发送;空系统消息会被跳过,不会丢弃之前的文本。工具更新能力选择共享的历史声明投影;pi-ai 接收初始可用工具,以及按开发者消息分组的新增和受支持的移除更新。当包含工具更新的 Anthropic 请求中所有投影后的声明均为延迟加载时,初始可用工具为一个占位工具 `DeferredToolPlaceholder`,其描述要求模型永不调用它;若被调用,会得到普通的未知工具结果。Anthropic 在更新消息中以内联方式接收后续工具定义。回放会保留工具调用命名空间,以支持动态加载的 Responses 工具。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。 +所选目录模型会收到系统消息文本、对话历史、工具以及支持的采样字段。目录模型声明 `supportsMidConvoSystemMessages` 时,适配器按历史顺序保留后续系统消息。否则,适配器在传给 pi-ai 之前,将每条非空的后续系统消息转换为以 `` 包裹的用户消息。包裹文本不会赋予系统角色优先级。单次调用的 `GenerateOptions.system` 放在初始历史提示词之前。重复的系统消息仍会重复发送;空系统消息会被跳过,不会丢弃之前的文本。工具更新能力选择共享的历史声明投影;pi-ai 接收初始可用工具,以及按开发者消息分组的新增和受支持的移除更新。当包含工具更新的 Anthropic 请求中所有投影后的声明均为延迟加载时,初始可用工具为一个占位工具 `DeferredToolPlaceholder`,其描述要求模型永不调用它;若被调用,会得到普通的未知工具结果。Anthropic 在更新消息中以内联方式接收后续工具定义。回放会保留工具调用命名空间,以支持动态加载的 Responses 工具。每张保留图片前都会有文本,注明其完整附件 id 与实际请求尺寸。当前执行文件系统可以映射附件提供方的宿主对象时,该文本还会携带只读规范化对象路径,并警告规范化或请求投影可能缩放或重新编码上传内容。日志中的图片省略决策选中的每个出现位置都会在替换文本中保留自己的身份与当前已解析访问方式,其规范化附件不会读取或变换。当保留的出现位置按精确 base64 载荷仍超过路由的 `maxRequestImageBytes` 时,调用以 `IMAGE_OFFLOAD_REQUIRED` 失败,由 `dsh-compaction-image-offload` 用 `image/offload` 事件记录所选位置并重试步骤。提供方原生回放元数据只在适配器针对历史内容校验通过后恢复。在 Anthropic Messages 路由上,如果回放响应的请求前缀自生成后发生变化(例如经过压缩),该响应会丢失 thinking 块;其文本和工具调用保留。 `imageRequestTarget(provider, model, image)` 向截图调用方提供与模型请求一致的图片尺寸和字节目标;不缩放图片的适配器保留归一化后的原图。 #### Token 影响 @@ -224,7 +227,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts #### KV Cache 影响 -传给适配器的后续系统消息不会进入初始提示词,从而保留此前的请求前缀。这不改变 Agent loop 的提示词纳入行为;它仍在不支持原生系统更新的路由上合并更新。转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。一次省略决策会把较早图片换成占位文本,因此复用在该消息处结束;省略永不回退,此后前缀保持稳定。 +传给适配器的后续系统消息不会进入初始提示词,从而保留此前的请求前缀。这不改变 Agent loop 的提示词纳入行为;它仍在不支持原生系统更新的路由上合并更新。转换保持逻辑请求顺序,图片句柄与卸载占位符则会添加模型可见文本。即使附件身份与请求字节保持稳定,执行世界路径变化也会改写历史句柄,并可能从该图片起阻止复用。更换适配器实例、提供方、模型或其他上游 token 具有相同的后缀影响。省略过期的 Anthropic thinking 发生在前缀变化之后,而该变化已在变化的消息处结束复用。第一个省略无摘要记录的 thinking 的请求会在第一条此类消息处结束复用,且仅发生一次。一次省略决策会把较早图片换成占位文本,因此复用在该消息处结束;省略永不回退,此后前缀保持稳定。 ### 提供方响应 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 4c3bdce845..0f8c53f25d 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -62,6 +62,8 @@ import type { ResolvedPiAiProviderProfile } from './config.ts' import { requestImageTarget, toPiContext } from './context.ts' import { conversationUpdates, createModels, getSupportedThinkingLevels } from './models.ts' import { toStreamChunks } from './stream.ts' +import { bindThinkingToPrefix } from './prefix-binding.ts' +import type { PrefixDigests } from './replay.ts' import type { PiAiRequestHeaderContext } from './types.ts' /** @@ -126,6 +128,12 @@ export interface PiAiAdapterOptions { * conversion because its stored replay state is unusable by this build. */ onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void + /** + * Observe one request omitting replayed Anthropic thinking whose request + * prefix changed or was never recorded; stale messages stay omitted, so + * every later request of the conversation reports them again. + */ + onThinkingOmitted?: (detail: { provider: string; model: string; count: number }) => void } /** The two auth injectables a pi-ai collection is built with. */ @@ -444,8 +452,9 @@ export class PiAiAdapter extends LlmAdapter { const onReplayDegrade = (reason: string): void => { this.config.onReplayDegrade?.({ provider: options.provider, model: options.model, reason }) } - const context = attachments === undefined - ? toPiContext(request, undefined, onReplayDegrade, updates.systemPromptUpdate === 'in-history') + const prefixDigests: PrefixDigests = new Map() + const converted = attachments === undefined + ? toPiContext(request, undefined, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests) : await toPiContext({ ...request, signal: watchdog.signal }, { attachments, resolveImageAccess: ref => this.config.resolveImageAccess?.(attachments, ref), @@ -454,7 +463,9 @@ export class PiAiAdapter extends LlmAdapter { maxPixels: profile.requestImagePixelBudget, maxBytes: profile.requestImageMaxBytes, }, - }, onReplayDegrade, updates.systemPromptUpdate === 'in-history') + }, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests) + const { context, digest, omitted } = bindThinkingToPrefix(converted, model, prefixDigests) + if (omitted > 0) this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted }) const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, @@ -463,7 +474,7 @@ export class PiAiAdapter extends LlmAdapter { signal: watchdog.signal, headers: requestHeaders(profile.headers, options, model.baseUrl, this.config.prepareHeaders), }) - const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id)[Symbol.asyncIterator]() + const iterator = toStreamChunks(events, model.contextWindow, options.signal, model.id, digest)[Symbol.asyncIterator]() let exhausted = false try { while (true) { diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index f884bb049a..3d5f9d91b7 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -16,6 +16,7 @@ import type { } from '@deepseek-ai/dsh-attachment' import type { Context as PiContext, ImageContent, Message as PiMessage, SystemMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' import { toPiAssistant } from './replay.ts' +import type { PrefixDigests } from './replay.ts' import { requestImageDimensions } from '@deepseek-ai/dsh-attachment' import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts' @@ -205,8 +206,9 @@ function appendAssistant( messages: PiMessage[], toolNames: Map, onReplayDegrade?: (reason: string) => void, + prefixDigests?: PrefixDigests, ): void { - const assistant = toPiAssistant(message, onReplayDegrade) + const assistant = toPiAssistant(message, onReplayDegrade, prefixDigests) for (const block of assistant.content) { if (block.type === 'toolCall') toolNames.set(brandString(block.id), block.name) } @@ -221,6 +223,7 @@ function appendSharedHistory( toolNames: Map, supportsMidConvoSystemMessages: boolean, onReplayDegrade?: (reason: string) => void, + prefixDigests?: PrefixDigests, ): boolean { if (message.role === 'developer') { appendDeveloper(message, options, messages) @@ -239,7 +242,7 @@ function appendSharedHistory( return true } if (message.role === 'assistant') { - appendAssistant(message, messages, toolNames, onReplayDegrade) + appendAssistant(message, messages, toolNames, onReplayDegrade, prefixDigests) return true } return false @@ -249,6 +252,7 @@ function textOnlyContext( options: GenerateOptions, supportsMidConvoSystemMessages: boolean, onReplayDegrade?: (reason: string) => void, + prefixDigests?: PrefixDigests, ): PiContext { assertSupportedHistory(options.messages) const split = splitSystemPrompt(options) @@ -258,7 +262,7 @@ function textOnlyContext( if (contentHasImage(message.content)) { throw new LlmError('pi-ai image conversion requires the durable attachment service', 'UNSUPPORTED_CONTENT') } - if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade)) continue + if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests)) continue if (message.role === 'tool') { messages.push(toolResultOf(message, toolNames, flattenText(message))) continue @@ -305,6 +309,7 @@ export function requestImageTarget(ref: ImageAttachmentRef, budget: PiImageReque * @param images - absent; selects the synchronous conversion. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. * @param supportsMidConvoSystemMessages - native model capability; otherwise later system text becomes a user-role system-reminder. + * @param prefixDigests - receives the recorded request-prefix digest of each replayed assistant message that has one. * @returns the pi-ai context; `tools` is omitted when the request declares none. * @throws {LlmError} `UNSUPPORTED_CONTENT` for images in any history role, including a leading system message. */ @@ -313,6 +318,7 @@ export function toPiContext( images?: undefined, onReplayDegrade?: (reason: string) => void, supportsMidConvoSystemMessages?: boolean, + prefixDigests?: PrefixDigests, ): PiContext /** * Convert harness history to a pi-ai Context while resolving durable images. @@ -325,6 +331,7 @@ export function toPiContext( * @param images - attachment provider, current path resolver, and request limits. * @param onReplayDegrade - forwarded to {@link toPiAssistant} for each assistant message. * @param supportsMidConvoSystemMessages - native model capability; otherwise later system text becomes a user-role system-reminder. + * @param prefixDigests - receives the recorded request-prefix digest of each replayed assistant message that has one. * @returns the asynchronously resolved pi-ai context. */ export function toPiContext( @@ -332,16 +339,18 @@ export function toPiContext( images: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, supportsMidConvoSystemMessages?: boolean, + prefixDigests?: PrefixDigests, ): Promise export function toPiContext( options: GenerateOptions, images?: PiImageRequestContext, onReplayDegrade?: (reason: string) => void, supportsMidConvoSystemMessages = false, + prefixDigests?: PrefixDigests, ): PiContext | Promise { return images === undefined - ? textOnlyContext(options, supportsMidConvoSystemMessages, onReplayDegrade) - : toPiContextWithImages(options, images, supportsMidConvoSystemMessages, onReplayDegrade) + ? textOnlyContext(options, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests) + : toPiContextWithImages(options, images, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests) } async function toPiContextWithImages( @@ -349,6 +358,7 @@ async function toPiContextWithImages( images: PiImageRequestContext, supportsMidConvoSystemMessages: boolean, onReplayDegrade?: (reason: string) => void, + prefixDigests?: PrefixDigests, ): Promise { const { attachments, resolveImageAccess, maxRequestImageBytes } = images const requestImagePolicy = images.requestImagePolicy ?? { @@ -380,7 +390,7 @@ async function toPiContextWithImages( const messages: PiMessage[] = [] for (const message of exactMessages) { - if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade)) continue + if (appendSharedHistory(message, options, messages, toolNames, supportsMidConvoSystemMessages, onReplayDegrade, prefixDigests)) continue if (message.role === 'tool') { messages.push(toolResultOf(message, toolNames, userContent(message.content, requestImages, resolveImageAccess))) continue diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 5a53e7106a..a671b7229a 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -252,6 +252,9 @@ export function apply(ctx: Context, config: Config): void { + ` sending that message as provider-neutral content (${reason})`, ) }, + onThinkingOmitted: ({ provider, model, count }) => { + ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`) + }, }) // Independent of the route set: signing in is what makes a route worth // adding, so the flows are offered before any profile names their provider. diff --git a/packages/llm/llm-pi-ai/src/prefix-binding.ts b/packages/llm/llm-pi-ai/src/prefix-binding.ts new file mode 100644 index 0000000000..d5ed0451df --- /dev/null +++ b/packages/llm/llm-pi-ai/src/prefix-binding.ts @@ -0,0 +1,93 @@ +/** + * Request-prefix binding for replayed Anthropic thinking. + * + * Anthropic can bind each signed thinking block to the request prefix it was + * generated under: system prompt, tools, and every earlier message. Compaction + * and tool-result pruning rewrite that prefix, so a retained signed block + * becomes invalid and endpoints that enforce the check reject the whole + * request. Each Anthropic Messages response records a digest of the context it + * was generated from; a later request replays its thinking only while the + * reconstructed prefix still has the same digest. Replay state without a + * digest predates this check and is treated as stale. + * + * Manual (non-adaptive) thinking requires the final assistant turn of a tool + * round to start with a thinking block, so that message keeps its thinking + * when the model does not force adaptive thinking. + * + * @module dsh-llm-pi-ai/prefix-binding + */ + +import { createHash } from 'node:crypto' +import type { Api, AssistantMessage, Context as PiContext, Model } from '@earendil-works/pi-ai' +import type { PrefixDigests } from './replay.ts' + +/** Wire protocol whose thinking signatures bind to the request prefix. */ +const PREFIX_BOUND_API = 'anthropic-messages' + +/** Seed of every digest chain; changing the digest input requires a new seed. */ +const DIGEST_SEED = 'dsh-llm-pi-ai/prefix-binding/v1' + +function chain(previous: string, value: unknown): string { + return createHash('sha256').update(previous).update('\0').update(JSON.stringify(value)).digest('hex') +} + +/** Index of the assistant message whose tool round the request continues, or -1. */ +function openToolRoundAssistant(messages: PiContext['messages']): number { + for (let index = messages.length - 1; index >= 0; index--) { + const message = messages[index] as PiContext['messages'][number] + if (message.role === 'assistant') return index + if (message.role !== 'toolResult') return -1 + } + return -1 +} + +/** Copy of an assistant message without its thinking blocks. */ +function withoutThinking(message: AssistantMessage): AssistantMessage { + return { ...message, content: message.content.filter(block => block.type !== 'thinking') } +} + +/** Context whose stale thinking is removed, with the digest of the complete request. */ +export interface PrefixBoundContext { + /** Context to send; equal to the input when nothing was removed. */ + context: PiContext + /** Digest of the sent context, recorded with the response; absent for other protocols. */ + digest?: string + /** Number of replayed assistant messages whose thinking was removed. */ + omitted: number +} + +/** + * Remove thinking from replayed Anthropic Messages assistant messages whose + * request prefix changed since they were generated, or whose replay state + * records no prefix digest. + * @param context - converted request context. + * @param model - requested model. + * @param prefixDigests - recorded digests the context conversion collected. + * @returns the context to send, its digest for Anthropic Messages models, and the omission count. + */ +export function bindThinkingToPrefix( + context: PiContext, + model: Model, + prefixDigests: PrefixDigests, +): PrefixBoundContext { + if (model.api !== PREFIX_BOUND_API) return { context, omitted: 0 } + const adaptive = (model as Model).compat?.forceAdaptiveThinking === true + const kept = adaptive ? -1 : openToolRoundAssistant(context.messages) + let digest = chain(DIGEST_SEED, { systemPrompt: context.systemPrompt ?? null, tools: context.tools ?? null }) + let messages: PiContext['messages'] | undefined + let omitted = 0 + for (const [index, original] of context.messages.entries()) { + let message = original + if ( + message.role === 'assistant' && message.api === PREFIX_BOUND_API && index !== kept + && prefixDigests.get(message) !== digest && message.content.some(block => block.type === 'thinking') + ) { + message = withoutThinking(message) + messages ??= context.messages.slice() + messages[index] = message + omitted++ + } + digest = chain(digest, message) + } + return { context: messages === undefined ? context : { ...context, messages }, digest, omitted } +} diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts index 098f4a6301..8e5bcd3ca3 100644 --- a/packages/llm/llm-pi-ai/src/replay.ts +++ b/packages/llm/llm-pi-ai/src/replay.ts @@ -32,6 +32,8 @@ export interface PiAiReplayResponse { /** Provider-native effort for historical replay; absence is preserved. */ providerThinkingLevel?: string stopReason: AssistantMessage['stopReason'] + /** Digest of the request context this response was generated from; see `prefix-binding.ts`. */ + prefixDigest?: string } /** The validated halves of one pi-ai replay envelope. */ @@ -65,6 +67,9 @@ function emptyPiUsage(): PiUsage { } } +/** Recorded request-prefix digests of reconstructed assistant messages, owned by one context conversion. */ +export type PrefixDigests = Map + /** * Project a successful pi-ai response into the minimal durable replay state. * The per-block half is index-aligned with the streamed blocks (pi-ai content @@ -72,9 +77,10 @@ function emptyPiUsage(): PiUsage { * removes one. * @param message - completed native pi-ai assistant response. * @param requestedModel - request identity stored in the assistant source; defaults to the native model. + * @param prefixDigest - digest of the request context, recorded when the protocol binds thinking to it. * @returns the versioned lossless-JSON replay projection. */ -export function toPiReplayState(message: AssistantMessage, requestedModel = message.model): ReplayEnvelope { +export function toPiReplayState(message: AssistantMessage, requestedModel = message.model, prefixDigest?: string): ReplayEnvelope { const response: PiAiReplayResponse = { kind: 'pi-ai', version: 2, @@ -85,6 +91,7 @@ export function toPiReplayState(message: AssistantMessage, requestedModel = mess ...message.responseId === undefined ? {} : { responseId: message.responseId }, ...message.providerThinkingLevel === undefined ? {} : { providerThinkingLevel: message.providerThinkingLevel }, stopReason: message.stopReason, + ...prefixDigest === undefined ? {} : { prefixDigest }, } return { response, @@ -131,6 +138,7 @@ function readReplayState(value: unknown): PiAiReplayState { if (response['responseModel'] !== undefined && typeof response['responseModel'] !== 'string') return invalidReplay('responseModel must be a string') if (response['responseId'] !== undefined && typeof response['responseId'] !== 'string') return invalidReplay('responseId must be a string') if (response['providerThinkingLevel'] !== undefined && typeof response['providerThinkingLevel'] !== 'string') return invalidReplay('providerThinkingLevel must be a string') + if (response['prefixDigest'] !== undefined && typeof response['prefixDigest'] !== 'string') return invalidReplay('prefixDigest must be a string') const blocks = envelope['blocks'] if (!Array.isArray(blocks)) return invalidReplay('blocks must be an array') for (const [index, value] of blocks.entries()) { @@ -184,7 +192,12 @@ function foreignAssistant(message: HarnessAssistantMessage): AssistantMessage { } /** Recombine durable Harness content with validated pi-ai replay metadata. */ -function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessageSource, rawState: unknown): AssistantMessage { +function replayedAssistant( + message: HarnessAssistantMessage, + source: ModelMessageSource, + rawState: unknown, + prefixDigests?: PrefixDigests, +): AssistantMessage { const state = readReplayState(rawState) if (state.response.provider !== source.provider) return invalidReplay('provider does not match assistant source') if (state.response.model !== source.model) return invalidReplay('model does not match assistant source') @@ -216,7 +229,7 @@ function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessag default: return invalidReplay(`block ${index} has an unsupported Harness type`) } }) - return { + const assistant: AssistantMessage = { role: 'assistant', content, api: state.response.api, @@ -229,6 +242,8 @@ function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessag stopReason: state.response.stopReason, timestamp: 0, } + if (state.response.prefixDigest !== undefined) prefixDigests?.set(assistant, state.response.prefixDigest) + return assistant } /** @@ -242,13 +257,18 @@ function replayedAssistant(message: HarnessAssistantMessage, source: ModelMessag * @param message - model-produced assistant content with provider, model, and optional adapter-owned replay metadata. * @param onDegrade - called with the diagnostic reason when an unusable replay * state falls back to provider-neutral conversion. + * @param prefixDigests - receives the recorded request-prefix digest of the returned message, when replay state has one. * @returns a native pi-ai assistant message reconstructed from durable content. */ -export function toPiAssistant(message: HarnessAssistantMessage, onDegrade?: (reason: string) => void): AssistantMessage { +export function toPiAssistant( + message: HarnessAssistantMessage, + onDegrade?: (reason: string) => void, + prefixDigests?: PrefixDigests, +): AssistantMessage { const source = message.source if (source.replayState === undefined) return foreignAssistant(message) try { - return replayedAssistant(message, source, source.replayState) + return replayedAssistant(message, source, source.replayState, prefixDigests) } catch (error: unknown) { /* v8 ignore next -- replayedAssistant throws only INVALID_REPLAY_STATE LlmErrors; the guard keeps a future non-replay failure loud instead of silently degrading it */ diff --git a/packages/llm/llm-pi-ai/src/stream.ts b/packages/llm/llm-pi-ai/src/stream.ts index 17b7158135..a897a8e740 100644 --- a/packages/llm/llm-pi-ai/src/stream.ts +++ b/packages/llm/llm-pi-ai/src/stream.ts @@ -136,6 +136,7 @@ export function mapStopReason(message: AssistantMessage, contextWindow?: number) * @param callerSignal - caller cancellation state; an aborted caller makes any * in-band terminal error an aborted finish. * @param requestedModel - request model identity recorded for durable replay. + * @param prefixDigest - request-context digest recorded for durable replay. * @returns the harness chunks, ending with `usage` then `finish`; throws * `LlmError` (`STREAM_CLOSED`) if the source ends without a terminal event, or * `MALFORMED_RESPONSE` when completed tool arguments are invalid. Length-limited @@ -146,6 +147,7 @@ export async function* toStreamChunks( contextWindow?: number, callerSignal?: AbortSignal, requestedModel?: string, + prefixDigest?: string, ): AsyncGenerator { // pi-ai contentIndex ↔ our block index map 1:1 (both count blocks from 0 // in stream order), but we track ids per index for tool calls. @@ -220,7 +222,7 @@ export async function* toStreamChunks( yield { type: 'finish', reason: mapStopReason(event.message, contextWindow), - replayState: toPiReplayState(event.message, requestedModel), + replayState: toPiReplayState(event.message, requestedModel, prefixDigest), } return case 'error': diff --git a/packages/llm/llm-pi-ai/tests/convert.spec.ts b/packages/llm/llm-pi-ai/tests/convert.spec.ts index a4b3e85955..fe6d72a2b4 100644 --- a/packages/llm/llm-pi-ai/tests/convert.spec.ts +++ b/packages/llm/llm-pi-ai/tests/convert.spec.ts @@ -670,6 +670,7 @@ describe('toPiContext', () => { ['non-string response id', { ...validReplay, response: { ...validResponse, responseId: 1 } }, 'responseId must be a string'], ['non-string provider thinking level', { ...validReplay, response: { ...validResponse, providerThinkingLevel: 1 } }, 'providerThinkingLevel must be a string'], ['null provider thinking level', { ...validReplay, response: { ...validResponse, providerThinkingLevel: null } }, 'providerThinkingLevel must be a string'], + ['non-string prefix digest', { ...validReplay, response: { ...validResponse, prefixDigest: 1 } }, 'prefixDigest must be a string'], ['missing blocks', { response: validResponse }, 'blocks must be an array'], ['non-array blocks', { ...validReplay, blocks: 'text' }, 'blocks must be an array'], ['number block', { ...validReplay, blocks: [1] }, 'block 0 must be an object'], diff --git a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts new file mode 100644 index 0000000000..037a966d36 --- /dev/null +++ b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts @@ -0,0 +1,178 @@ +/** Replayed Anthropic thinking is bound to the request prefix it was generated under. */ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import LlmRuntime, { createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { Message, ToolCallId } from '@deepseek-ai/dsh-llm' +import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' +import { PiAiAdapter } from '@deepseek-ai/dsh-llm-pi-ai' +import { resolveProfiles } from '../src/config.ts' +import { memoryAuth } from './auth-double.ts' +import { assemble } from './assemble.ts' +import { anthropicFrame, closeMockServers, mockServer } from './mock-server.ts' + +const contexts: Context[] = [] +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + await closeMockServers() + vi.unstubAllEnvs() +}) + +const tool = { name: 'lookup', description: 'Look up a value.', parameters: { type: 'object', properties: { key: { type: 'number' } } } } + +/** One Anthropic response: signed and redacted thinking, then a tool call or a reply. */ +function frames(signature: string, toolUse: boolean): ReturnType[] { + const blocks = [ + { type: 'thinking', thinking: `thinking ${signature}`, signature }, + { type: 'redacted_thinking', data: `redacted-${signature}` }, + toolUse ? { type: 'tool_use', id: `toolu_${signature}`, name: 'lookup', input: { key: 1 } } : { type: 'text', text: 'done' }, + ] + return [ + anthropicFrame({ type: 'message_start', message: { + id: `msg_${signature}`, type: 'message', role: 'assistant', model: 'model', content: [], + stop_reason: null, stop_sequence: null, usage: { input_tokens: 3, output_tokens: 1 }, + } }), + ...blocks.flatMap((content_block, index) => [ + anthropicFrame({ type: 'content_block_start', index, content_block }), + anthropicFrame({ type: 'content_block_stop', index }), + ]), + anthropicFrame({ type: 'message_delta', delta: { stop_reason: toolUse ? 'tool_use' : 'end_turn', stop_sequence: null }, usage: { output_tokens: 1 } }), + anthropicFrame({ type: 'message_stop' }), + ] +} + +async function setup(api: 'anthropic-messages' | 'openai-completions', script: Parameters[0], adaptive = true) { + vi.stubEnv('PREFIX_BINDING_KEY', 'test-key') + const server = await mockServer(script) + const ctx = new Context() + contexts.push(ctx) + await ctx.plugin(LlmRuntime) + await ctx.plugin(LlmPiAi, { providers: { + route: { api, baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none', models: [{ id: 'model' }], + ...api === 'anthropic-messages' ? { compat: { forceAdaptiveThinking: adaptive } } : {} }, + } }) + const run = async (messages: Message[]) => { + const result = await assemble(ctx, { provider: 'route', model: 'model', messages, tools: [tool], system: 'system' }) + return JSON.parse(JSON.stringify(result.message)) as Message + } + return { server, run } +} + +function user(text: string): Message { + return createUserMessage({ source: { kind: 'user' }, content: [{ type: 'text', text }] }) +} + +function result(callId: ToolCallId, text: string): Message { + return JSON.parse(JSON.stringify(createToolResultMessage({ callId, content: [{ type: 'text', text }], isError: false }))) as Message +} + +function callId(message: Message): ToolCallId { + const call = message.content.find(block => block.type === 'tool-call') + if (call === undefined) throw new Error('expected a tool call') + return call.id +} + +function wire(request: unknown): string { + return JSON.stringify((request as { messages: unknown }).messages) +} + +describe('Anthropic thinking prefix binding', () => { + it('keeps thinking while its prefix is unchanged and omits it after the prefix is rewritten', async () => { + const { server, run } = await setup('anthropic-messages', [ + { events: frames('first', true) }, + { events: frames('second', true) }, + { events: frames('third', false) }, + { events: frames('fourth', false) }, + { events: frames('fifth', false) }, + ]) + const first = await run([user('Look up the value.')]) + expect(first.source).toMatchObject({ replayState: { response: { prefixDigest: expect.stringMatching(/^[0-9a-f]{64}$/) } } }) + const second = await run([user('Look up the value.'), first, result(callId(first), 'large output')]) + expect(wire(server.requests[1])).toContain('"signature":"first"') + expect(wire(server.requests[1])).toContain('redacted-first') + + // Pruning the first tool result rewrites the second response's prefix but not the first one's. + const tail = [first, result(callId(first), 'pruned'), second, result(callId(second), 'value')] + const third = await run([user('Look up the value.'), ...tail]) + expect(wire(server.requests[2])).toContain('"signature":"first"') + expect(wire(server.requests[2])).not.toContain('"signature":"second"') + expect(wire(server.requests[2])).not.toContain('redacted-second') + expect(wire(server.requests[2])).toContain('toolu_second') + + // Compaction replaces the head: every retained response loses its thinking, tool calls stay paired. + const compacted = [user(''), ...tail, third, user('Continue.')] + const fourth = await run(compacted) + const request = wire(server.requests[3]) + for (const stale of ['"signature":"first"', 'redacted-first', '"signature":"second"', '"signature":"third"']) { + expect(request).not.toContain(stale) + } + expect(request).toContain('toolu_first') + expect(request).toContain('toolu_second') + + // A response generated on the compacted prefix replays its own thinking. + await run([...compacted, fourth, user('Again.')]) + expect(wire(server.requests[4])).toContain('"signature":"fourth"') + expect(wire(server.requests[4])).not.toContain('"signature":"third"') + }) + + it('treats a changed system prompt as a changed prefix', async () => { + const { server, run } = await setup('anthropic-messages', [{ events: frames('first', true) }, { events: frames('second', false) }]) + const first = await run([user('Look up the value.')]) + const ctx = contexts[0] as Context + await assemble(ctx, { + provider: 'route', model: 'model', tools: [tool], system: 'changed system', + messages: [user('Look up the value.'), first, result(callId(first), 'value')], + }) + expect(wire(server.requests[1])).not.toContain('"signature":"first"') + expect(wire(server.requests[1])).toContain('toolu_first') + }) + + it('omits thinking whose replay state records no prefix digest', async () => { + const { server, run } = await setup('anthropic-messages', [{ events: frames('first', true) }, { events: frames('second', false) }]) + const first = await run([user('Look up the value.')]) + const replayState = (first.source as { replayState: { response: Record } }).replayState + delete replayState.response['prefixDigest'] + await run([user('Look up the value.'), first, result(callId(first), 'value')]) + expect(wire(server.requests[1])).not.toContain('"signature":"first"') + expect(wire(server.requests[1])).toContain('toolu_first') + }) + + it('keeps stale thinking only on the open tool round of a manual-thinking model', async () => { + const { server, run } = await setup('anthropic-messages', [ + { events: frames('first', true) }, + { events: frames('second', true) }, + { events: frames('third', false) }, + ], false) + const first = await run([user('Look up the value.')]) + const second = await run([user('Look up the value.'), first, result(callId(first), 'value')]) + await run([user(''), first, result(callId(first), 'value'), second, result(callId(second), 'value')]) + expect(wire(server.requests[2])).not.toContain('"signature":"first"') + expect(wire(server.requests[2])).toContain('"signature":"second"') + }) + + it('omits stale thinking without an omission observer', async () => { + const { run } = await setup('anthropic-messages', [{ events: frames('first', true) }]) + const first = await run([user('Look up the value.')]) + const server = await mockServer([{ events: frames('second', false) }]) + const adapter = new PiAiAdapter({ + profiles: () => resolveProfiles({ route: { + api: 'anthropic-messages', baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none', + models: [{ id: 'model' }], compat: { forceAdaptiveThinking: true }, + } }), + resolveApiKey: () => Promise.resolve('test-key'), + auth: memoryAuth(), + }) + const messages = [user(''), first, result(callId(first), 'value')] + for await (const _chunk of adapter.stream({ provider: 'route', model: 'model', messages, tools: [tool], system: 'system' })) { /* drain */ } + expect(wire(server.requests[0])).not.toContain('"signature":"first"') + }) + + it('records no prefix digest for protocols without prefix binding', async () => { + const { run } = await setup('openai-completions', [{ events: [ + JSON.stringify({ choices: [{ index: 0, delta: { role: 'assistant', content: 'reply' }, finish_reason: null }] }), + JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], usage: { prompt_tokens: 3, completion_tokens: 1 } }), + '[DONE]', + ] }]) + const reply = await run([user('Hello.')]) + expect((reply.source as { replayState: { response: object } }).replayState.response).not.toHaveProperty('prefixDigest') + }) +}) From 052c6c9c536aa4b01dbeb14c02f0b1d7f214cbf1 Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 30 Oct 2026 01:37:17 +0800 Subject: [PATCH 007/106] test(llm-pi-ai): type the prefix digest matcher Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com> --- packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts index 037a966d36..f3c108c027 100644 --- a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts +++ b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts @@ -85,7 +85,8 @@ describe('Anthropic thinking prefix binding', () => { { events: frames('fifth', false) }, ]) const first = await run([user('Look up the value.')]) - expect(first.source).toMatchObject({ replayState: { response: { prefixDigest: expect.stringMatching(/^[0-9a-f]{64}$/) } } }) + const prefixDigest: unknown = expect.stringMatching(/^[0-9a-f]{64}$/) + expect(first.source).toMatchObject({ replayState: { response: { prefixDigest } } }) const second = await run([user('Look up the value.'), first, result(callId(first), 'large output')]) expect(wire(server.requests[1])).toContain('"signature":"first"') expect(wire(server.requests[1])).toContain('redacted-first') From 5824871706c500d4f15bb0a4be0791c0f7f34efa Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 30 Oct 2026 01:52:38 +0800 Subject: [PATCH 008/106] fix(llm-pi-ai): report thinking omissions on every Anthropic request Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com> --- packages/llm/llm-pi-ai/src/adapter.ts | 9 +++++---- packages/llm/llm-pi-ai/src/index.ts | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index 0f8c53f25d..dc27de4535 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -129,9 +129,10 @@ export interface PiAiAdapterOptions { */ onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void /** - * Observe one request omitting replayed Anthropic thinking whose request - * prefix changed or was never recorded; stale messages stay omitted, so - * every later request of the conversation reports them again. + * Observe how many replayed assistant messages one request sent without + * their Anthropic thinking because the request prefix changed or was never + * recorded; called for every request, including with a zero count. Stale + * messages stay omitted, so later requests of the conversation count them again. */ onThinkingOmitted?: (detail: { provider: string; model: string; count: number }) => void } @@ -465,7 +466,7 @@ export class PiAiAdapter extends LlmAdapter { }, }, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests) const { context, digest, omitted } = bindThinkingToPrefix(converted, model, prefixDigests) - if (omitted > 0) this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted }) + this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted }) const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index a671b7229a..9fa3f0c7ac 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -253,7 +253,7 @@ export function apply(ctx: Context, config: Config): void { ) }, onThinkingOmitted: ({ provider, model, count }) => { - ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`) + if (count > 0) ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`) }, }) // Independent of the route set: signing in is what makes a route worth From 018689a59b9ae52930622c8ea6aa96672074217c Mon Sep 17 00:00:00 2001 From: Turtle Date: Fri, 30 Oct 2026 02:38:47 +0800 Subject: [PATCH 009/106] fix(llm-pi-ai): narrow Anthropic thinking omission to signed blocks Keep stale thinking on the open tool round only when the request enables manual thinking and the round continues with tool results; omit only signed or redacted thinking; report omissions only when there are any; own PrefixDigests in prefix-binding; document consumer gaps under Known Limitations. Co-authored-by: LegGasai <75920107+LegGasai@users.noreply.github.com> --- packages/llm/llm-pi-ai/README.i18n.yaml | 8 +-- packages/llm/llm-pi-ai/README.md | 4 +- packages/llm/llm-pi-ai/README.zh.md | 4 +- packages/llm/llm-pi-ai/src/adapter.ts | 17 +++-- packages/llm/llm-pi-ai/src/context.ts | 2 +- packages/llm/llm-pi-ai/src/index.ts | 2 +- packages/llm/llm-pi-ai/src/prefix-binding.ts | 71 ++++++++++++------- packages/llm/llm-pi-ai/src/replay.ts | 4 +- .../llm-pi-ai/tests/prefix-binding.spec.ts | 58 +++++++++++++-- 9 files changed, 119 insertions(+), 51 deletions(-) diff --git a/packages/llm/llm-pi-ai/README.i18n.yaml b/packages/llm/llm-pi-ai/README.i18n.yaml index a1dbdb1b1d..7c68e687ce 100644 --- a/packages/llm/llm-pi-ai/README.i18n.yaml +++ b/packages/llm/llm-pi-ai/README.i18n.yaml @@ -60,8 +60,8 @@ en: 477d277e9ac35c8f zh: 96870ca333fb6510 /deepseek-ai-dsh-llm-pi-ai/understand-the-implementation/replay-and-vocabulary: - en: 507ceb2856849646 - zh: 4f54925b52a1c190 + en: e68f92d78150d341 + zh: 45850c739a7329d3 /deepseek-ai-dsh-llm-pi-ai/further-exploration: en: a2a2ccdeae59d408 zh: c47ee091aa90ad91 @@ -93,8 +93,8 @@ en: b6bd570c0159ba08 zh: 7d2d91eea6a8d830 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work: - en: 1c54b949c81df84f - zh: b894d01aa3e6afc1 + en: 1664f55c85478298 + zh: 1fa4e07e49cc6cb6 /deepseek-ai-dsh-llm-pi-ai/known-limitations-and-deferred-work/dev-note: en: 3678dd0f6642c2a5 zh: 75cbdd064cc64282 diff --git a/packages/llm/llm-pi-ai/README.md b/packages/llm/llm-pi-ai/README.md index 68cf9c77d3..b70d1b12b5 100644 --- a/packages/llm/llm-pi-ai/README.md +++ b/packages/llm/llm-pi-ai/README.md @@ -192,7 +192,7 @@ The `pre-commit` hook runs `node_modules/.bin/tsx packages/llm/llm-pi-ai/scripts Successful assistant responses store a versioned, lossless-JSON replay state beside the provider and model that produced them — response-level facts plus one per-block entry per streamed block. At request time, `LlmRuntime` passes replay state only when the same adapter instance owns both routes; the adapter validates it and restores native response ids, provider signatures, and optional `providerThinkingLevel` effort metadata, keeping absent effort metadata absent. Replay validates the requested model identity against the assistant source and uses it to retain same-model signatures; provider-reported response models remain diagnostic metadata. Absent or unusable replay state degrades to provider-neutral content while preserving the assistant source’s required provider and model. pi-ai tool-call arguments are parsed objects, so the adapter parses input and re-stringifies output to the harness raw-JSON convention; pi-ai in-stream error events map to terminal `finish` chunks. Protocol conversion operates on request-local copies. Switching from A to B and back to A preserves the stored content, including empty text blocks, tool ids, original block order, and native replay metadata; requests to a different provider, API, or model omit unsupported signatures and reasoning blocks. Degraded replay without native metadata also omits reasoning. Valid native replay retains the stored reasoning when the original model resumes. -Anthropic can bind each signed thinking block to the request prefix it was generated under: system prompt, tools, and earlier messages. Accounts and requests that enforce this check reject a request that replays a stale block. Anthropic Messages responses therefore record a SHA-256 digest of their request context as `prefixDigest`. A later Anthropic Messages request recomputes the digest of the reconstructed prefix before each replayed response and omits that response's thinking and redacted thinking when the digests differ, as after compaction, tool-result pruning, image offload, or a system-prompt or tool change. Replay state without `prefixDigest`, written before this check existed, is treated the same way, which repairs conversations that already fail. Text and tool calls remain, and omitted thinking stays omitted on later requests. Manual thinking requires the final assistant turn of a tool round to start with a thinking block, so when the model's compat does not set `forceAdaptiveThinking`, the assistant message whose tool round the request continues keeps its thinking. The digest covers the pi-ai context only; switching authentication or compat settings that change the wire request mid-conversation is not detected. +Anthropic can bind each signed thinking block to the request prefix it was generated under: system prompt, tools, and earlier messages. Accounts and requests that enforce this check reject a request that replays a stale block. Anthropic Messages responses therefore record a SHA-256 digest of their request context as `prefixDigest`. A later Anthropic Messages request recomputes the digest of the reconstructed prefix before each replayed response and omits that response's signed and redacted thinking when the digests differ, as after compaction, tool-result pruning, image offload, or a system-prompt or tool change. Replay state without `prefixDigest`, written before this check existed, is treated the same way, which repairs conversations that already fail. Text, tool calls, and unsigned thinking remain; a response that held only signed thinking is no longer sent. Omitted thinking stays omitted on later requests. Manual thinking requires the final assistant turn of a tool round to start with a thinking block, so when a request enables thinking and the model's compat does not set `forceAdaptiveThinking`, the assistant message whose tool round the request continues with tool results keeps its thinking.
@@ -268,6 +268,8 @@ These limits define where the adapter stops and future work begins. They are cur - **Provider HTTP status is unavailable** — pi-ai error events do not expose a stable HTTP status across providers. - **Retry policy is provider-owned, not an SDK retry** — 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. - **Streamed JSON tool-call arguments avoid per-delta reparsing** — the installed pi-ai carries [`patches/@earendil-works__pi-ai@1.3.0.patch`](../../../patches/@earendil-works__pi-ai@1.3.0.patch), which removes the per-delta re-parse of the whole accumulated argument JSON in every stream adapter (upstream [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265)); unpatched, a multi-megabyte argument stream costs O(n²) CPU on the event loop and stalls every session in the process. Completion events supply parsed arguments; Responses may parse at both argument completion and output-item completion. This adapter reads only delta strings and finalized arguments. Re-apply or retire the patch on every pi-ai upgrade. +- **Stale Anthropic thinking is omitted even where the endpoint would accept it** — prefix checks depend on the model, the account, and the request, which the adapter cannot observe, so every prefix change and every replay state without `prefixDigest` costs the model that reasoning, and Anthropic regenerates it as output tokens. The digest covers the pi-ai context objects, not the wire body: switching authentication or wire-changing compat settings mid-conversation is not detected, a pi-ai upgrade that changes Anthropic message serialization requires a new `DIGEST_SEED` in [`src/prefix-binding.ts`](src/prefix-binding.ts), and any change to how this package builds those objects drops recorded thinking once. +- **A manual-thinking tool round that spans a prefix change can still be rejected** — its open assistant message must keep its stale thinking to satisfy manual thinking, so an endpoint that enforces the prefix check rejects each request until a user message ends the round. Models that enforce the check use adaptive thinking in the installed catalog. - **Gemini tool-call thought signatures are patched into Chat Completions** — the same patch reads `tool_calls[].extra_content.google.thought_signature` from a streamed OpenAI-compatible response into the tool call's replay signature and sends it back as `extra_content` when the same model resumes (upstream [earendil-works/pi#10157](https://github.com/earendil-works/pi/issues/10157)); unpatched, Gemini rejects the next step of a tool-calling turn with HTTP 400. Encrypted OpenRouter reasoning details keep their `reasoning_details` replay. diff --git a/packages/llm/llm-pi-ai/README.zh.md b/packages/llm/llm-pi-ai/README.zh.md index 620fd428f6..69bcd52afa 100644 --- a/packages/llm/llm-pi-ai/README.zh.md +++ b/packages/llm/llm-pi-ai/README.zh.md @@ -192,7 +192,7 @@ git add packages/llm/llm-pi-ai/src/provider-data.generated.ts 成功 assistant 响应会存储带版本的、无损 JSON 回放状态,与产生它们的提供方和模型放在一起——响应级事实加每个流式块一条逐块条目。请求时,`LlmRuntime` 仅当同一适配器实例拥有两条路由时才传递回放状态;适配器校验它并恢复原生响应 id、提供方签名与可选的 `providerThinkingLevel` effort 元数据,缺失的 effort 元数据仍保持缺失。回放会对照 assistant 来源校验请求模型身份,并用它保留同模型签名;提供方报告的响应模型仅作为诊断元数据。回放状态缺失或无法使用时会降级为提供方无关内容,并保留 assistant 来源中必需的提供方和模型。pi-ai 工具调用参数是解析后的对象,因此适配器解析输入并重新字符串化输出,以符合 harness 原始 JSON 约定;pi-ai 流内错误事件映射为终止 `finish` 分片。 协议转换仅作用于请求副本。从 A 切换到 B 再切回 A 时,存储的内容保持完整,包括空文本块、工具 id、原始块顺序和原生回放元数据;发给不同提供方、API 或模型的请求会省略不受支持的签名和推理块。缺少原生元数据的降级回放也会省略推理。切回原模型时,有效的原生回放会保留已存储的推理。 -Anthropic 可以将每个已签名 thinking 块绑定到生成它时的请求前缀:系统提示词、工具和此前的消息。执行此检查的账户和请求会拒绝回放过期块的请求。因此,Anthropic Messages 响应会将其请求上下文的 SHA-256 摘要记录为 `prefixDigest`。后续 Anthropic Messages 请求会在每个回放响应之前重新计算重建前缀的摘要;摘要不同时,例如在压缩、工具结果裁剪、图片卸载或系统提示词或工具变化之后,省略该响应的 thinking 与 redacted thinking。不含 `prefixDigest` 的回放状态(在此检查出现之前写入)按同样方式处理,从而修复已经失败的对话。文本和工具调用保留,已省略的 thinking 在后续请求中保持省略。手动思考要求工具轮次的最后一个 assistant 轮次以 thinking 块开头,因此当模型的 compat 未设置 `forceAdaptiveThinking` 时,请求所延续的工具轮次对应的 assistant 消息会保留其 thinking。摘要只覆盖 pi-ai 上下文;对话中途切换认证或 compat 设置而改变线上请求时,不会被检测到。 +Anthropic 可以将每个已签名 thinking 块绑定到生成它时的请求前缀:系统提示词、工具和此前的消息。执行此检查的账户和请求会拒绝回放过期块的请求。因此,Anthropic Messages 响应会将其请求上下文的 SHA-256 摘要记录为 `prefixDigest`。后续 Anthropic Messages 请求会在每个回放响应之前重新计算重建前缀的摘要;摘要不同时,例如在压缩、工具结果裁剪、图片卸载或系统提示词或工具变化之后,省略该响应的已签名 thinking 与 redacted thinking。不含 `prefixDigest` 的回放状态(在此检查出现之前写入)按同样方式处理,从而修复已经失败的对话。文本、工具调用和未签名 thinking 保留;只含已签名 thinking 的响应不再发送。已省略的 thinking 在后续请求中保持省略。手动思考要求工具轮次的最后一个 assistant 轮次以 thinking 块开头,因此当请求启用 thinking 且模型的 compat 未设置 `forceAdaptiveThinking` 时,请求以工具结果延续的工具轮次对应的 assistant 消息会保留其 thinking。 @@ -268,6 +268,8 @@ pi-ai 事件变成 harness 的推理、文本、工具调用、用量与 finish - **提供方 HTTP 状态不可用**——pi-ai 错误事件不跨提供方暴露稳定 HTTP 状态。 - **重试策略由提供方自有,而非 SDK 重试**——pi-ai SDK 重试保持禁用,因此持久 agent(智能体)步骤与 `llm/retry` 事件拥有每个可见尝试,直接 `ctx.llm.stream()` 调用仍是单次尝试。 - **流式 JSON 工具调用参数避免逐 delta 重复解析**——安装的 pi-ai 带有 [`patches/@earendil-works__pi-ai@1.3.0.patch`](../../../patches/@earendil-works__pi-ai@1.3.0.patch),它移除了每个流适配器中对整段累计参数 JSON 的逐 delta 重新解析(上游 [earendil-works/pi#9265](https://github.com/earendil-works/pi/issues/9265));未打补丁时,数 MB 的参数流会在事件循环上消耗 O(n²) CPU,并使进程内所有会话停滞。完成事件提供解析后的参数;Responses 可能在参数完成和输出项完成时分别解析。本适配器只读取 delta 字符串与最终参数。每次升级 pi-ai 时都要重新应用或撤销该补丁。 +- **即使端点会接受,过期的 Anthropic thinking 也会被省略**——前缀检查取决于模型、账户和请求,而适配器无法观测这些因素,因此每次前缀变化以及每个不含 `prefixDigest` 的回放状态都会让模型失去这部分推理,Anthropic 会以输出 token 重新生成它。摘要覆盖 pi-ai 上下文对象,而不是线上请求体:对话中途切换认证或会改变线上请求的 compat 设置不会被检测到;改变 Anthropic 消息序列化的 pi-ai 升级需要在 [`src/prefix-binding.ts`](src/prefix-binding.ts) 中使用新的 `DIGEST_SEED`;本包构建这些对象的方式发生任何变化,都会使已记录的 thinking 被丢弃一次。 +- **跨越前缀变化的手动思考工具轮次仍可能被拒绝**——其未结束的 assistant 消息必须保留过期的 thinking 以满足手动思考要求,因此执行前缀检查的端点会拒绝每个请求,直到一条用户消息结束该轮次。在已安装的目录中,执行此检查的模型使用自适应思考。 - **Chat Completions 中的 Gemini 工具调用思考签名由补丁保留**——同一补丁把 OpenAI 兼容流式响应里的 `tool_calls[].extra_content.google.thought_signature` 读入工具调用的重放签名,并在同一模型继续时以 `extra_content` 发回(上游 [earendil-works/pi#10157](https://github.com/earendil-works/pi/issues/10157));未打补丁时,Gemini 会以 HTTP 400 拒绝工具调用轮次的下一步。OpenRouter 的加密推理详情仍通过 `reasoning_details` 重放。 diff --git a/packages/llm/llm-pi-ai/src/adapter.ts b/packages/llm/llm-pi-ai/src/adapter.ts index dc27de4535..be41fc2242 100644 --- a/packages/llm/llm-pi-ai/src/adapter.ts +++ b/packages/llm/llm-pi-ai/src/adapter.ts @@ -63,7 +63,7 @@ import { requestImageTarget, toPiContext } from './context.ts' import { conversationUpdates, createModels, getSupportedThinkingLevels } from './models.ts' import { toStreamChunks } from './stream.ts' import { bindThinkingToPrefix } from './prefix-binding.ts' -import type { PrefixDigests } from './replay.ts' +import type { PrefixDigests } from './prefix-binding.ts' import type { PiAiRequestHeaderContext } from './types.ts' /** @@ -129,10 +129,10 @@ export interface PiAiAdapterOptions { */ onReplayDegrade?: (detail: { provider: string; model: string; reason: string }) => void /** - * Observe how many replayed assistant messages one request sent without - * their Anthropic thinking because the request prefix changed or was never - * recorded; called for every request, including with a zero count. Stale - * messages stay omitted, so later requests of the conversation count them again. + * Observe one request that sent replayed assistant messages without their + * signed Anthropic thinking because the request prefix changed or was never + * recorded. Stale messages stay omitted, so later requests of the + * conversation report them again. */ onThinkingOmitted?: (detail: { provider: string; model: string; count: number }) => void } @@ -465,8 +465,11 @@ export class PiAiAdapter extends LlmAdapter { maxBytes: profile.requestImageMaxBytes, }, }, onReplayDegrade, updates.systemPromptUpdate === 'in-history', prefixDigests) - const { context, digest, omitted } = bindThinkingToPrefix(converted, model, prefixDigests) - this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count: omitted }) + const manualThinking = model.reasoning && reasoning !== undefined && reasoning !== 'off' + && (model as Model<'anthropic-messages'>).compat?.forceAdaptiveThinking !== true + const { context, digest } = bindThinkingToPrefix(converted, model.api, prefixDigests, manualThinking, (count) => { + this.config.onThinkingOmitted?.({ provider: options.provider, model: options.model, count }) + }) const events = snapshot.models.streamSimple(model, context, { ...profileOptions(profile, reasoning, apiKey), ...options.temperature === undefined ? {} : { temperature: options.temperature }, diff --git a/packages/llm/llm-pi-ai/src/context.ts b/packages/llm/llm-pi-ai/src/context.ts index 3d5f9d91b7..91e9e1c35f 100644 --- a/packages/llm/llm-pi-ai/src/context.ts +++ b/packages/llm/llm-pi-ai/src/context.ts @@ -16,7 +16,7 @@ import type { } from '@deepseek-ai/dsh-attachment' import type { Context as PiContext, ImageContent, Message as PiMessage, SystemMessage, TextContent, Tool as PiTool } from '@earendil-works/pi-ai' import { toPiAssistant } from './replay.ts' -import type { PrefixDigests } from './replay.ts' +import type { PrefixDigests } from './prefix-binding.ts' import { requestImageDimensions } from '@deepseek-ai/dsh-attachment' import { DEFAULT_REQUEST_IMAGE_MAX_BYTES, DEFAULT_REQUEST_IMAGE_PIXEL_BUDGET } from './config.ts' diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 9fa3f0c7ac..a671b7229a 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -253,7 +253,7 @@ export function apply(ctx: Context, config: Config): void { ) }, onThinkingOmitted: ({ provider, model, count }) => { - if (count > 0) ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`) + ctx.logger.debug(`llm-pi-ai: omitted stale thinking from ${count} assistant message(s) for route "${provider}/${model}"`) }, }) // Independent of the route set: signing in is what makes a route worth diff --git a/packages/llm/llm-pi-ai/src/prefix-binding.ts b/packages/llm/llm-pi-ai/src/prefix-binding.ts index d5ed0451df..9ca530c339 100644 --- a/packages/llm/llm-pi-ai/src/prefix-binding.ts +++ b/packages/llm/llm-pi-ai/src/prefix-binding.ts @@ -6,44 +6,55 @@ * and tool-result pruning rewrite that prefix, so a retained signed block * becomes invalid and endpoints that enforce the check reject the whole * request. Each Anthropic Messages response records a digest of the context it - * was generated from; a later request replays its thinking only while the - * reconstructed prefix still has the same digest. Replay state without a + * was generated from; a later request replays its signed thinking only while + * the reconstructed prefix still has the same digest. Replay state without a * digest predates this check and is treated as stale. * - * Manual (non-adaptive) thinking requires the final assistant turn of a tool - * round to start with a thinking block, so that message keeps its thinking - * when the model does not force adaptive thinking. + * The digest covers the pi-ai context objects this package builds, including + * fields Anthropic never receives. Any change to their construction + * invalidates every recorded digest once; a pi-ai upgrade that changes how + * Anthropic messages are serialized without changing those objects requires a + * new {@link DIGEST_SEED}. * * @module dsh-llm-pi-ai/prefix-binding */ import { createHash } from 'node:crypto' -import type { Api, AssistantMessage, Context as PiContext, Model } from '@earendil-works/pi-ai' -import type { PrefixDigests } from './replay.ts' +import type { Api, AssistantMessage, Context as PiContext } from '@earendil-works/pi-ai' + +/** Recorded request-prefix digests of reconstructed assistant messages, owned by one context conversion. */ +export type PrefixDigests = Map /** Wire protocol whose thinking signatures bind to the request prefix. */ const PREFIX_BOUND_API = 'anthropic-messages' -/** Seed of every digest chain; changing the digest input requires a new seed. */ +/** Seed of every digest chain; changing the digest input or its serialization requires a new seed. */ const DIGEST_SEED = 'dsh-llm-pi-ai/prefix-binding/v1' function chain(previous: string, value: unknown): string { return createHash('sha256').update(previous).update('\0').update(JSON.stringify(value)).digest('hex') } -/** Index of the assistant message whose tool round the request continues, or -1. */ +/** Index of the assistant message whose tool round the request continues with tool results, or -1. */ function openToolRoundAssistant(messages: PiContext['messages']): number { + let results = 0 for (let index = messages.length - 1; index >= 0; index--) { const message = messages[index] as PiContext['messages'][number] - if (message.role === 'assistant') return index + if (message.role === 'assistant') return results > 0 ? index : -1 if (message.role !== 'toolResult') return -1 + results++ } return -1 } -/** Copy of an assistant message without its thinking blocks. */ -function withoutThinking(message: AssistantMessage): AssistantMessage { - return { ...message, content: message.content.filter(block => block.type !== 'thinking') } +/** Whether a thinking block carries a signature or redacted payload that binds it to its prefix. */ +function signed(block: AssistantMessage['content'][number]): boolean { + return block.type === 'thinking' && (block.redacted === true || (block.thinkingSignature ?? '').length > 0) +} + +/** Copy of an assistant message without its signed thinking blocks. */ +function withoutSignedThinking(message: AssistantMessage): AssistantMessage { + return { ...message, content: message.content.filter(block => !signed(block)) } } /** Context whose stale thinking is removed, with the digest of the complete request. */ @@ -52,27 +63,31 @@ export interface PrefixBoundContext { context: PiContext /** Digest of the sent context, recorded with the response; absent for other protocols. */ digest?: string - /** Number of replayed assistant messages whose thinking was removed. */ - omitted: number } /** - * Remove thinking from replayed Anthropic Messages assistant messages whose - * request prefix changed since they were generated, or whose replay state - * records no prefix digest. + * Remove signed thinking from replayed Anthropic Messages assistant messages + * whose request prefix changed since they were generated, or whose replay + * state records no prefix digest. Manual thinking requires the final + * assistant turn of a tool round to start with a thinking block, so with + * `manualThinking` the assistant message whose tool round the request + * continues keeps its thinking. * @param context - converted request context. - * @param model - requested model. + * @param api - wire protocol of the requested model. * @param prefixDigests - recorded digests the context conversion collected. - * @returns the context to send, its digest for Anthropic Messages models, and the omission count. + * @param manualThinking - whether the request enables budget-based rather than adaptive thinking. + * @param onOmitted - called once with the number of messages whose thinking was removed, when there are any. + * @returns the context to send and, for Anthropic Messages, its digest. */ export function bindThinkingToPrefix( context: PiContext, - model: Model, + api: Api, prefixDigests: PrefixDigests, + manualThinking: boolean, + onOmitted: (count: number) => void, ): PrefixBoundContext { - if (model.api !== PREFIX_BOUND_API) return { context, omitted: 0 } - const adaptive = (model as Model).compat?.forceAdaptiveThinking === true - const kept = adaptive ? -1 : openToolRoundAssistant(context.messages) + if (api !== PREFIX_BOUND_API) return { context } + const kept = manualThinking ? openToolRoundAssistant(context.messages) : -1 let digest = chain(DIGEST_SEED, { systemPrompt: context.systemPrompt ?? null, tools: context.tools ?? null }) let messages: PiContext['messages'] | undefined let omitted = 0 @@ -80,14 +95,16 @@ export function bindThinkingToPrefix( let message = original if ( message.role === 'assistant' && message.api === PREFIX_BOUND_API && index !== kept - && prefixDigests.get(message) !== digest && message.content.some(block => block.type === 'thinking') + && prefixDigests.get(message) !== digest && message.content.some(signed) ) { - message = withoutThinking(message) + message = withoutSignedThinking(message) messages ??= context.messages.slice() messages[index] = message omitted++ } digest = chain(digest, message) } - return { context: messages === undefined ? context : { ...context, messages }, digest, omitted } + if (messages === undefined) return { context, digest } + onOmitted(omitted) + return { context: { ...context, messages }, digest } } diff --git a/packages/llm/llm-pi-ai/src/replay.ts b/packages/llm/llm-pi-ai/src/replay.ts index 8e5bcd3ca3..ba1be42e14 100644 --- a/packages/llm/llm-pi-ai/src/replay.ts +++ b/packages/llm/llm-pi-ai/src/replay.ts @@ -11,6 +11,7 @@ import { LlmError } from '@deepseek-ai/dsh-llm' import type { AssistantMessage as HarnessAssistantMessage, ModelMessageSource, ReplayEnvelope } from '@deepseek-ai/dsh-llm' import type { Api, AssistantMessage, ToolCall, Usage as PiUsage } from '@earendil-works/pi-ai' +import type { PrefixDigests } from './prefix-binding.ts' /** Per-block half of the pi-ai replay envelope, one entry per content block. */ export type PiAiReplayBlock = @@ -67,9 +68,6 @@ function emptyPiUsage(): PiUsage { } } -/** Recorded request-prefix digests of reconstructed assistant messages, owned by one context conversion. */ -export type PrefixDigests = Map - /** * Project a successful pi-ai response into the minimal durable replay state. * The per-block half is index-aligned with the streamed blocks (pi-ai content diff --git a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts index f3c108c027..ac003107a2 100644 --- a/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts +++ b/packages/llm/llm-pi-ai/tests/prefix-binding.spec.ts @@ -21,11 +21,14 @@ const tool = { name: 'lookup', description: 'Look up a value.', parameters: { ty /** One Anthropic response: signed and redacted thinking, then a tool call or a reply. */ function frames(signature: string, toolUse: boolean): ReturnType[] { - const blocks = [ + return response(signature, [ { type: 'thinking', thinking: `thinking ${signature}`, signature }, { type: 'redacted_thinking', data: `redacted-${signature}` }, toolUse ? { type: 'tool_use', id: `toolu_${signature}`, name: 'lookup', input: { key: 1 } } : { type: 'text', text: 'done' }, - ] + ], toolUse) +} + +function response(signature: string, blocks: Record[], toolUse = false): ReturnType[] { return [ anthropicFrame({ type: 'message_start', message: { id: `msg_${signature}`, type: 'message', role: 'assistant', model: 'model', content: [], @@ -40,14 +43,15 @@ function frames(signature: string, toolUse: boolean): ReturnType[0], adaptive = true) { +async function setup(api: 'anthropic-messages' | 'openai-completions', script: Parameters[0], adaptive = true, manual = false) { vi.stubEnv('PREFIX_BINDING_KEY', 'test-key') const server = await mockServer(script) const ctx = new Context() contexts.push(ctx) await ctx.plugin(LlmRuntime) await ctx.plugin(LlmPiAi, { providers: { - route: { api, baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none', models: [{ id: 'model' }], + route: { api, baseURL: server.url, apiKeyEnv: 'PREFIX_BINDING_KEY', cacheRetention: 'none', models: [{ id: 'model', ...manual ? { reasoningEfforts: { high: 'high' } } : {} }], + ...manual ? { reasoning: 'high' as const } : {}, ...api === 'anthropic-messages' ? { compat: { forceAdaptiveThinking: adaptive } } : {} }, } }) const run = async (messages: Message[]) => { @@ -137,17 +141,59 @@ describe('Anthropic thinking prefix binding', () => { expect(wire(server.requests[1])).toContain('toolu_first') }) - it('keeps stale thinking only on the open tool round of a manual-thinking model', async () => { + it('keeps stale thinking only on the open tool round of a manual-thinking request', async () => { const { server, run } = await setup('anthropic-messages', [ { events: frames('first', true) }, { events: frames('second', true) }, { events: frames('third', false) }, - ], false) + { events: frames('fourth', false) }, + ], false, true) const first = await run([user('Look up the value.')]) const second = await run([user('Look up the value.'), first, result(callId(first), 'value')]) await run([user(''), first, result(callId(first), 'value'), second, result(callId(second), 'value')]) expect(wire(server.requests[2])).not.toContain('"signature":"first"') expect(wire(server.requests[2])).toContain('"signature":"second"') + expect(server.requests[2]).toMatchObject({ thinking: { type: 'enabled' } }) + + // Once a user message closes the round, the formerly kept thinking is stale too. + const compacted = [user(''), first, result(callId(first), 'value'), second, result(callId(second), 'value')] + await run([...compacted, user('Continue.')]) + expect(wire(server.requests[3])).not.toContain('"signature":"second"') + }) + + it('sends an empty manual-thinking history unchanged', async () => { + const { server, run } = await setup('anthropic-messages', [{ events: frames('first', false) }], false, true) + await run([]) + expect(server.requests[0]).toMatchObject({ messages: [] }) + }) + + it('omits stale manual thinking from a final assistant message without tool results', async () => { + const { server, run } = await setup('anthropic-messages', [{ events: frames('first', false) }, { events: frames('second', false) }], false, true) + const first = await run([user('Answer.')]) + await run([user(''), first]) + expect(wire(server.requests[1])).not.toContain('"signature":"first"') + }) + + it('omits stale thinking of the open tool round when the request disables thinking', async () => { + const { server, run } = await setup('anthropic-messages', [{ events: frames('first', true) }, { events: frames('second', false) }], false) + const first = await run([user('Look up the value.')]) + await run([user(''), first, result(callId(first), 'value')]) + expect(wire(server.requests[1])).not.toContain('"signature":"first"') + }) + + it('drops a thinking-only message and keeps unsigned thinking', async () => { + const { server, run } = await setup('anthropic-messages', [ + { events: response('only', [{ type: 'thinking', thinking: 'thinking only', signature: 'only' }]) }, + { events: response('unsigned', [{ type: 'thinking', thinking: 'unsigned thinking', signature: '' }, { type: 'text', text: 'reply' }]) }, + { events: frames('third', false) }, + ]) + const only = await run([user('Think.')]) + const unsigned = await run([user('Think.'), only, user('Answer.')]) + await run([user(''), only, user('Answer.'), unsigned, user('Again.')]) + const messages = (server.requests[2] as { messages: { role: string }[] }).messages + expect(messages.map(message => message.role)).toEqual(['user', 'user', 'assistant', 'user']) + expect(wire(server.requests[2])).not.toContain('thinking only') + expect(wire(server.requests[2])).toContain('unsigned thinking') }) it('omits stale thinking without an omission observer', async () => { From db9de7da60076fed34b02ad4681d02606e52fe2b Mon Sep 17 00:00:00 2001 From: Turtle Date: Mon, 2 Nov 2026 18:03:17 +0800 Subject: [PATCH 010/106] refactor(ui-settings-models): build retry-policy fields from the provider schema --- .../mobile-collapsed.expected.md | 6 + .../mobile-expanded.expected.md | 6 + apps/web/tests/models-settings.e2e.ts | 6 +- .../ui-settings-models/README.i18n.yaml | 8 +- packages/client/ui-settings-models/README.md | 4 +- .../client/ui-settings-models/README.zh.md | 4 +- .../src/client/ProviderEditor.tsx | 6 +- .../src/client/RetryPolicyEditor.tsx | 327 ++++++++++-------- .../ui-settings-models/src/client/locales.ts | 64 ++-- .../tests/retry-policy.client.spec.tsx | 90 ++++- packages/llm/llm/src/retry-policy.ts | 8 +- packages/llm/llm/tests/retry-policy.spec.ts | 16 + 12 files changed, 352 insertions(+), 193 deletions(-) diff --git a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md index 46babcd11e..2f573f6e53 100644 --- a/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md +++ b/apps/web/tests/expected/models-settings/mobile-collapsed.expected.md @@ -36,6 +36,12 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - group "重试策略": + - text: 重试策略 + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md index b1f780f9b3..5e0855c653 100644 --- a/apps/web/tests/expected/models-settings/mobile-expanded.expected.md +++ b/apps/web/tests/expected/models-settings/mobile-expanded.expected.md @@ -36,6 +36,12 @@ - option "OpenAI Chat Completions" - option "OpenAI Responses" - option "Anthropic Messages" [selected] + - group "重试策略": + - text: 重试策略 + - combobox "重试策略": + - option "默认(仅临时错误)" [selected] + - option "仅临时错误" + - option "所有错误" - region "模型目录": - text: 模型目录 已自定义模型目录 - button "恢复默认模型" diff --git a/apps/web/tests/models-settings.e2e.ts b/apps/web/tests/models-settings.e2e.ts index 67dc2773fb..c59d3cd2e6 100644 --- a/apps/web/tests/models-settings.e2e.ts +++ b/apps/web/tests/models-settings.e2e.ts @@ -641,8 +641,10 @@ describe('web e2e: Models settings page configures a dormant provider', () => { await toggle.click() expect(await name.inputValue()).toBe('手机上的完整显示名称') const expandedName = await name.boundingBox() - if (expandedName === null) throw new Error('Expanded model name is not visible') - expect(expandedName.y).toBeGreaterThanOrEqual(compactId.y + compactId.height) + // Expanding can scroll the dialog, so the ID is measured again in the same frame. + const expandedId = await id.boundingBox() + if (expandedName === null || expandedId === null) throw new Error('Expanded model fields are not visible') + expect(expandedName.y).toBeGreaterThanOrEqual(expandedId.y + expandedId.height) expect(expandedName.width).toBeGreaterThan(compactId.width) expect(await dialog.getByRole('region', { name: '模型目录', exact: true }) .evaluate(element => element.scrollWidth <= element.clientWidth)).toBe(true) diff --git a/packages/client/ui-settings-models/README.i18n.yaml b/packages/client/ui-settings-models/README.i18n.yaml index 18d6e519e0..ff9ccb9f3e 100644 --- a/packages/client/ui-settings-models/README.i18n.yaml +++ b/packages/client/ui-settings-models/README.i18n.yaml @@ -21,8 +21,8 @@ en: d771da09da774692 zh: 9566b3c0e95da8a6 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/editing-a-provider: - en: a9ff7675aa8a5c7c - zh: 85d61b45635c9f7a + en: bf0cf1707b319061 + zh: 30f1fe4e655723b3 /deepseek-ai-dsh-client-ui-settings-models/use-this-package/adding-and-deleting-providers: en: c0b04b2b11af60d3 zh: d195eec281e445cb @@ -36,8 +36,8 @@ en: a87afbec5e339b36 zh: 6348c81af7e650e1 /deepseek-ai-dsh-client-ui-settings-models/understand-the-implementation/validation: - en: 1695ec7430ad88e1 - zh: 1df96b8ae82c2e26 + en: d469e496518d44c5 + zh: e71938ef7502a20c /deepseek-ai-dsh-client-ui-settings-models/understand-the-implementation/concurrency-and-credentials: en: 6e04c00818f014cd zh: 2d57f8d8944e67ed diff --git a/packages/client/ui-settings-models/README.md b/packages/client/ui-settings-models/README.md index 0f1d2d6146..84abb42ce6 100644 --- a/packages/client/ui-settings-models/README.md +++ b/packages/client/ui-settings-models/README.md @@ -55,7 +55,7 @@ An endpoint without a fixed catalog default shows the provider's `baseURLEnv` co The collapsed 自定义设置 fold carries the curated extras: `baseURL` for both families (the placeholder shows the endpoint the profile resolves to, the public endpoint for deepseek, else the `defaultBaseURL` the configurable-provider directory reports for a pi-ai catalog route), each adapter's model catalog, and the **display name** and **API protocol** of a pi-ai route the adapter does not ship. Profile `headers` remain deployment configuration in `cordis.patch.yml` or Cordis config and have no Models-page editor. The Provider ID stays fixed: it is the settings key, the name every other namespace and every logged session references, and the stem of a credential reference the page cannot read back to move. There is no provider-scoped reasoning-effort field: effort is a per-model capability, so a provider-scoped control could only be set to a value some models reject. Each model row edits `id`, optional display `name`, optional `contextWindow`/`maxTokens`, input types, and, on a hand-declared pi-ai route, reasoning levels; unrelated model fields survive edits. -The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the maximum retry count and the retried error codes, ticked from a list that names each stable failure code in words (for example **Rate limited** for `RATE_LIMIT`) and also keeps any stored code outside it, shown verbatim; the last ticked code cannot be cleared, and ticking exactly the inherited codes keeps the field inherited; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses: a negative or fractional retry count, an empty code list, a blank or non-text code, a repeated code, a delay that is not positive or exceeds the schema maximum, an initial delay above the maximum delay, or a jitter ratio outside 0 to 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or transient mode when none is inherited, and Apply saves that mode. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy. +The same fold edits the provider's `retryPolicy` for both families. **Retry policy** defaults to the inherited policy and names its mode; choosing **Transient errors** or **All errors** writes a user-layer policy. Transient mode edits the maximum retry count and the retried error codes, ticked from a list that names each stable failure code in words (for example **Rate limited** for `RATE_LIMIT`) and also keeps any stored code outside it, shown verbatim; the last ticked code cannot be cleared, and ticking exactly the inherited codes keeps the field inherited; both modes edit the initial delay, maximum delay, and jitter ratio under a **Backoff** fold, which starts open only when the stored policy customizes backoff. Each empty field shows and keeps the inherited value, read from the composition layer, then the schema default. Apply is disabled while a field holds a value the adapter refuses, such as a fractional retry count, a delay that is not positive, or a jitter ratio above 1. The refusal appears below the fields, or above the card actions while the fold is closed. A stored policy without a mode shows the inherited mode, or the schema's first mode when none is inherited, and Apply saves that mode. Switching modes keeps only the fields the new mode declares. Clearing a field unsets it, so the inherited value applies again; choosing the default again removes the user-layer policy. The DeepSeek card edits the `llm-deepseek` endpoint, credentials, retry policy, and model catalog. It uses Messages, with the default endpoint placeholder `https://api.deepseek.com/anthropic`. @@ -93,7 +93,7 @@ The page never holds a full settings section: it holds only the REDACTED descrip ### Validation -A typed API key is judged on its own field: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. A drafted `retryPolicy` passes the checks of `resolveRetryPolicy` in `@deepseek-ai/dsh-llm`, mirrored for the same reason, because `llm-deepseek` only logs a warning for a live policy it refuses. Switching to always mode keeps the transient-only fields, which the adapter accepts and ignores, except a retry count that is not a number and could no longer be corrected. The card writes the policy field by field, so an unchanged inherited field stays inherited. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. +A typed API key is judged on its own field: after trimming, it must be non-empty and every character must be printable ASCII (`[\x21-\x7E]`), which is exactly what an HTTP header value can carry — the twin of `normalizeApiKey` in `@deepseek-ai/dsh-llm`, mirrored here because the source-plane split forbids importing it. A value matching a pasted `NAME=value` environment line or wrapped in matching quotes is refused as the same format failure. Empty ids, duplicate ids, empty explicit names, and unreadable, non-positive, or fractional capacities fail before any write. The retry fields come from the `retryPolicy` schema the namespace publishes: each union branch is a mode, number fields become inputs whose integer step, minimum, and maximum are checked from the schema, string lists become the code picker, nested objects become folds, and fields of other types are not shown. The labels, hints, messages, and code names are copy owned by this page; an unlabelled field shows its schema key. The two rules `resolveRetryPolicy` in `@deepseek-ai/dsh-llm` enforces beyond any field bound, an initial delay at most the maximum delay and no repeated code, are mirrored for the same reason, because `llm-deepseek` only logs a warning for a live policy it refuses. The card writes the policy field by field, so an unchanged inherited field stays inherited. DeepSeek's `models` is one replace-by-value array: the editor shows inherited effective rows until the first model edit materializes the complete array in the user layer, while reset unsets that override. ### Concurrency and credentials diff --git a/packages/client/ui-settings-models/README.zh.md b/packages/client/ui-settings-models/README.zh.md index efeb3decb3..8b51476f2a 100644 --- a/packages/client/ui-settings-models/README.zh.md +++ b/packages/client/ui-settings-models/README.zh.md @@ -55,7 +55,7 @@ API 密钥输入框以掩码显示已保存的密钥,并通过 `autocomplete=" 收起的「自定义设置」折叠区承载精选的额外字段:两个家族都有 `baseURL`(占位符显示 profile 解析出的端点,deepseek 即公共端点;没有时显示可配置提供商目录为 pi-ai 内置路由报告的 `defaultBaseURL`)、各适配器自己的模型目录,以及适配器未提供的 pi-ai 路由的**显示名称**与 **API 协议**。Profile `headers` 仍是 `cordis.patch.yml` 或 Cordis 配置中的部署配置,Models 页面不提供编辑器。Provider ID 保持固定:它是 settings 的键、其他每个 namespace 与每一条已记录会话引用的名字,也是页面读不回、因而搬不走的凭据引用词干。这里没有提供商级的推理等级字段:它是按模型的能力,提供商级的控件只可能被设成某些模型会拒绝的值。每个模型行可编辑 `id`、可选显示 `name`、可选 `contextWindow`/`maxTokens`、输入类型,以及手工声明的 pi-ai 路由上的推理等级;无关的模型字段在编辑后仍会保留。 -同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和要重试的错误码,错误码从以文字命名各稳定失败码的列表中勾选(例如 `RATE_LIMIT` 显示为**请求限流**),列表也保留已存储的列表外错误码并原样显示;最后一个勾选项不能取消,勾选的错误码与继承值完全相同时字段保持继承;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用:负数或小数重试次数、空的错误码列表、空白或非文本的错误码、重复的错误码、非正或超过 schema 上限的延迟、初始延迟大于最大延迟,或抖动比例不在 0 到 1 之间。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示临时错误模式,应用时会保存该模式。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。 +同一折叠区还为两个家族编辑提供商的 `retryPolicy`。**重试策略**默认沿用继承的策略并显示其模式;选择**仅临时错误**或**所有错误**会写入用户层策略。临时错误模式编辑最大重试次数和要重试的错误码,错误码从以文字命名各稳定失败码的列表中勾选(例如 `RATE_LIMIT` 显示为**请求限流**),列表也保留已存储的列表外错误码并原样显示;最后一个勾选项不能取消,勾选的错误码与继承值完全相同时字段保持继承;两种模式都在**退避**折叠区中编辑初始延迟、最大延迟和抖动比例,只有已存储策略自定义了退避时该折叠区才默认展开。每个空字段显示并保留继承值,先取组合层的值,再取 schema 默认值。字段中有适配器会拒绝的值时,应用按钮不可用,例如小数重试次数、非正延迟或大于 1 的抖动比例。拒绝原因显示在字段下方;折叠区收起时显示在卡片操作按钮上方。未设置模式的已存储策略显示继承的模式,没有可继承的模式时显示 schema 中的第一个模式,应用时会保存该模式。切换模式时只保留新模式声明的字段。清空字段会取消该字段的设置,从而重新使用继承值;重新选择默认会移除用户层策略。 `llm-deepseek` 的 DeepSeek 卡片编辑端点、凭据、重试策略和模型目录。它使用 Messages,默认端点占位符为 `https://api.deepseek.com/anthropic`。 @@ -93,7 +93,7 @@ Models 页面包含 **DeepSeek 账号**(`deepseek-account`,英文为 **DeepS ### 校验 -键入的 API 密钥按其自身字段判定:去除首尾空白后必须非空,且每个字符都必须是可打印 ASCII(`[\x21-\x7E]`),这正是 HTTP 头值能够携带的字符集——与 `@deepseek-ai/dsh-llm` 中的 `normalizeApiKey` 互为镜像,此处复刻是因为源平面拆分禁止导入它。与粘贴的 `NAME=value` 环境行一致或包裹在匹配引号内的值,会作为同样的格式失败被拒绝。空 id、重复 id、空显式名称以及不可读、非正数或小数的容量都会在任何写入之前失败。草稿中的 `retryPolicy` 须通过 `@deepseek-ai/dsh-llm` 中 `resolveRetryPolicy` 的检查,出于同样原因在此复刻,因为 `llm-deepseek` 对它拒绝的实时策略只记录警告。切换到重试所有错误模式时保留仅临时错误模式使用的字段,适配器接受并忽略它们;但不是数字的重试次数会被移除,因为切换后无法再修正。卡片按字段写入策略,因此未修改的继承字段保持继承。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器先显示继承的有效行,直到第一次模型编辑把完整数组物化进用户层,重置则取消该覆盖。 +键入的 API 密钥按其自身字段判定:去除首尾空白后必须非空,且每个字符都必须是可打印 ASCII(`[\x21-\x7E]`),这正是 HTTP 头值能够携带的字符集——与 `@deepseek-ai/dsh-llm` 中的 `normalizeApiKey` 互为镜像,此处复刻是因为源平面拆分禁止导入它。与粘贴的 `NAME=value` 环境行一致或包裹在匹配引号内的值,会作为同样的格式失败被拒绝。空 id、重复 id、空显式名称以及不可读、非正数或小数的容量都会在任何写入之前失败。重试字段来自命名空间发布的 `retryPolicy` schema:每个 union 分支是一种模式,数字字段成为输入框,其整数步长、最小值和最大值按 schema 检查,字符串列表成为错误码勾选列表,嵌套对象成为折叠区,其他类型的字段不显示。标签、提示、错误消息和错误码名称是本页面拥有的文案;没有标签的字段显示其 schema 键名。`@deepseek-ai/dsh-llm` 中 `resolveRetryPolicy` 在字段上限之外还强制执行两条规则:初始延迟不大于最大延迟、错误码不重复,出于同样原因在此复刻,因为 `llm-deepseek` 对它拒绝的实时策略只记录警告。卡片按字段写入策略,因此未修改的继承字段保持继承。DeepSeek 的 `models` 是一个按值整体替换的数组:编辑器先显示继承的有效行,直到第一次模型编辑把完整数组物化进用户层,重置则取消该覆盖。 ### 并发与凭据 diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 336dc50307..89f3c6e4cf 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -193,7 +193,11 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { // A stored retry policy without a mode edits under the mode it inherits; // the draft names it so a saved policy always carries one. if (!isRecord(policy) || policy['mode'] !== undefined) return loaded - const mode = schema.getPath(namespace.base, [...settingsPath, 'retryPolicy', 'mode']) ?? 'normal' + const path = [...settingsPath, 'retryPolicy'] + const node = schema.nodeAtPath(schema.rehydrate(namespace.schema), path) + /* v8 ignore next -- a stored retryPolicy implies the schema declares one */ + if (node === undefined) return loaded + const { mode } = retryPolicyDefaults(schema, node, schema.getPath(namespace.base, path)) return { ...loaded, retryPolicy: { ...policy, mode } } }) const [keyDraft, setKeyDraft] = useState(props.initialCredential ?? '') diff --git a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx index e35e04373b..6097f9c7b2 100644 --- a/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx +++ b/packages/client/ui-settings-models/src/client/RetryPolicyEditor.tsx @@ -1,9 +1,11 @@ /** - * The provider retry-policy fields of an editor card. Both adapter families - * embed the same `retryPolicy` schema in a provider profile, so the card reads - * its modes, defaults, and bounds from that schema instead of copying them. - * An absent `retryPolicy` keeps the inherited policy; choosing a mode writes a - * user-layer policy whose empty fields keep the inherited values. + * The provider retry-policy fields of an editor card, built from the + * `retryPolicy` schema the owning namespace publishes: its union branches are + * the modes, each branch's fields are the inputs, and the field bounds and + * defaults come from the schema. Number fields render as inputs, string lists + * as a code picker, and nested objects as a fold. An absent `retryPolicy` + * keeps the inherited policy; choosing a mode writes a user-layer policy whose + * empty fields keep the inherited values. */ import { useState } from 'react' @@ -16,25 +18,25 @@ import styles from './ModelsSection.module.css' /** One schema node of the provider's settings form. */ type SchemaNode = NonNullable> -const CODES = ['retryableCodes'] as const -const MAX_RETRIES = ['maxRetries'] as const -const INITIAL_DELAY = ['backoff', 'initialDelayMs'] as const -const MAX_DELAY = ['backoff', 'maxDelayMs'] as const -const JITTER = ['backoff', 'jitterRatio'] as const +/** Labels of known modes and fields by schema key; any other key shows verbatim. */ +const LABELS: ReadonlyMap = new Map([ + ['normal', 'retryModeNormal'], + ['always', 'retryModeAlways'], + ['maxRetries', 'retryMaxRetries'], + ['retryableCodes', 'retryCodes'], + ['backoff', 'retryBackoff'], + ['initialDelayMs', 'retryInitialDelay'], + ['maxDelayMs', 'retryMaxDelay'], + ['jitterRatio', 'retryJitter'], +]) -type FieldPath = typeof MAX_RETRIES | typeof INITIAL_DELAY | typeof MAX_DELAY | typeof JITTER - -/** Backoff fields of both modes, as `[policy path, label]`. */ -const BACKOFF_FIELDS: readonly (readonly [FieldPath, ModelsKey])[] = [ - [INITIAL_DELAY, 'retryInitialDelay'], - [MAX_DELAY, 'retryMaxDelay'], - [JITTER, 'retryJitter'], -] +/** Hints shown under the mode select, by mode. */ +const MODE_HINTS: ReadonlyMap = new Map([['always', 'retryAlwaysHint']]) /** - * Stable `LlmFailure` codes offered as retryable choices, with their labels: the + * Stable `LlmFailure` codes offered in a code picker, with their labels: the * transient defaults of `dsh-llm`, then the other request failures a retry can - * plausibly recover. A stored code outside this list stays listed after them. + * plausibly recover. Inherited and stored codes outside this list stay listed after them. */ const CODE_CHOICES: ReadonlyMap = new Map([ ['EMPTY_RESPONSE', 'retryCodeEmptyResponse'], @@ -50,42 +52,71 @@ const CODE_CHOICES: ReadonlyMap = new Map([ /** Plain decimal text; prefixed forms such as `0x10` stay invalid text. */ const DECIMAL = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:e[+-]?\d+)?$/iu -/** The inherited policy beneath the user layer, plus the schema bounds of its fields. */ +/** The pair `resolveRetryPolicy` orders beyond any single field bound: initial delay at most the maximum. */ +const DELAY_ORDER = [['backoff', 'initialDelayMs'], ['backoff', 'maxDelayMs']] as const + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Read a nested field of a plain policy object. */ +function fieldAt(value: Record, path: readonly string[]): unknown { + return path.reduce((node, key) => isRecord(node) ? node[key] : undefined, value) +} + +/** The mode a union branch selects, read from its `mode` constant. */ +function modeOf(branch: SchemaNode): string | undefined { + const mode: unknown = branch.dict?.['mode']?.value + return typeof mode === 'string' ? mode : undefined +} + +/** The editable fields of one object node, in schema order, without the `mode` discriminant. */ +function fieldsOf(node: SchemaNode): [string, SchemaNode][] { + /* v8 ignore next -- object schema nodes always carry `dict` */ + return Object.entries(node.dict ?? {}).filter(([key]) => key !== 'mode') +} + +/** The inherited policy beneath the user layer, and the schema branch of each mode. */ export interface RetryPolicyDefaults { + /** Modes in schema order. */ + modes: readonly string[] /** Mode in effect when the user layer names none. */ - mode: 'normal' | 'always' - /** Read the inherited value of one policy field: the composition layer, then the schema default. */ - value: (path: readonly string[]) => unknown - /** Read the schema maximum of one numeric policy field. */ - max: (path: readonly string[]) => number | undefined + mode: string + /** Schema branch of a stored `mode` value, or undefined when no branch declares it. */ + branch: (mode: unknown) => SchemaNode | undefined + /** Inherited value of one field of a branch: the composition layer, then the branch default. */ + value: (branch: SchemaNode, path: readonly string[]) => unknown } /** - * Resolve the inherited policy beneath the user layer. + * Resolve the modes and inherited policy beneath the user layer. * @param schema - settings schema operations. * @param node - the `retryPolicy` schema union. * @param inherited - the policy the composition layer sets, if any. - * @returns the inherited mode, field values, and field bounds. + * @returns the modes and the inherited mode and field values. */ export function retryPolicyDefaults( schema: SettingsSchemaOperations, node: SchemaNode, inherited: unknown, ): RetryPolicyDefaults { - const normal = node.list?.find(branch => branch.dict?.['mode']?.value === 'normal') - const field = (path: readonly string[]) => normal === undefined ? undefined : schema.nodeAtPath(normal, path) + // A schema that is not a union of mode branches has no `list` and offers no mode. + const branches = new Map((node.list ?? []).flatMap((branch) => { + const mode = modeOf(branch) + return mode === undefined ? [] : [[mode, branch] as const] + })) + const modes = [...branches.keys()] + const stored = schema.getPath(inherited, ['mode']) return { - mode: schema.getPath(inherited, ['mode']) === 'always' ? 'always' : 'normal', - value: (path) => { + modes, + mode: typeof stored === 'string' && branches.has(stored) ? stored : modes[0] ?? '', + branch: mode => typeof mode === 'string' ? branches.get(mode) : undefined, + value: (branch, path) => { const value = schema.getPath(inherited, path) if (value !== undefined) return value - const fallback: unknown = field(path)?.meta.default + const fallback: unknown = schema.nodeAtPath(branch, path)?.meta.default return fallback }, - max: (path) => { - const max: unknown = field(path)?.meta.max - return typeof max === 'number' ? max : undefined - }, } } @@ -95,11 +126,25 @@ export interface RetryPolicyFailure { path?: readonly string[] } +/** The first bound of a number field's schema that `value` breaks, as copy. */ +function numberFailure(node: SchemaNode, value: unknown, t: (key: ModelsKey) => string): string | undefined { + if (typeof value !== 'number' || !Number.isFinite(value)) return t('retryValueNumber') + const { min, max, step } = node.meta + if (step === 1 && !Number.isInteger(value)) return t('retryValueInteger') + // `Number.MIN_VALUE` is the schema idiom for a positive bound. + if (min === Number.MIN_VALUE && value <= 0) return t('retryValuePositive') + if (typeof min === 'number' && value < min) return t('retryValueMin').replace('{min}', () => String(min)) + if (typeof max === 'number' && value > max) return t('retryValueMax').replace('{max}', () => String(max)) + return undefined +} + /** - * The first refused field of a drafted policy, checked as `resolveRetryPolicy` - * checks the merged policy: fields the draft leaves empty inherit. + * The first refused field of a drafted policy, checked against the selected + * branch's field bounds with empty fields inherited, then against the + * cross-field rules `resolveRetryPolicy` enforces beyond the schema, then + * against the whole schema. * @param schema - settings schema operations. - * @param node - the `retryPolicy` schema union, which bounds every field. + * @param node - the `retryPolicy` schema union. * @param policy - the drafted user-layer policy; undefined inherits. * @param defaults - the inherited policy beneath the draft. * @param t - section copy. @@ -113,43 +158,50 @@ export function retryPolicyFailure( t: (key: ModelsKey) => string, ): RetryPolicyFailure | undefined { if (policy === undefined) return undefined - if (typeof policy !== 'object' || policy === null || Array.isArray(policy)) { + const whole = (): RetryPolicyFailure | undefined => { const message = schema.validate(node, policy) return message === undefined ? undefined : { message } } - const fail = (key: ModelsKey, path: readonly string[]): RetryPolicyFailure => ({ message: t(key), path }) - const number = (path: readonly string[]): number => { - const value = schema.getPath(policy, path) ?? defaults.value(path) - return typeof value === 'number' ? value : NaN - } - if (schema.getPath(policy, ['mode']) === 'normal') { - const retries = number(MAX_RETRIES) - if (!Number.isSafeInteger(retries) || retries < 0) return fail('retryMaxRetriesInvalid', MAX_RETRIES) - const codes = schema.getPath(policy, CODES) - if (Array.isArray(codes)) { - if (codes.length === 0 || codes.some(code => typeof code !== 'string' || code.length === 0)) { - return fail('retryCodesInvalid', CODES) + if (!isRecord(policy)) return whole() + const branch = defaults.branch(policy['mode']) + if (branch === undefined) return whole() + const read = (path: readonly string[]): unknown => schema.getPath(policy, path) ?? defaults.value(branch, path) + const check = (owner: SchemaNode, base: readonly string[]): RetryPolicyFailure | undefined => { + for (const [key, field] of fieldsOf(owner)) { + const path = [...base, key] + if (field.type === 'object') { + const nested = check(field, path) + if (nested !== undefined) return nested + } else if (field.type === 'number') { + const value = read(path) + // A field with neither a value nor a default is left to the schema check below. + const message = value === undefined ? undefined : numberFailure(field, value, t) + if (message !== undefined) return { message, path } + } else if (field.type === 'array') { + const stored = schema.getPath(policy, path) + if (stored === undefined) continue + if (schema.validate(field, stored) !== undefined) return { message: t('retryCodesInvalid'), path } + if (Array.isArray(stored) && new Set(stored).size !== stored.length) return { message: t('retryCodesDuplicate'), path } } - if (new Set(codes).size !== codes.length) return fail('retryCodesDuplicate', CODES) } + return undefined } - for (const path of [INITIAL_DELAY, MAX_DELAY]) { - const delay = number(path) - const max = defaults.max(path) ?? Infinity - if (!(delay > 0 && delay <= max)) return { message: t('retryDelayInvalid').replace('{max}', () => String(max)), path } + const failure = check(branch, []) + if (failure !== undefined) return failure + const [lowerPath, upperPath] = DELAY_ORDER + const lower = read(lowerPath) + const upper = read(upperPath) + if (typeof lower === 'number' && typeof upper === 'number' && lower > upper) { + return { message: t('retryDelayOrder'), path: lowerPath } } - if (number(INITIAL_DELAY) > number(MAX_DELAY)) return fail('retryDelayOrder', INITIAL_DELAY) - const jitter = number(JITTER) - if (!(jitter >= 0 && jitter <= 1)) return fail('retryJitterInvalid', JITTER) - const message = schema.validate(node, policy) - return message === undefined ? undefined : { message } + return whole() } /** Props of {@link RetryPolicyEditor}. */ export interface RetryPolicyEditorProps { /** Drafted user-layer policy; undefined inherits. */ policy: unknown - /** Inherited policy beneath the draft. */ + /** Inherited policy and schema branches beneath the draft. */ defaults: RetryPolicyDefaults /** Failure from {@link retryPolicyFailure}. */ failure: RetryPolicyFailure | undefined @@ -169,22 +221,16 @@ function textOf(value: unknown): string { return typeof value === 'number' || typeof value === 'string' ? String(value) : '' } -/** Set or remove one field of a plain policy object, dropping an emptied `backoff`. */ -function withField(policy: Record, path: FieldPath | typeof CODES, value: unknown): Record { - const assign = (target: Record, key: string, next: unknown): void => { - if (next === undefined) Reflect.deleteProperty(target, key) - else target[key] = next - } +/** Set or remove one nested field of a plain policy object, dropping emptied parents. */ +function withField(policy: Record, path: readonly string[], value: unknown): Record { + const [key, ...rest] = path + /* v8 ignore next -- paths come from schema fields and are never empty */ + if (key === undefined) return policy const result = { ...policy } - if (path.length === 1) { - assign(result, path[0], value) - return result - } - const [parent, key] = path - const prior = result[parent] - const child = typeof prior === 'object' && prior !== null ? { ...prior as Record } : {} - assign(child, key, value) - assign(result, parent, Object.keys(child).length === 0 ? undefined : child) + const prior = result[key] + const next = rest.length === 0 ? value : withField(isRecord(prior) ? prior : {}, rest, value) + if (next === undefined || (isRecord(next) && Object.keys(next).length === 0)) Reflect.deleteProperty(result, key) + else result[key] = next return result } @@ -195,64 +241,66 @@ function withField(policy: Record, path: FieldPath | typeof COD */ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode { const { t, disabled, defaults, failure } = props - const policy = typeof props.policy === 'object' && props.policy !== null - ? props.policy as Record - : undefined - const current = policy ?? {} - const mode = current['mode'] + const current = isRecord(props.policy) ? props.policy : {} + const branch = defaults.branch(current['mode']) + const mode = branch === undefined ? undefined : String(current['mode']) // Raw text keeps partial input such as `0.` visible while it is typed. const [texts, setTexts] = useState>({}) - // Backoff starts folded unless the draft already customizes it. - const [backoffOpen] = useState(() => current['backoff'] !== undefined) - const textAt = (path: FieldPath): string => texts[path.join('.')] ?? textOf(path.reduce( - (value, key) => typeof value === 'object' && value !== null ? (value as Record)[key] : undefined, current, - )) - const edit = (path: FieldPath, text: string): void => { - const trimmed = text.trim() - setTexts(previous => ({ ...previous, [path.join('.')]: trimmed.length === 0 ? '' : text })) - const value = DECIMAL.test(trimmed) ? Number(trimmed) : trimmed - props.onChange(withField(current, path, value === '' ? undefined : value)) + // A nested fold starts open only when the draft already customizes it. + const [openFolds] = useState(() => new Set(Object.keys(current).filter(key => isRecord(current[key])))) + const label = (key: string): string => { + const labelKey = LABELS.get(key) + return labelKey === undefined ? key : t(labelKey) } - const field = (path: FieldPath, label: ModelsKey, className = styles['modelField']): ReactNode => { - const invalid = failure?.path?.join('.') === path.join('.') + const invalidAt = (path: readonly string[]): boolean => failure?.path?.join('.') === path.join('.') + const numberField = (root: SchemaNode, path: readonly string[], node: SchemaNode, className: string | undefined): ReactNode => { + const id = path.join('.') + const invalid = invalidAt(path) + const storedValue = fieldAt(current, path) return ( -