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 ( -