refactor(ui-settings-models): build retry-policy fields from the provider schema

This commit is contained in:
Turtle committed 2026-11-02 18:03:17 +08:00
1 parent 84173f14e3
commit db9de7da60
12 files changed
+352 -193

No files matched your search

@@ -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 "恢复默认模型"
@@ -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 "恢复默认模型"
+4 -2
View File
@@ -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)
@@ -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
+2 -2
View File
@@ -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
@@ -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` 是一个按值整体替换的数组:编辑器先显示继承的有效行,直到第一次模型编辑把完整数组物化进用户层,重置则取消该覆盖。
### 并发与凭据
@@ -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 ?? '')
@@ -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<ReturnType<SettingsSchemaOperations['nodeAtPath']>>
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<string, ModelsKey> = 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<string, ModelsKey> = 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<string, ModelsKey> = new Map([
['EMPTY_RESPONSE', 'retryCodeEmptyResponse'],
@@ -50,42 +52,71 @@ const CODE_CHOICES: ReadonlyMap<string, ModelsKey> = 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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}
/** Read a nested field of a plain policy object. */
function fieldAt(value: Record<string, unknown>, path: readonly string[]): unknown {
return path.reduce<unknown>((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<string, unknown>, path: FieldPath | typeof CODES, value: unknown): Record<string, unknown> {
const assign = (target: Record<string, unknown>, 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<string, unknown>, path: readonly string[], value: unknown): Record<string, unknown> {
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<string, unknown> } : {}
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<string, unknown>, 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<string, unknown>
: 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<Record<string, string>>({})
// 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<unknown>(
(value, key) => typeof value === 'object' && value !== null ? (value as Record<string, unknown>)[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 (
<label key={path.join('.')} className={className}>
<span className={styles['modelFieldLabel']}>{t(label)}</span>
<label key={id} className={className}>
<span className={styles['modelFieldLabel']}>{label(path[path.length - 1] as string)}</span>
<input
className={styles['input']}
type="text"
inputMode={path === MAX_RETRIES ? 'numeric' : 'decimal'}
value={textAt(path)}
placeholder={textOf(defaults.value(path))}
inputMode={node.meta.step === 1 ? 'numeric' : 'decimal'}
value={texts[id] ?? textOf(storedValue)}
placeholder={textOf(defaults.value(root, path))}
aria-invalid={invalid}
aria-describedby={invalid ? props.failureId : undefined}
disabled={disabled}
onChange={(event) => { edit(path, event.target.value) }}
onChange={(event) => {
const trimmed = event.target.value.trim()
setTexts(previous => ({ ...previous, [id]: trimmed.length === 0 ? '' : event.target.value }))
const value = DECIMAL.test(trimmed) ? Number(trimmed) : trimmed
props.onChange(withField(current, path, value === '' ? undefined : value))
}}
/>
</label>
)
}
const codesField = (): ReactNode => {
const codesField = (root: SchemaNode, path: readonly string[]): ReactNode => {
const strings = (value: unknown): string[] => Array.isArray(value) ? value.filter(code => typeof code === 'string') : []
const inherited = strings(defaults.value(CODES))
const stored = current['retryableCodes']
const selected = Array.isArray(stored) ? strings(stored) : inherited
const inherited = strings(defaults.value(root, path))
const storedCodes = fieldAt(current, path)
const selected = Array.isArray(storedCodes) ? strings(storedCodes) : inherited
const choices = [...new Set([...CODE_CHOICES.keys(), ...inherited, ...selected])]
const invalid = failure?.path?.join('.') === CODES.join('.')
const invalid = invalidAt(path)
return (
<fieldset
key={path.join('.')}
className={styles['modelInputTypes']}
aria-invalid={invalid}
aria-describedby={invalid ? props.failureId : undefined}
>
<legend className={styles['modelFieldLabel']}>{t('retryCodes')}</legend>
<legend className={styles['modelFieldLabel']}>{label(path[path.length - 1] as string)}</legend>
<div className={styles['retryCodeChoices']}>
{choices.map((code) => {
const labelKey = CODE_CHOICES.get(code)
return (
<Checkbox
key={code}
// A stored code the list does not name shows verbatim.
// A code the list does not name shows verbatim.
label={labelKey === undefined ? code : t(labelKey)}
checked={selected.includes(code)}
disabled={disabled || (selected.length === 1 && selected.includes(code))}
@@ -260,7 +308,7 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
const next = choices.filter(value => value === code ? checked : selected.includes(value))
// Selecting exactly the inherited codes keeps the field inherited.
const inherits = next.length === inherited.length && next.every(value => inherited.includes(value))
props.onChange(withField(current, CODES, inherits ? undefined : next))
props.onChange(withField(current, path, inherits ? undefined : next))
}}
/>
)
@@ -269,53 +317,52 @@ export function RetryPolicyEditor(props: RetryPolicyEditorProps): ReactNode {
</fieldset>
)
}
// Top-level number fields share the width of one nested column so they line up with it.
const fields = (root: SchemaNode, owner: SchemaNode, base: readonly string[]): ReactNode[] => fieldsOf(owner).map(([key, node]) => {
const path = [...base, key]
if (node.type === 'number') {
return numberField(root, path, node, base.length === 0 ? `${styles['modelField']} ${styles['retryCount']}` : styles['modelField'])
}
if (node.type === 'array') return codesField(root, path)
if (node.type === 'object') {
return (
<details key={key} className={styles['retryFold']} open={openFolds.has(key)}>
<summary className={styles['customizedSummary']}>{label(key)}</summary>
<div className={styles['retryBackoff']}>{fields(root, node, path)}</div>
</details>
)
}
return null
})
const labelId = `${props.failureId}-label`
const modeLabel = (value: 'normal' | 'always'): string => t(value === 'normal' ? 'retryModeNormal' : 'retryModeAlways')
const hint = mode === undefined ? undefined : MODE_HINTS.get(mode)
return (
<div className={styles['field']} role="group" aria-labelledby={labelId}>
<span id={labelId} className={styles['fieldLabel']}>{t('retryPolicy')}</span>
<select
className={`${styles['input']} ${styles['selectInput']}`}
value={typeof mode === 'string' ? mode : ''}
value={mode ?? ''}
aria-labelledby={labelId}
aria-describedby={failure !== undefined && failure.path === undefined ? props.failureId : undefined}
disabled={disabled}
onChange={(event) => {
const next = event.target.value
const next = defaults.branch(event.target.value)
setTexts({})
if (next === '') {
if (next === undefined) {
props.onChange(undefined)
return
}
// Fields of the other mode stay, as the adapter accepts them; a hidden
// retry count that is not a number could no longer be corrected.
const kept = next === 'always' && typeof current['maxRetries'] !== 'number'
? withField(current, MAX_RETRIES, undefined)
: current
props.onChange({ ...kept, mode: next })
// A switched policy keeps only the fields its new mode declares.
const declared = new Set(fieldsOf(next).map(([key]) => key))
const kept = Object.fromEntries(Object.entries(current).filter(([key]) => declared.has(key)))
props.onChange({ ...kept, mode: event.target.value })
}}
>
<option value="">{t('retryModeDefault').replace('{mode}', () => modeLabel(defaults.mode))}</option>
<option value="normal">{modeLabel('normal')}</option>
<option value="always">{modeLabel('always')}</option>
<option value="">{t('retryModeDefault').replace('{mode}', () => label(defaults.mode))}</option>
{defaults.modes.map(value => <option key={value} value={value}>{label(value)}</option>)}
</select>
{mode === 'always' ? <span className={styles['advancedHint']}>{t('retryAlwaysHint')}</span> : null}
{mode === 'normal' || mode === 'always'
? (
<div className={styles['retryBody']}>
{mode === 'normal'
? <>
{field(MAX_RETRIES, 'retryMaxRetries', `${styles['modelField']} ${styles['retryCount']}`)}
{codesField()}
</>
: null}
<details className={styles['retryFold']} open={backoffOpen}>
<summary className={styles['customizedSummary']}>{t('retryBackoff')}</summary>
<div className={styles['retryBackoff']}>{BACKOFF_FIELDS.map(([path, label]) => field(path, label))}</div>
</details>
</div>
)
: null}
{hint === undefined ? null : <span className={styles['advancedHint']}>{t(hint)}</span>}
{branch === undefined ? null : <div className={styles['retryBody']}>{fields(branch, branch, [])}</div>}
{failure === undefined || !props.showFailure
? null
: <p id={props.failureId} className={styles['error']}>{failure.message}</p>}
@@ -74,12 +74,14 @@ export const en = {
retryMaxDelay: 'Max delay (ms)',
retryJitter: 'Jitter ratio',
retryBackoff: 'Backoff',
retryMaxRetriesInvalid: 'Max retries must be a whole number, 0 or more.',
retryValueNumber: 'Enter a number.',
retryValueInteger: 'Enter a whole number.',
retryValuePositive: 'Must be greater than 0.',
retryValueMin: 'Must be at least {min}.',
retryValueMax: 'Must be at most {max}.',
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.',
retryJitterInvalid: 'Jitter ratio must be between 0 and 1.',
models: 'Models',
modelsInherited: 'Using the adapter defaults',
modelsCustomized: 'Customized model catalog',
@@ -242,12 +244,14 @@ export const zh: { [Key in keyof typeof en]: string } = {
retryMaxDelay: '最大延迟 (ms)',
retryJitter: '抖动比例',
retryBackoff: '退避',
retryMaxRetriesInvalid: '最大重试次数必须是不小于 0 的整数。',
retryValueNumber: '请输入数字。',
retryValueInteger: '请输入整数。',
retryValuePositive: '必须大于 0。',
retryValueMin: '不能小于 {min}。',
retryValueMax: '不能大于 {max}。',
retryCodesInvalid: '错误码必须是非空文本。',
retryCodesDuplicate: '每个错误码只能出现一次。',
retryDelayInvalid: '延迟必须是正数,且不超过 {max} ms。',
retryDelayOrder: '初始延迟不能大于最大延迟。',
retryJitterInvalid: '抖动比例必须在 0 到 1 之间。',
models: '模型目录',
modelsInherited: '正在使用适配器默认模型',
modelsCustomized: '已自定义模型目录',
@@ -407,12 +411,14 @@ export const ja = {
'retryMaxDelay': '最大遅延 (ms)',
'retryJitter': 'ジッター比率',
'retryBackoff': 'バックオフ',
'retryMaxRetriesInvalid': '最大再試行回数は0以上の整数で指定してください。',
'retryValueNumber': '数値を入力してください。',
'retryValueInteger': '整数を入力してください。',
'retryValuePositive': '0 より大きい値にしてください。',
'retryValueMin': '{min} 以上にしてください。',
'retryValueMax': '{max} 以下にしてください。',
'retryCodesInvalid': 'エラーコードは空でないテキストにしてください。',
'retryCodesDuplicate': '各エラーコードは1回のみ指定できます。',
'retryDelayInvalid': '遅延は {max} ms 以下の正の値で指定してください。',
'retryDelayOrder': '初期遅延は最大遅延以下にしてください。',
'retryJitterInvalid': 'ジッター比率は0から1の範囲で指定してください。',
'models': 'モデル',
'modelsInherited': 'アダプターのデフォルトを使用',
'modelsCustomized': 'カスタマイズされたモデルカタログ',
@@ -572,12 +578,14 @@ export const es = {
retryMaxDelay: 'Retraso máximo (ms)',
retryJitter: 'Proporción de jitter',
retryBackoff: 'Retroceso',
retryMaxRetriesInvalid: 'Los reintentos máximos deben ser un número entero igual o mayor que 0.',
retryValueNumber: 'Ingrese un número.',
retryValueInteger: 'Ingrese un número entero.',
retryValuePositive: 'Debe ser mayor que 0.',
retryValueMin: 'Debe ser al menos {min}.',
retryValueMax: 'Debe ser como máximo {max}.',
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.',
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',
@@ -737,12 +745,14 @@ export const fr = {
retryMaxDelay: 'Délai maximum (ms)',
retryJitter: 'Ratio de gigue',
retryBackoff: 'Temporisation',
retryMaxRetriesInvalid: 'Le nombre maximum de tentatives doit être un entier supérieur ou égal à 0.',
retryValueNumber: 'Saisissez un nombre.',
retryValueInteger: 'Saisissez un nombre entier.',
retryValuePositive: 'Doit être supérieur à 0.',
retryValueMin: 'Doit être supérieur ou égal à {min}.',
retryValueMax: 'Doit être inférieur ou égal à {max}.',
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.',
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',
@@ -902,12 +912,14 @@ export const de = {
retryMaxDelay: 'Max. Verzögerung (ms)',
retryJitter: 'Jitter-Anteil',
retryBackoff: 'Backoff',
retryMaxRetriesInvalid: 'Die maximale Anzahl an Wiederholungen muss eine ganze Zahl ab 0 sein.',
retryValueNumber: 'Geben Sie eine Zahl ein.',
retryValueInteger: 'Geben Sie eine ganze Zahl ein.',
retryValuePositive: 'Muss größer als 0 sein.',
retryValueMin: 'Muss mindestens {min} sein.',
retryValueMax: 'Darf höchstens {max} 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.',
retryJitterInvalid: 'Der Jitter-Anteil muss zwischen 0 und 1 liegen.',
models: 'Modelle',
modelsInherited: 'Verwendung der Adapterstandards',
modelsCustomized: 'Maßgeschneiderter Modellkatalog',
@@ -1067,12 +1079,14 @@ export const ru = {
retryMaxDelay: 'Макс. задержка (ms)',
retryJitter: 'Коэффициент джиттера',
retryBackoff: 'Отсрочка',
retryMaxRetriesInvalid: 'Максимальное количество повторных попыток должно быть целым числом не меньше 0.',
retryValueNumber: 'Введите число.',
retryValueInteger: 'Введите целое число.',
retryValuePositive: 'Должно быть больше 0.',
retryValueMin: 'Должно быть не меньше {min}.',
retryValueMax: 'Должно быть не больше {max}.',
retryCodesInvalid: 'Коды ошибок должны быть непустым текстом.',
retryCodesDuplicate: 'Каждый код ошибки может встречаться только один раз.',
retryDelayInvalid: 'Задержки должны быть положительными и не превышать {max} ms.',
retryDelayOrder: 'Начальная задержка не может превышать максимальную.',
retryJitterInvalid: 'Коэффициент джиттера должен быть от 0 до 1.',
models: 'Модели',
modelsInherited: 'Использование настроек адаптера по умолчанию',
modelsCustomized: 'Индивидуальный каталог моделей',
@@ -1232,12 +1246,14 @@ export const pt = {
retryMaxDelay: 'Atraso máximo (ms)',
retryJitter: 'Proporção de jitter',
retryBackoff: 'Backoff',
retryMaxRetriesInvalid: 'O máximo de novas tentativas deve ser um número inteiro maior ou igual a 0.',
retryValueNumber: 'Insira um número.',
retryValueInteger: 'Insira um número inteiro.',
retryValuePositive: 'Deve ser maior que 0.',
retryValueMin: 'Deve ser no mínimo {min}.',
retryValueMax: 'Deve ser no máximo {max}.',
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.',
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',
@@ -14,15 +14,15 @@ 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),
initialDelayMs: Schema.number().min(Number.MIN_VALUE).max(2_147_483_647).default(500),
maxDelayMs: Schema.number().min(Number.MIN_VALUE).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']),
retryableCodes: Schema.array(Schema.string().min(1)).min(1).default(['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT']),
backoff,
}),
Schema.object({ mode: Schema.const('always').required(), backoff }),
@@ -58,6 +58,7 @@ function piAiNamespace(): SettingsNamespaceView {
function setup(namespace: SettingsNamespaceView, settingsPath: string[] = []) {
const writeSettings = vi.fn<ModelsOperations['writeSettings']>(() => Promise.resolve({ kind: 'written', view: namespace }))
const operations: ModelsOperations = {
readCredential: vi.fn<ModelsOperations['readCredential']>(() => Promise.resolve({ kind: 'refused', message: '' })),
storeCredential: vi.fn(() => Promise.resolve(undefined)),
removeCredential: vi.fn(() => Promise.resolve(undefined)),
writeSettings,
@@ -113,16 +114,15 @@ describe('retry policy fields', () => {
], 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.retryInitialDelay, '0', delayInvalid],
[en.retryInitialDelay, '0x10', delayInvalid],
[en.retryMaxDelay, '3000000000', delayInvalid],
[en.retryMaxRetries, '-1', en.retryValueMin.replace('{min}', '0')],
[en.retryMaxRetries, '1.5', en.retryValueInteger],
[en.retryMaxRetries, 'many', en.retryValueNumber],
[en.retryInitialDelay, '0', en.retryValuePositive],
[en.retryInitialDelay, '0x10', en.retryValueNumber],
[en.retryMaxDelay, '3000000000', en.retryValueMax.replace('{max}', '2147483647')],
[en.retryInitialDelay, '20000', en.retryDelayOrder],
[en.retryJitter, '2', en.retryJitterInvalid],
[en.retryJitter, '2', en.retryValueMax.replace('{max}', '1')],
])('refuses %s = %s', (label, value, message) => {
setup(deepSeekNamespace())
fireEvent.change(mode(), { target: { value: 'normal' } })
@@ -280,16 +280,76 @@ describe('retry policy fields', () => {
])
})
it('leaves placeholders empty when the schema declares no normal mode', () => {
it('builds modes and fields from the schema the namespace publishes', async () => {
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' } })
const policy = Schema.union([
Schema.object({
mode: Schema.const('patient').required(),
attempts: Schema.number().step(1).min(1),
codes: Schema.array(Schema.string()),
note: Schema.string(),
backoff: Schema.object({ maxDelayMs: Schema.number().max(100) }),
}),
Schema.object({ mode: Schema.const('always').required() }),
])
const { writeSettings } = setup({ ...namespace, schema: json(Schema.object({ retryPolicy: policy })) })
expect(mode().selectedOptions[0]?.textContent).toBe('Default (patient)')
expect(screen.getByRole('option', { name: en.retryModeAlways })).toBeTruthy()
fireEvent.change(mode(), { target: { value: 'patient' } })
expect(input('attempts').inputMode).toBe('numeric')
expect(input(en.retryMaxDelay).inputMode).toBe('decimal')
expect(input(en.retryMaxDelay).placeholder).toBe('')
expect(code_('SERVER').checked).toBe(false)
expect(screen.queryByText('note')).toBeNull()
expect(screen.queryByLabelText(en.retryInitialDelay)).toBeNull()
fireEvent.change(input('attempts'), { target: { value: '0' } })
expect(screen.getByText(en.retryValueMin.replace('{min}', '1'))).toBeTruthy()
fireEvent.change(input('attempts'), { target: { value: '2' } })
expect(document.getElementById('route-retry-failure')).toBeNull()
fireEvent.click(apply())
await waitFor(() => { expect(writeSettings).toHaveBeenCalled() })
expect(writeSettings.mock.calls[0]?.[1]).toEqual([
{ op: 'set', path: ['retryPolicy', 'mode'], value: 'patient' },
{ op: 'set', path: ['retryPolicy', 'attempts'], value: 2 },
])
})
it('offers only Default when the retry schema declares no mode branches', () => {
const namespace = deepSeekNamespace()
const policy = Schema.union([Schema.object({ backoff: Schema.object({}) }), Schema.const(false)])
setup({ ...namespace, schema: json(Schema.object({ retryPolicy: policy })) })
expect(mode().options).toHaveLength(1)
cleanup()
setup({ ...namespace, schema: json(Schema.object({ retryPolicy: Schema.object({ mode: Schema.string() }) })) })
expect(mode().options).toHaveLength(1)
})
it('lists no inherited codes when the inherited value is not a list', () => {
setup(deepSeekNamespace({ base: { retryPolicy: { mode: 'normal', retryableCodes: 'SERVER' } } }))
fireEvent.change(mode(), { target: { value: 'normal' } })
expect(code_('SERVER').checked).toBe(false)
})
it('refuses a stored policy whose mode is not text', () => {
setup(deepSeekNamespace({ user: { retryPolicy: { mode: 5 } } }))
expect(mode().value).toBe('')
expect(apply().disabled).toBe(true)
})
it('keeps only the fields the new mode declares when switching modes', async () => {
const { writeSettings } = setup(deepSeekNamespace({
user: { retryPolicy: { mode: 'normal', maxRetries: 2, backoff: { maxDelayMs: 2_000 } } },
}))
expect(input(en.retryMaxDelay).value).toBe('2000')
fireEvent.change(mode(), { target: { value: 'always' } })
fireEvent.click(apply())
await waitFor(() => { expect(writeSettings).toHaveBeenCalled() })
expect(writeSettings.mock.calls[0]?.[1]).toEqual([
{ op: 'set', path: ['retryPolicy', 'mode'], value: 'always' },
{ op: 'unset', path: ['retryPolicy', 'maxRetries'] },
])
})
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() })) })
+5 -3
View File
@@ -78,16 +78,18 @@ export interface ResolvedAlwaysRetryPolicy extends ResolvedRetryBackoff {
/** Immutable provider policy captured when its adapter route is registered. */
export type ResolvedRetryPolicy = ResolvedNormalRetryPolicy | ResolvedAlwaysRetryPolicy
// Field bounds live in the schema so settings forms can read them; resolveRetryPolicy
// additionally enforces the cross-field rules no field bound expresses.
const backoffSchema: z<BackoffConfig> = z.object({
initialDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
maxDelayMs: z.number().max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
initialDelayMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_INITIAL_DELAY_MS),
maxDelayMs: z.number().min(Number.MIN_VALUE).max(MAX_TIMER_DELAY_MS).default(DEFAULT_MAX_DELAY_MS),
jitterRatio: z.number().min(0).max(1).default(DEFAULT_JITTER_RATIO),
})
const normalPolicySchema: z<NormalRetryPolicyConfig> = z.object({
mode: z.const('normal').required(),
maxRetries: z.number().step(1).min(0).max(Number.MAX_SAFE_INTEGER).default(DEFAULT_MAX_RETRIES),
retryableCodes: z.array(z.string()).default([...DEFAULT_RETRYABLE_CODES]),
retryableCodes: z.array(z.string().min(1)).min(1).default([...DEFAULT_RETRYABLE_CODES]),
backoff: backoffSchema,
})
@@ -96,4 +96,20 @@ describe('provider retry policy', () => {
resolveRetryPolicy(config as unknown as RetryPolicyConfig, 'provider.retryPolicy')
}).toThrow(message)
})
it.each<[string, RetryPolicyConfig]>([
['a zero delay', { mode: 'always', backoff: { initialDelayMs: 0 } }],
['an empty code list', { mode: 'normal', retryableCodes: [] }],
['a blank code', { mode: 'normal', retryableCodes: [''] }],
])('declares the resolver bound on %s in the schema', (_name, config) => {
expect(() => RetryPolicySchema(config)).toThrow()
})
it('accepts the smallest values inside the schema bounds and still defaults the codes', () => {
expect(RetryPolicySchema({ mode: 'normal', retryableCodes: ['X'], backoff: { initialDelayMs: Number.MIN_VALUE } }))
.toMatchObject({ retryableCodes: ['X'], backoff: { initialDelayMs: Number.MIN_VALUE } })
expect(RetryPolicySchema({ mode: 'normal' })).toMatchObject({
retryableCodes: ['EMPTY_RESPONSE', 'RATE_LIMIT', 'SERVER', 'TIMEOUT', 'TRANSPORT'],
})
})
})