From 147a5e26ef7f7b65f606117476df558cebfea759 Mon Sep 17 00:00:00 2001 From: kimrinking-cell <248360754+kimrinking-cell@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:28:02 +0900 Subject: [PATCH 01/13] feat: add provider request pacing controls Add provider-wide and exact-model request-start pacing with dashboard controls, live queue status, management validation, transport coverage, tests, and localized documentation. --- assets/request-pacing-dashboard.jpg | Bin 0 -> 49672 bytes .../ja/reference/configuration/providers.md | 1 + .../ko/reference/configuration/providers.md | 1 + .../docs/reference/configuration/providers.md | 1 + .../ru/reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + .../reference/configuration/providers.md | 1 + .../provider-workspace/ProviderSettings.tsx | 105 +++++++++- .../components/provider-workspace/types.ts | 1 + gui/src/i18n/de.ts | 17 ++ gui/src/i18n/en.ts | 17 ++ gui/src/i18n/ja.ts | 17 ++ gui/src/i18n/ko.ts | 17 ++ gui/src/i18n/ru.ts | 17 ++ gui/src/i18n/tr.ts | 17 ++ gui/src/i18n/zh-TW.ts | 17 ++ gui/src/i18n/zh.ts | 17 ++ gui/src/provider-workspace/catalog.ts | 6 + .../styles/provider-workspace-settings.css | 27 +++ ...r-settings-live-models-provenance.test.tsx | 1 + .../provider-settings-request-pacing.test.tsx | 78 +++++++ src/config.ts | 28 +++ src/providers/request-pacing.ts | 190 ++++++++++++++++++ src/server/auth-cors.ts | 8 + src/server/management/provider-routes.ts | 46 ++++- src/server/responses/compact.ts | 2 +- src/server/responses/core.ts | 27 ++- src/server/responses/fetch-helpers.ts | 27 ++- src/types.ts | 16 ++ structure/04_transports-and-sidecars.md | 9 + tests/management-provider-validation.test.ts | 63 ++++++ tests/request-pacing.test.ts | 144 +++++++++++++ 32 files changed, 897 insertions(+), 23 deletions(-) create mode 100644 assets/request-pacing-dashboard.jpg create mode 100644 gui/tests/provider-settings-request-pacing.test.tsx create mode 100644 src/providers/request-pacing.ts create mode 100644 tests/request-pacing.test.ts diff --git a/assets/request-pacing-dashboard.jpg b/assets/request-pacing-dashboard.jpg new file mode 100644 index 0000000000000000000000000000000000000000..94986cb74ee24083c21404a4ad767b6bc036c391 GIT binary patch literal 49672 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 literal 0 HcmV?d00001 diff --git a/docs-site/src/content/docs/ja/reference/configuration/providers.md b/docs-site/src/content/docs/ja/reference/configuration/providers.md index a990b52263..c680faf8ed 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -56,6 +56,7 @@ account を削除しても mapping は保持され、同じ id を再追加す | --- | --- | --- | | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai` (または別名 `azure`) のいずれか。 | | `baseUrl` | `string` |アップストリーム API のベース URL。ほとんどの組み込み固定エンドポイントは不一致を無視します。衝突安全キー プリセットは、古い同じ名前のカスタム宛先を保持します。 | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | オプションの送信開始間隔調整。プロバイダー制限は全モデルに適用され、モデル別設定は遅延を増やす場合のみ有効です。キュー待機は応答ヘッダーのタイムアウトを消費しません。 | | `responsesPath?` | `string` |キー認証 `openai-responses` リクエストの相対リソース パス。 `/` で始まり、スキーム、クエリ、またはフラグメントが含まれていない必要があります。 | | `supportsServiceTier?` | `boolean` | `service_tier` ケイパビリティの 3 状態です。`true`: fast モードが注入でき、呼び出し元の値も保持されます。`false`: フィールドは削除され、注入もされません (非対応と文書化されたアップストリームには送りません)。未設定: 未分類 — 呼び出し元の値はそのまま保持され、fast モードは注入しません。レジストリは正規 OpenAI (`true`)、DeepSeek、Volcengine Ark (`false`) を分類します。実際にティアをサポートするカスタム ゲートウェイにのみ明示的に設定してください。 | | `preserveResponsesReasoningContent?` | `boolean` | リプレイされる Responses reasoning アイテムの平文 reasoning コンテンツを消去せずに保持します (消去は ChatGPT バックエンドのルールです)。DeepSeek のように reasoning リプレイを受け入れるアップストリームで有効にしてください。プロキシ生成の `ocxr1` エンベロープは常に削除されます。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/providers.md b/docs-site/src/content/docs/ko/reference/configuration/providers.md index 7aed7c7771..eaea77f373 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -56,6 +56,7 @@ managed map을 활성화하면 privacy-safe selector를 만들고, 이후 계정 | --- | --- | --- | | `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` 중 하나이며, `azure`는 별칭입니다. | | `baseUrl` | `string` | 상위 API 기본 URL입니다. 대부분의 내장 고정 엔드포인트는 불일치를 무시합니다. 충돌 안전 키 프리셋은 같은 이름의 이전 사용자 지정 목적지를 보존합니다. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 선택적 아웃바운드 요청 시작 속도 조절입니다. Provider 제한은 모든 모델에 적용되고 정확한 모델 override는 지연을 더 늘릴 때만 적용됩니다. 큐 대기는 응답 헤더 타임아웃을 소모하지 않습니다. | | `responsesPath?` | `string` | 키 인증 `openai-responses` 요청의 상대 리소스 경로입니다. 반드시 `/`로 시작해야 하며 스킴, query, fragment를 포함하면 안 됩니다. | | `supportsServiceTier?` | `boolean` | `service_tier` 케이퍼빌리티 3상태입니다. `true`: fast 모드가 주입할 수 있고 호출자 값도 보존합니다. `false`: 필드를 제거하고 절대 주입하지 않습니다(미지원으로 문서화된 업스트림에는 볼 수 없습니다). 미설정: 미분류 — 호출자가 준 값은 그대로 보존하고 fast 모드는 주입하지 않습니다. 레지스트리는 정식 OpenAI(`true`), DeepSeek, Volcengine Ark(`false`)를 분류하며, 실제로 티어를 지원하는 커스텀 게이트웨이에만 명시적으로 설정하세요. | | `preserveResponsesReasoningContent?` | `boolean` | 리플레이되는 Responses reasoning 항목의 평문 reasoning 내용을 지우지 않고 유지합니다(지우는 것은 ChatGPT 백엔드 규칙입니다). DeepSeek처럼 reasoning 리플레이를 허용하는 업스트림에 켜세요. 프록시가 만든 `ocxr1` 봉투는 항상 제거됩니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 62203e285d..05f288d12e 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -66,6 +66,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | --- | --- | --- | | `adapter` | `string` | One of `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (or alias `azure`). | | `baseUrl` | `string` | Upstream API base URL. Most built-in fixed endpoints ignore a mismatch; collision-safe key presets preserve an older same-named custom destination. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional outbound request-start pacing. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while exact model overrides can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and adapter `fetchResponse` transports are covered; custom `runTurn` transports are not. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | | `supportsServiceTier?` | `boolean` | Tri-state `service_tier` capability. `true`: fast mode may inject and caller values are preserved. `false`: the field is stripped and never injected (the upstream documented as not supporting it must not receive it). Absent: the provider is unclassified — caller-supplied values are preserved untouched and fast mode never injects. The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. | | `preserveResponsesReasoningContent?` | `boolean` | Keep plaintext reasoning content on replayed Responses reasoning items instead of blanking it (blanking is the ChatGPT backend's rule). Enable for upstreams whose contract accepts reasoning replay, such as DeepSeek. Proxy-minted `ocxr1` envelopes are always stripped. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/providers.md b/docs-site/src/content/docs/ru/reference/configuration/providers.md index b1bb26c78c..f0de52a3a9 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -69,6 +69,7 @@ cross-route credential fallback не существует. Строки API GPT- | --- | --- | --- | | `adapter` | `string` | Один из `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` (или alias `azure`). | | `baseUrl` | `string` | Базовый URL API upstream'а. Большинство built-in fixed-endpoint'ов игнорируют несовпадение; collision-safe key-preset'ы сохраняют старый custom destination с тем же именем. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Опциональная равномерная задержка начала исходящих запросов. Лимит провайдера действует на все модели, а правила моделей могут только увеличить задержку. Ожидание очереди не расходует таймаут заголовков ответа. | | `responsesPath?` | `string` | Relative resource path для key-auth запросов `openai-responses`. Должен начинаться с `/` и не может содержать scheme, query или fragment. | | `supportsServiceTier?` | `boolean` | Три состояния поддержки `service_tier`. `true`: fast mode может подставлять поле, значения вызывающего сохраняются. `false`: поле удаляется и никогда не подставляется (апстрим, для которого задокументировано отсутствие поддержки, не должен его получать). Не задано: провайдер не классифицирован — значения вызывающего сохраняются без изменений, fast mode не подставляет. Registry классифицирует canonical OpenAI (`true`), DeepSeek и Volcengine Ark (`false`); задавайте явно только для custom gateway'ев, реально поддерживающих tier'ы. | | `preserveResponsesReasoningContent?` | `boolean` | Сохранять plaintext reasoning content в replay'нутых Responses reasoning item'ах вместо очистки (очистка — правило ChatGPT backend'а). Включайте для upstream'ов, чей контракт принимает reasoning replay, например DeepSeek. Proxy-minted `ocxr1` envelope'ы удаляются всегда. | diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md index 1d2a8c10c9..2ac6f65e93 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/providers.md @@ -56,6 +56,7 @@ selector,而不是分配一个新名称。 | --- | --- | --- | | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或别名 `azure`)之一。 | | `baseUrl` | `string` | 上游 API 基础 URL。大多数内置固定端点会忽略不匹配的值;具备冲突安全键的预设会保留一个更早、同名的自定义目标。 | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 可选的出站请求启动节流。提供商限制适用于所有模型,模型规则只能增加延迟。排队等待不计入响应头超时。 | | `responsesPath?` | `string` | 用于 key-auth `openai-responses` 请求的相对资源路径。必须以 `/` 开头,且不能包含 scheme、query 或 fragment。 | | `supportsServiceTier?` | `boolean` | `service_tier` 能力的三态。`true`:fast 模式可以注入,调用方提供的值也会被保留。`false`:剥离该字段且绝不注入(已明确不支持的上游不会收到它)。未设置:未分类——调用方提供的值原样保留,fast 模式绝不注入。注册表已对官方 OpenAI(`true`)、DeepSeek 和 Volcengine Ark(`false`)分类;仅对真正支持分层的自定义网关显式设置。 | | `preserveResponsesReasoningContent?` | `boolean` | 在重放的 Responses reasoning 项中保留明文 reasoning 内容,而不是清空(清空是 ChatGPT 后端的规则)。对接受 reasoning 重放的上游(如 DeepSeek)启用。代理生成的 `ocxr1` 信封始终会被剥离。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md index 2f729dc355..c4f5419a0f 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/providers.md @@ -40,6 +40,7 @@ description: 供應商項目、認證、端點、模型目錄、配額、context | --- | --- | --- | | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或別名 `azure`)之一。 | | `baseUrl` | `string` | 上游 API base URL。多數內建固定端點忽略不符;碰撞安全的金鑰預設保留較舊的同名自訂目的地。 | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 選用的出站請求啟動節流。供應商限制適用於所有模型,模型規則只能增加延遲。排隊等待不計入回應標頭逾時。 | | `responsesPath?` | `string` | Key-auth `openai-responses` 請求的相對資源路徑。必須以 `/` 開頭且不含 scheme、query 或 fragment。 | | `disabled?` | `boolean` | 將供應商保留在磁碟上但排除於路由與模型/目錄清單。 | | `apiKey?` | `string` | API 金鑰,或在請求時解析的 `${ENV_VAR}` / `$ENV_VAR` 參考。 | diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 26ca85fa35..44c7a83241 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -22,6 +22,31 @@ const ADAPTERS = ["openai-responses", "openai-chat", "anthropic", "google", "azu const EMPTY_MODELS: string[] = []; type ChoicesStatus = "idle" | "loading" | "ready" | "error"; +type PacingRule = { requestsPerMinute?: number; minIntervalMs?: number }; +type PacingStatus = { enabled: boolean; queued: number; nextSlotInMs: number; lastStartedAt?: number; lastModelId?: string }; + +function numberDraft(value: number | undefined): string { return value === undefined ? "" : String(value); } +function positiveRpm(value: string): number | undefined { + if (!value.trim()) return undefined; + const parsed = Number(value); + return Number.isFinite(parsed) && parsed >= 1 / 60 ? parsed : undefined; +} +function positiveInteger(value: string): number | undefined { + if (!value.trim()) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) && parsed > 0 ? parsed : undefined; +} +function pacingSignature(value: WorkspaceItem["requestPacing"] | undefined): string { + const models = Object.entries(value?.models ?? {}) + .sort(([left], [right]) => left.localeCompare(right)) + .map(([model, rule]) => [model, rule.requestsPerMinute ?? null, rule.minIntervalMs ?? null]); + return JSON.stringify([ + value?.enabled === true, + value?.requestsPerMinute ?? null, + value?.minIntervalMs ?? null, + models, + ]); +} export default function ProviderSettings({ item, availableModels = EMPTY_MODELS, apiBase, onUpdateProvider, onDirtyChange, onRegisterSave, @@ -53,6 +78,14 @@ export default function ProviderSettings({ const [baseUrlChoices, setBaseUrlChoices] = useState(); const [choicesStatus, setChoicesStatus] = useState(apiBase ? "loading" : "idle"); const [endpointChoice, setEndpointChoice] = useState(() => "custom"); + const [pacingEnabled, setPacingEnabled] = useState(item.requestPacing?.enabled === true); + const [pacingRpm, setPacingRpm] = useState(() => numberDraft(item.requestPacing?.requestsPerMinute)); + const [pacingDelay, setPacingDelay] = useState(() => numberDraft(item.requestPacing?.minIntervalMs)); + const [pacingModels, setPacingModels] = useState>(() => ({ ...(item.requestPacing?.models ?? {}) })); + const [pacingModelId, setPacingModelId] = useState(""); + const [pacingModelRpm, setPacingModelRpm] = useState(""); + const [pacingModelDelay, setPacingModelDelay] = useState(""); + const [pacingStatus, setPacingStatus] = useState(null); /* eslint-disable react-hooks/set-state-in-effect -- intentional form reset when saved provider fields change */ useEffect(() => { @@ -64,10 +97,14 @@ export default function ProviderSettings({ setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); + setPacingEnabled(item.requestPacing?.enabled === true); + setPacingRpm(numberDraft(item.requestPacing?.requestsPerMinute)); + setPacingDelay(numberDraft(item.requestPacing?.minIntervalMs)); + setPacingModels({ ...(item.requestPacing?.models ?? {}) }); setMsg(null); setModeMsg(null); queueMicrotask(() => setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl))); - }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, baseUrlChoices]); + }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, item.liveModels, item.requestPacing, baseUrlChoices]); /* eslint-enable react-hooks/set-state-in-effect */ // Account mode syncs on its own: a mode PATCH refresh must not reset an in-progress @@ -109,6 +146,27 @@ export default function ProviderSettings({ // eslint-disable-next-line react-hooks/exhaustive-deps -- item.baseUrl sync is handled by the form-reset effect }, [apiBase, item.name]); + useEffect(() => { + if (!apiBase) return; + let active = true; + const load = () => { + fetch(`${apiBase}/api/provider-request-pacing?name=${encodeURIComponent(item.name)}`) + .then(r => readJsonIfOk(r)) + .then(status => { if (active && status) setPacingStatus(status); }) + .catch(() => undefined); + }; + load(); + const timer = window.setInterval(load, 2_000); + return () => { active = false; window.clearInterval(timer); }; + }, [apiBase, item.name]); + + const pacingDraft = useMemo(() => ({ + enabled: pacingEnabled, + ...(positiveRpm(pacingRpm) !== undefined ? { requestsPerMinute: positiveRpm(pacingRpm) } : {}), + ...(positiveInteger(pacingDelay) !== undefined ? { minIntervalMs: positiveInteger(pacingDelay) } : {}), + ...(Object.keys(pacingModels).length > 0 ? { models: pacingModels } : {}), + }), [pacingDelay, pacingEnabled, pacingModels, pacingRpm]); + const dirty = adapter.trim() !== item.adapter || baseUrl.trim() !== item.baseUrl || defaultModel.trim() !== (item.defaultModel ?? "") @@ -117,8 +175,10 @@ export default function ProviderSettings({ || note.trim() !== (item.note ?? "") || allowPrivateNetwork !== (item.allowPrivateNetwork ?? false) || liveModels !== (item.liveModels !== false); + const pacingDirty = pacingSignature(pacingDraft) !== pacingSignature(item.requestPacing); + const formDirty = dirty || pacingDirty; - useEffect(() => { onDirtyChange?.(dirty); return () => onDirtyChange?.(false); }, [dirty, onDirtyChange]); + useEffect(() => { onDirtyChange?.(formDirty); return () => onDirtyChange?.(false); }, [formDirty, onDirtyChange]); const modelOptions = useMemo(() => { const set = new Set(availableModels); @@ -152,7 +212,11 @@ export default function ProviderSettings({ setSaving(true); setMsg(null); try { + if (pacingEnabled && !pacingDraft.requestsPerMinute && !pacingDraft.minIntervalMs && !pacingDraft.models) { + setMsg({ ok: false, text: t("pws.pacingRuleRequired") }); return false; + } const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork }; + if (pacingDirty) patch.requestPacing = pacingDraft; // Keep omitted legacy values omitted unless the user actually changes this toggle. // Otherwise an unrelated settings save manufactures `liveModels: true` provenance. if (liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels; @@ -201,6 +265,8 @@ export default function ProviderSettings({ setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth); setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(item.liveModels !== false); setMsg(null); + setPacingEnabled(item.requestPacing?.enabled === true); setPacingRpm(numberDraft(item.requestPacing?.requestsPerMinute)); + setPacingDelay(numberDraft(item.requestPacing?.minIntervalMs)); setPacingModels({ ...(item.requestPacing?.models ?? {}) }); setEndpointChoice(matchChoiceId(baseUrlChoices, item.baseUrl)); }; @@ -213,6 +279,15 @@ export default function ProviderSettings({ } }; + const addPacingModel = () => { + const modelId = pacingModelId.trim(); + const rpm = positiveRpm(pacingModelRpm); + const delay = positiveInteger(pacingModelDelay); + if (!modelId || (rpm === undefined && delay === undefined)) return; + setPacingModels(current => ({ ...current, [modelId]: { ...(rpm !== undefined ? { requestsPerMinute: rpm } : {}), ...(delay !== undefined ? { minIntervalMs: delay } : {}) } })); + setPacingModelId(""); setPacingModelRpm(""); setPacingModelDelay(""); + }; + return (
- {dirty && ( +
+
+

{t("pws.pacingTitle")}

{t("pws.pacingDesc")}

+ +
+
+ + +
+

{t("pws.pacingSlowerWins")}

+
+ {pacingStatus?.queued ?? 0} {t("pws.pacingQueued")} + {pacingStatus?.nextSlotInMs ?? 0} ms {t("pws.pacingNextSlot")} + {pacingStatus?.lastModelId ?? t("pws.pacingNone")} {t("pws.pacingLastModel")} +
+

{t("pws.pacingModelOverrides")}

+
+ + + + +
+ {Object.entries(pacingModels).length > 0 &&
{Object.entries(pacingModels).map(([model, rule]) =>
{model}{rule.requestsPerMinute !== undefined ? `${rule.requestsPerMinute} ${t("pws.pacingRpmUnit")}` : ""}{rule.requestsPerMinute !== undefined && rule.minIntervalMs !== undefined ? " · " : ""}{rule.minIntervalMs !== undefined ? `${rule.minIntervalMs} ms` : ""}
)}
} +
+ {formDirty && (
{t("pws.settingsUnsavedBar")}
diff --git a/gui/src/components/provider-workspace/types.ts b/gui/src/components/provider-workspace/types.ts index 97e6f1969d..b5757d06eb 100644 --- a/gui/src/components/provider-workspace/types.ts +++ b/gui/src/components/provider-workspace/types.ts @@ -98,6 +98,7 @@ export type ProviderUpdatePatch = { disabled?: boolean; allowPrivateNetwork?: boolean; liveModels?: boolean; + requestPacing?: WorkspaceItem["requestPacing"] | null; /** Dedicated field: the API PATCHes it alone for the canonical `openai` provider. */ codexAccountMode?: "direct" | "pool"; }; diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 1894b3deb0..ee7a577330 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1665,6 +1665,23 @@ export const de: Record = { "pws.removeDefaultConfirmBody": "Standardanbieter \"{name}\" entfernen? \"{defaultProvider}\" wird zum Standardanbieter. Dies kann nicht rückgängig gemacht werden.", "pws.removeConfirmTitle": "Anbieter entfernen", "pws.saveSettings": "Speichern", + "pws.pacingTitle": "Anfragetaktung", + "pws.pacingDesc": "Verteilt ausgehende Anfragestarts für diesen Anbieter gleichmäßig. Streaming-Antworten dürfen sich überlappen.", + "pws.pacingEnabled": "Aktiviert", + "pws.pacingRpm": "Anfragen pro Minute", + "pws.pacingRpmUnit": "RPM", + "pws.pacingDelay": "Mindestintervall (ms)", + "pws.pacingSlowerWins": "Das langsamere Anbieterlimit gilt. Modellregeln können nur stärker verzögern.", + "pws.pacingQueued": "in Warteschlange", + "pws.pacingNextSlot": "bis zum nächsten Slot", + "pws.pacingLastModel": "letztes Modell", + "pws.pacingNone": "Keine", + "pws.pacingModelOverrides": "Modellregeln", + "pws.pacingModel": "Modell", + "pws.pacingAdd": "Regel hinzufügen", + "pws.pacingRemove": "Entfernen", + "pws.pacingRemoveModel": "Anfragetaktung für {model} entfernen", + "pws.pacingRuleRequired": "Legen Sie zuerst ein Anbieterlimit oder eine Modellregel fest.", "pws.saving": "Wird gespeichert…", "pws.settingsSaved": "Einstellungen gespeichert.", "pws.accountModeSaved": "Kontomodus gespeichert.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index 04b154d0be..2183305e84 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1178,6 +1178,23 @@ export const en = { "pws.removeDefaultConfirmBody": "Remove default provider \"{name}\"? \"{defaultProvider}\" will become the default provider. This cannot be undone.", "pws.removeConfirmTitle": "Remove provider", "pws.saveSettings": "Save", + "pws.pacingTitle": "Request pacing", + "pws.pacingDesc": "Evenly delay outbound request starts for this provider. Streaming responses may overlap.", + "pws.pacingEnabled": "Enabled", + "pws.pacingRpm": "Requests per minute", + "pws.pacingRpmUnit": "RPM", + "pws.pacingDelay": "Minimum interval (ms)", + "pws.pacingSlowerWins": "The slower provider limit wins. Model overrides can only add more delay.", + "pws.pacingQueued": "queued", + "pws.pacingNextSlot": "until next slot", + "pws.pacingLastModel": "last model", + "pws.pacingNone": "None", + "pws.pacingModelOverrides": "Model overrides", + "pws.pacingModel": "Model", + "pws.pacingAdd": "Add override", + "pws.pacingRemove": "Remove", + "pws.pacingRemoveModel": "Remove request pacing override for {model}", + "pws.pacingRuleRequired": "Enable request pacing only after setting a provider limit or a model override.", "pws.saving": "Saving…", "pws.settingsSaved": "Settings saved.", "pws.accountModeSaved": "Account mode saved.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index 690534344f..d9f3a17cce 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1120,6 +1120,23 @@ export const ja: Record = { "pws.removeDefaultConfirmBody": "既定のプロバイダー \"{name}\" を削除しますか? \"{defaultProvider}\" が既定のプロバイダーになります。この操作は元に戻せません。", "pws.removeConfirmTitle": "プロバイダーを削除", "pws.saveSettings": "保存", + "pws.pacingTitle": "リクエスト間隔調整", + "pws.pacingDesc": "このプロバイダーへの送信開始を均等に遅延します。ストリーミング応答は重複できます。", + "pws.pacingEnabled": "有効", + "pws.pacingRpm": "1分あたりのリクエスト数", + "pws.pacingRpmUnit": "RPM", + "pws.pacingDelay": "最小間隔 (ms)", + "pws.pacingSlowerWins": "より遅いプロバイダー制限が優先され、モデル設定は遅延を増やす場合のみ適用されます。", + "pws.pacingQueued": "待機中", + "pws.pacingNextSlot": "次のスロットまで", + "pws.pacingLastModel": "最後のモデル", + "pws.pacingNone": "なし", + "pws.pacingModelOverrides": "モデル別設定", + "pws.pacingModel": "モデル", + "pws.pacingAdd": "設定を追加", + "pws.pacingRemove": "削除", + "pws.pacingRemoveModel": "{model} のリクエスト間隔設定を削除", + "pws.pacingRuleRequired": "プロバイダー制限またはモデル別設定を追加してから有効にしてください。", "pws.saving": "保存中…", "pws.settingsSaved": "設定を保存しました。", "pws.accountModeSaved": "アカウントモードを保存しました。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 440319d2ed..fd5a1d6e66 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1692,6 +1692,23 @@ export const ko: Record = { "pws.removeDefaultConfirmBody": "기본 프로바이더 \"{name}\"을(를) 제거하시겠습니까? \"{defaultProvider}\"이(가) 기본 프로바이더가 됩니다. 이 작업은 되돌릴 수 없습니다.", "pws.removeConfirmTitle": "프로바이더 제거", "pws.saveSettings": "저장", + "pws.pacingTitle": "요청 속도 조절", + "pws.pacingDesc": "이 프로바이더로 나가는 요청 시작을 일정 간격으로 지연합니다. 스트리밍 응답은 서로 겹칠 수 있습니다.", + "pws.pacingEnabled": "사용", + "pws.pacingRpm": "분당 요청 수", + "pws.pacingRpmUnit": "RPM", + "pws.pacingDelay": "최소 시작 간격(ms)", + "pws.pacingSlowerWins": "프로바이더의 더 느린 제한이 우선합니다. 모델별 설정은 지연을 더 늘릴 때만 적용됩니다.", + "pws.pacingQueued": "대기 중", + "pws.pacingNextSlot": "다음 슬롯까지", + "pws.pacingLastModel": "마지막 모델", + "pws.pacingNone": "없음", + "pws.pacingModelOverrides": "모델별 설정", + "pws.pacingModel": "모델", + "pws.pacingAdd": "모델 제한 추가", + "pws.pacingRemove": "제거", + "pws.pacingRemoveModel": "{model} 모델의 요청 속도 설정 제거", + "pws.pacingRuleRequired": "프로바이더 제한이나 모델별 제한을 하나 이상 설정한 뒤 요청 속도 조절을 켜세요.", "pws.saving": "저장 중…", "pws.settingsSaved": "설정이 저장되었습니다.", "pws.accountModeSaved": "계정 모드가 저장되었습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index 9b36937968..7914b7512d 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1162,6 +1162,23 @@ export const ru: Record = { "pws.removeDefaultConfirmBody": "Удалить провайдера по умолчанию \"{name}\"? \"{defaultProvider}\" станет провайдером по умолчанию. Это действие нельзя отменить.", "pws.removeConfirmTitle": "Удалить провайдера", "pws.saveSettings": "Сохранить", + "pws.pacingTitle": "Интервал запросов", + "pws.pacingDesc": "Равномерно задерживает начало исходящих запросов к провайдеру. Потоковые ответы могут пересекаться.", + "pws.pacingEnabled": "Включено", + "pws.pacingRpm": "Запросов в минуту", + "pws.pacingRpmUnit": "RPM", + "pws.pacingDelay": "Минимальный интервал (мс)", + "pws.pacingSlowerWins": "Действует более медленный лимит провайдера. Правила моделей могут только увеличить задержку.", + "pws.pacingQueued": "в очереди", + "pws.pacingNextSlot": "до следующего слота", + "pws.pacingLastModel": "последняя модель", + "pws.pacingNone": "Нет", + "pws.pacingModelOverrides": "Правила моделей", + "pws.pacingModel": "Модель", + "pws.pacingAdd": "Добавить правило", + "pws.pacingRemove": "Удалить", + "pws.pacingRemoveModel": "Удалить интервал запросов для {model}", + "pws.pacingRuleRequired": "Сначала задайте лимит провайдера или правило модели.", "pws.saving": "Сохранение…", "pws.settingsSaved": "Настройки сохранены.", "pws.accountModeSaved": "Режим аккаунта сохранён.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index 183b536af7..b1724cb3d7 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1169,6 +1169,23 @@ export const tr: Record = { "pws.removeDefaultConfirmBody": "Varsayılan sağlayıcı \"{name}\" kaldırılsın mı? \"{defaultProvider}\" varsayılan sağlayıcı olacaktır. Bu işlem geri alınamaz.", "pws.removeConfirmTitle": "Sağlayıcıyı kaldır", "pws.saveSettings": "Kaydet", + "pws.pacingTitle": "İstek aralığı", + "pws.pacingDesc": "Bu sağlayıcıya giden istek başlangıçlarını eşit aralıklarla geciktirir. Akış yanıtları çakışabilir.", + "pws.pacingEnabled": "Etkin", + "pws.pacingRpm": "Dakikadaki istek", + "pws.pacingRpmUnit": "RPM", + "pws.pacingDelay": "En kısa aralık (ms)", + "pws.pacingSlowerWins": "Daha yavaş sağlayıcı sınırı geçerlidir. Model kuralları yalnızca ek gecikme getirir.", + "pws.pacingQueued": "kuyrukta", + "pws.pacingNextSlot": "sonraki aralığa", + "pws.pacingLastModel": "son model", + "pws.pacingNone": "Yok", + "pws.pacingModelOverrides": "Model kuralları", + "pws.pacingModel": "Model", + "pws.pacingAdd": "Kural ekle", + "pws.pacingRemove": "Kaldır", + "pws.pacingRemoveModel": "{model} için istek aralığı kuralını kaldır", + "pws.pacingRuleRequired": "Önce bir sağlayıcı sınırı veya model kuralı belirleyin.", "pws.saving": "Kaydediliyor…", "pws.settingsSaved": "Ayarlar kaydedildi.", "pws.accountModeSaved": "Hesap modu kaydedildi.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index 340c9e18ec..127da4c080 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -959,6 +959,23 @@ export const zhTW: Record = { "pws.removeDefaultConfirmBody": "移除預設供應商「{name}」?「{defaultProvider}」將成為預設供應商。此操作無法撤消。", "pws.removeConfirmTitle": "移除供應商", "pws.saveSettings": "儲存", + "pws.pacingTitle": "請求節流", + "pws.pacingDesc": "均勻延遲送往此供應商的請求啟動。串流回應可以重疊。", + "pws.pacingEnabled": "啟用", + "pws.pacingRpm": "每分鐘請求數", + "pws.pacingRpmUnit": "次/分鐘", + "pws.pacingDelay": "最小間隔(毫秒)", + "pws.pacingSlowerWins": "以較慢的供應商限制為準,模型規則只能增加延遲。", + "pws.pacingQueued": "排隊中", + "pws.pacingNextSlot": "距下個時段", + "pws.pacingLastModel": "上個模型", + "pws.pacingNone": "無", + "pws.pacingModelOverrides": "模型規則", + "pws.pacingModel": "模型", + "pws.pacingAdd": "新增規則", + "pws.pacingRemove": "移除", + "pws.pacingRemoveModel": "移除 {model} 的請求節流規則", + "pws.pacingRuleRequired": "請先設定供應商限制或模型規則,再啟用請求節流。", "pws.saving": "儲存中…", "pws.settingsSaved": "設定已儲存。", "pws.settingsUnsavedBar": "有未儲存的更改。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index 0385282d91..05915b1325 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1685,6 +1685,23 @@ export const zh: Record = { "pws.removeDefaultConfirmBody": "移除默认提供方「{name}」?「{defaultProvider}」将成为默认提供方。此操作无法撤销。", "pws.removeConfirmTitle": "移除提供商", "pws.saveSettings": "保存", + "pws.pacingTitle": "请求节流", + "pws.pacingDesc": "均匀延迟发往此提供商的请求启动。流式响应可以重叠。", + "pws.pacingEnabled": "启用", + "pws.pacingRpm": "每分钟请求数", + "pws.pacingRpmUnit": "RPM", + "pws.pacingDelay": "最小间隔(毫秒)", + "pws.pacingSlowerWins": "以较慢的提供商限制为准,模型规则只能增加延迟。", + "pws.pacingQueued": "排队中", + "pws.pacingNextSlot": "距下个时隙", + "pws.pacingLastModel": "上个模型", + "pws.pacingNone": "无", + "pws.pacingModelOverrides": "模型规则", + "pws.pacingModel": "模型", + "pws.pacingAdd": "添加规则", + "pws.pacingRemove": "移除", + "pws.pacingRemoveModel": "移除 {model} 的请求节流规则", + "pws.pacingRuleRequired": "请先设置提供商限制或模型规则,再启用请求节流。", "pws.saving": "保存中…", "pws.settingsSaved": "设置已保存。", "pws.accountModeSaved": "账户模式已保存。", diff --git a/gui/src/provider-workspace/catalog.ts b/gui/src/provider-workspace/catalog.ts index 847d0ae326..90ed999548 100644 --- a/gui/src/provider-workspace/catalog.ts +++ b/gui/src/provider-workspace/catalog.ts @@ -45,6 +45,12 @@ export interface WorkspaceProvider { disabled?: boolean; note?: string; allowPrivateNetwork?: boolean; + requestPacing?: { + enabled?: boolean; + requestsPerMinute?: number; + minIntervalMs?: number; + models?: Record; + }; /** Codex account routing mode for the canonical `openai` forward provider. */ codexAccountMode?: "direct" | "pool"; } diff --git a/gui/src/styles/provider-workspace-settings.css b/gui/src/styles/provider-workspace-settings.css index 4fc0d52dce..3429106e7f 100644 --- a/gui/src/styles/provider-workspace-settings.css +++ b/gui/src/styles/provider-workspace-settings.css @@ -117,6 +117,33 @@ .pwi-settings-dirty { font-size: var(--text-caption); color: var(--amber); } +.pwi-pacing-card { + display: flex; flex-direction: column; gap: 12px; padding: 14px; + border: 1px solid var(--border); border-radius: var(--radius-sm); + background: color-mix(in srgb, var(--surface) 88%, var(--accent) 12%); +} +.pwi-pacing-head { display: flex; align-items: flex-start; justify-content: space-between; gap: 14px; } +.pwi-pacing-head h3, .pwi-pacing-card h4 { margin: 0; color: var(--text); font-size: var(--text-control); } +.pwi-pacing-head p { margin: 4px 0 0; color: var(--muted); font-size: var(--text-caption); line-height: 1.45; } +.pwi-pacing-toggle { display: flex; align-items: center; gap: 6px; white-space: nowrap; font-size: var(--text-label); } +.pwi-pacing-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; align-items: end; } +.pwi-pacing-grid--model { grid-template-columns: minmax(160px, 2fr) repeat(2, minmax(110px, 1fr)) auto; } +.pwi-pacing-status { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 8px; } +.pwi-pacing-status span { padding: 8px 10px; border: 1px solid var(--border-soft); border-radius: var(--radius-xs); color: var(--muted); font-size: var(--text-caption); } +.pwi-pacing-status strong { display: block; overflow: hidden; color: var(--text); font-size: var(--text-label); text-overflow: ellipsis; white-space: nowrap; } +.pwi-pacing-overrides { display: flex; flex-direction: column; gap: 6px; } +.pwi-pacing-row { display: grid; grid-template-columns: minmax(0, 1fr) auto auto; align-items: center; gap: 10px; padding: 7px 8px; border-radius: var(--radius-xs); background: var(--bg); } +.pwi-pacing-row code { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.pwi-pacing-row span { color: var(--muted); font-size: var(--text-caption); } + +@media (max-width: 760px) { + .pwi-pacing-head { flex-direction: column; } + .pwi-pacing-grid, .pwi-pacing-grid--model, .pwi-pacing-status { grid-template-columns: 1fr; } + .pwi-pacing-grid--model .btn { width: 100%; } + .pwi-pacing-row { grid-template-columns: minmax(0, 1fr) auto; } + .pwi-pacing-row span { grid-column: 1 / -1; grid-row: 2; } +} + /* ── ProviderJsonEditor ────────────────────────────────────── */ .pwi-json-panel { display: flex; flex-direction: column; gap: 8px; } diff --git a/gui/tests/provider-settings-live-models-provenance.test.tsx b/gui/tests/provider-settings-live-models-provenance.test.tsx index 097780177e..11d7cc6ab7 100644 --- a/gui/tests/provider-settings-live-models-provenance.test.tsx +++ b/gui/tests/provider-settings-live-models-provenance.test.tsx @@ -91,6 +91,7 @@ test("an unrelated settings save does not materialize an omitted liveModels valu expect(patches).toHaveLength(1); expect(Object.hasOwn(patches[0]!, "liveModels")).toBe(false); + expect(Object.hasOwn(patches[0]!, "requestPacing")).toBe(false); await act(async () => { root.unmount(); }); }); diff --git a/gui/tests/provider-settings-request-pacing.test.tsx b/gui/tests/provider-settings-request-pacing.test.tsx new file mode 100644 index 0000000000..530411b9c6 --- /dev/null +++ b/gui/tests/provider-settings-request-pacing.test.tsx @@ -0,0 +1,78 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { Window } from "happy-dom"; +import { act } from "react"; +import type { Root } from "react-dom/client"; +import ProviderSettings from "../src/components/provider-workspace/ProviderSettings"; +import type { ProviderUpdatePatch } from "../src/components/provider-workspace/types"; +import { LanguageProvider } from "../src/i18n/provider"; +import type { WorkspaceItem } from "../src/provider-workspace/catalog"; + +const globals = ["document", "window", "navigator", "localStorage", "IS_REACT_ACT_ENVIRONMENT"] as const; +let previousGlobals: Record<(typeof globals)[number], unknown>; +let testWindow: Window; + +beforeEach(() => { + previousGlobals = Object.fromEntries(globals.map(key => [key, Reflect.get(globalThis, key)])) as typeof previousGlobals; + testWindow = new Window({ url: "http://localhost/#providers/workspace" }); + Object.defineProperty(testWindow.navigator, "language", { configurable: true, value: "en-US" }); + Object.defineProperties(globalThis, { + document: { configurable: true, value: testWindow.document }, + window: { configurable: true, value: testWindow }, + navigator: { configurable: true, value: testWindow.navigator }, + localStorage: { configurable: true, value: testWindow.localStorage }, + }); + (globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; +}); + +afterEach(() => { + testWindow.close(); + for (const key of globals) Object.defineProperty(globalThis, key, { configurable: true, value: previousGlobals[key] }); +}); + +async function setInput(input: HTMLInputElement, value: string): Promise { + await act(async () => { + Object.getOwnPropertyDescriptor(testWindow.HTMLInputElement.prototype, "value")!.set!.call(input, value); + input.dispatchEvent(new testWindow.Event("input", { bubbles: true })); + }); +} + +test("settings saves provider pacing and a slower model override", async () => { + const item: WorkspaceItem = { + name: "nvidia", + adapter: "openai-chat", + baseUrl: "https://integrate.api.nvidia.com/v1", + authMode: "key", + }; + const patches: ProviderUpdatePatch[] = []; + const container = document.createElement("div"); + document.body.append(container); + const { createRoot } = await import("react-dom/client"); + let root!: Root; + await act(async () => { + root = createRoot(container); + root.render( { patches.push(patch); return { ok: true }; }} + />); + }); + + await act(async () => { container.querySelector(".pwi-pacing-toggle input")!.click(); }); + const numbers = container.querySelectorAll('.pwi-pacing-card input[type="number"]'); + await setInput(numbers[0]!, "38"); + const modelInput = container.querySelector('.pwi-pacing-grid--model input[list]')!; + await setInput(modelInput, "deepseek-ai/deepseek-v4-flash-0731"); + await setInput(numbers[2]!, "10"); + await act(async () => { container.querySelector(".pwi-pacing-grid--model button")!.click(); }); + const save = container.querySelector(".pwi-settings-sticky-bar .btn-primary")!; + await act(async () => { save.click(); await Promise.resolve(); }); + + expect(patches).toHaveLength(1); + expect(patches[0]?.requestPacing).toEqual({ + enabled: true, + requestsPerMinute: 38, + models: { "deepseek-ai/deepseek-v4-flash-0731": { requestsPerMinute: 10 } }, + }); + expect(container.textContent).toContain("deepseek-ai/deepseek-v4-flash-0731"); + await act(async () => { root.unmount(); }); +}); diff --git a/src/config.ts b/src/config.ts index ef3580e1a2..5a7ee67680 100644 --- a/src/config.ts +++ b/src/config.ts @@ -613,6 +613,33 @@ const retryOn429PolicySchema = z.object({ respectRetryAfter: z.boolean().optional(), }).strict(); +const requestPacingRuleSchema = z.object({ + // Keep the RPM-derived timer within the same one-hour bound as minIntervalMs. + requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), + minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), +}).strict().refine(value => value.requestsPerMinute !== undefined || value.minIntervalMs !== undefined, { + message: "request pacing rules need requestsPerMinute or minIntervalMs", +}); + +const requestPacingSchema = z.object({ + enabled: z.boolean(), + requestsPerMinute: z.number().min(1 / 60).max(60_000).optional(), + minIntervalMs: z.number().int().min(1).max(3_600_000).optional(), + models: z.record(z.string().trim().min(1), requestPacingRuleSchema).optional(), +}).strict().refine(value => value.enabled === false + || value.requestsPerMinute !== undefined + || value.minIntervalMs !== undefined + || (value.models !== undefined && Object.keys(value.models).length > 0), { + message: "enabled request pacing needs a provider rule or model override", +}); + +export function requestPacingConfigError(value: unknown): string | null { + if (value === undefined) return null; + const parsed = requestPacingSchema.safeParse(value); + if (parsed.success) return null; + return "requestPacing must contain enabled and a valid requestsPerMinute/minIntervalMs provider rule or model overrides"; +} + /** * Zod schema for one provider entry: known fields are validated strictly while unknown * fields pass through (preserved for runtime extensions). @@ -620,6 +647,7 @@ const retryOn429PolicySchema = z.object({ const providerConfigSchema = z.object({ adapter: z.string().min(1), baseUrl: z.string().min(1), + requestPacing: requestPacingSchema.optional().catch(undefined), mcpMaxTools: z.number().int().positive().optional(), mcpMaxSchemaBytes: z.number().int().positive().optional(), mcpMaxResultBytes: z.number().int().positive().optional(), diff --git a/src/providers/request-pacing.ts b/src/providers/request-pacing.ts new file mode 100644 index 0000000000..efd519502d --- /dev/null +++ b/src/providers/request-pacing.ts @@ -0,0 +1,190 @@ +import type { OcxProviderConfig, RequestPacingRule } from "../types"; + +interface Waiter { + modelId?: string; + providerIntervalMs: number; + modelIntervalMs: number; + signal?: AbortSignal; + resolve: () => void; + reject: (reason: unknown) => void; + abort?: () => void; +} + +interface ProviderPacer { + queue: Waiter[]; + providerNextStartAt: number; + modelNextStartAt: Map; + timer?: ReturnType; + active: boolean; + lastStartedAt?: number; + lastModelId?: string; +} + +export interface ProviderRequestPacingStatus { + provider: string; + enabled: boolean; + queued: number; + nextSlotInMs: number; + lastStartedAt?: number; + lastModelId?: string; +} + +const pacers = new Map(); + +function abortReason(signal: AbortSignal): unknown { + return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); +} + +function normalizedInterval(rule: RequestPacingRule | undefined): number { + if (!rule) return 0; + const rpmInterval = typeof rule.requestsPerMinute === "number" && rule.requestsPerMinute > 0 + ? 60_000 / rule.requestsPerMinute + : 0; + const fixedInterval = typeof rule.minIntervalMs === "number" && rule.minIntervalMs > 0 + ? rule.minIntervalMs + : 0; + return Math.max(rpmInterval, fixedInterval); +} + +export function requestPacingIntervalMs(provider: OcxProviderConfig, modelId?: string): number { + const policy = provider.requestPacing; + if (!policy?.enabled) return 0; + const override = modelId ? policy.models?.[modelId] : undefined; + return Math.max(normalizedInterval(policy), normalizedInterval(override)); +} + +function requestPacingIntervals(provider: OcxProviderConfig, modelId?: string): { + providerIntervalMs: number; + modelIntervalMs: number; +} { + const policy = provider.requestPacing; + if (!policy?.enabled) return { providerIntervalMs: 0, modelIntervalMs: 0 }; + return { + providerIntervalMs: normalizedInterval(policy), + modelIntervalMs: modelId ? normalizedInterval(policy.models?.[modelId]) : 0, + }; +} + +function runQueue(providerName: string, state: ProviderPacer): void { + if (state.active) return; + if (state.queue.length === 0) { + if (state.timer) clearTimeout(state.timer); + state.timer = undefined; + return; + } + if (state.timer) return; + const now = Date.now(); + for (const [modelId, readyAt] of state.modelNextStartAt) { + if (readyAt <= now) state.modelNextStartAt.delete(modelId); + } + const providerReadyAt = Math.max(now, state.providerNextStartAt); + let waiterIndex = state.queue.findIndex(waiter => { + const modelReadyAt = waiter.modelId ? (state.modelNextStartAt.get(waiter.modelId) ?? 0) : 0; + return Math.max(providerReadyAt, modelReadyAt) <= now; + }); + if (waiterIndex < 0) { + let earliestAt = Number.POSITIVE_INFINITY; + for (const waiter of state.queue) { + const modelReadyAt = waiter.modelId ? (state.modelNextStartAt.get(waiter.modelId) ?? 0) : 0; + earliestAt = Math.min(earliestAt, Math.max(providerReadyAt, modelReadyAt)); + } + const delayMs = Math.max(0, earliestAt - now); + state.timer = setTimeout(() => { + state.timer = undefined; + runQueue(providerName, state); + }, delayMs); + return; + } + const waiter = state.queue[waiterIndex]!; + const delayMs = Math.max(0, state.providerNextStartAt - now); + if (delayMs > 0) { + state.timer = setTimeout(() => { + state.timer = undefined; + runQueue(providerName, state); + }, delayMs); + return; + } + + state.active = true; + state.queue.splice(waiterIndex, 1); + if (waiter.abort) waiter.signal?.removeEventListener("abort", waiter.abort); + const startedAt = Date.now(); + state.lastStartedAt = startedAt; + state.lastModelId = waiter.modelId; + state.providerNextStartAt = startedAt + waiter.providerIntervalMs; + if (waiter.modelId && waiter.modelIntervalMs > 0) { + state.modelNextStartAt.set(waiter.modelId, startedAt + waiter.modelIntervalMs); + } + state.active = false; + waiter.resolve(); + queueMicrotask(() => runQueue(providerName, state)); +} + +export async function waitForProviderRequestSlot( + providerName: string, + provider: OcxProviderConfig, + modelId?: string, + signal?: AbortSignal, +): Promise { + const intervals = requestPacingIntervals(provider, modelId); + if (Math.max(intervals.providerIntervalMs, intervals.modelIntervalMs) <= 0) return; + if (signal?.aborted) throw abortReason(signal); + + const state = pacers.get(providerName) ?? { + queue: [], providerNextStartAt: 0, modelNextStartAt: new Map(), active: false, + }; + pacers.set(providerName, state); + await new Promise((resolve, reject) => { + const waiter: Waiter = { modelId, ...intervals, signal, resolve, reject }; + waiter.abort = () => { + const index = state.queue.indexOf(waiter); + if (index >= 0) state.queue.splice(index, 1); + if (state.timer) { + clearTimeout(state.timer); + state.timer = undefined; + } + reject(abortReason(signal!)); + runQueue(providerName, state); + }; + signal?.addEventListener("abort", waiter.abort, { once: true }); + state.queue.push(waiter); + // Abort may race between the eager check above and listener registration. + if (signal?.aborted) { + waiter.abort(); + return; + } + if (state.timer) { + clearTimeout(state.timer); + state.timer = undefined; + } + runQueue(providerName, state); + }); +} + +export function providerRequestPacingStatus( + providerName: string, + provider: OcxProviderConfig, + now = Date.now(), +): ProviderRequestPacingStatus { + const state = pacers.get(providerName); + let nextSlotAt = state?.providerNextStartAt ?? 0; + if (state && state.queue.length > 0) { + nextSlotAt = Math.min(...state.queue.map(waiter => Math.max( + state.providerNextStartAt, + waiter.modelId ? (state.modelNextStartAt.get(waiter.modelId) ?? 0) : 0, + ))); + } + return { + provider: providerName, + enabled: provider.requestPacing?.enabled === true, + queued: state?.queue.length ?? 0, + nextSlotInMs: Math.max(0, Math.ceil(nextSlotAt - now)), + ...(state?.lastStartedAt !== undefined ? { lastStartedAt: state.lastStartedAt } : {}), + ...(state?.lastModelId ? { lastModelId: state.lastModelId } : {}), + }; +} + +export function resetProviderRequestPacingForTest(): void { + for (const state of pacers.values()) if (state.timer) clearTimeout(state.timer); + pacers.clear(); +} diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index dd7742cc02..aeeafa0bf6 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -14,6 +14,7 @@ import { providerModelCostsConfigError, reasoningSummaryDeliveryRecordConfigError, retryOn429PolicyConfigError, + requestPacingConfigError, sanitizeModelCostsForDisplay, } from "../config"; import { providerDestinationConfigError } from "../lib/destination-policy"; @@ -453,6 +454,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): // modelCosts is a user-owned display overlay, not part of the canonical // forward seed; it is validated separately below (providerModelCostsConfigError). delete canonicalCandidate.modelCosts; + // requestPacing is a user-owned transport overlay, not part of the canonical seed. + delete canonicalCandidate.requestPacing; const canonical = seed && sameCanonicalProviderSeed(canonicalCandidate, seed); if (!canonical) { return `provider ${name} must equal the canonical built-in provider seed`; @@ -477,6 +480,10 @@ export function providerManagementConfigError(name: unknown, provider: unknown): // it before it reaches the management API response. return `provider ${JSON.stringify(redactSecretString(name))} ${retryOn429Error}`; } + const requestPacingError = requestPacingConfigError(raw.requestPacing); + if (requestPacingError) { + return `provider ${JSON.stringify(redactSecretString(name))} ${requestPacingError}`; + } const modelCostsError = providerModelCostsConfigError(raw.modelCosts); if (modelCostsError) { // The provider name is caller-controlled and can be token-shaped; redact and JSON-escape @@ -580,6 +587,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "keyOptional", "freeTier", "liveModels", + "requestPacing", "models", "contextWindow", "modelContextWindows", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 203cc6a584..c9da16cb8c 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -14,6 +14,7 @@ import { normalizeNonBlankStringArray, providerBaseUrlConfigError, providerHeadersConfigError, + requestPacingConfigError, readConfigAdmissionSnapshot, saveConfigPreservingClaudeCode, withConfigMutationLockSync, @@ -44,6 +45,7 @@ import { import { routedSlug, slugEquals } from "../../providers/slug-codec"; import { clearAccountQuotaCache, clearProviderQuotaCache, fetchProviderQuotaReports } from "../../providers/quota"; import { clearKeyCooldowns } from "../../providers/key-failover"; +import { providerRequestPacingStatus } from "../../providers/request-pacing"; import { CODEX_FORWARD_BASE_URL, isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { codexAccountNamespaceProviderCollisionError } from "../../codex/account-namespace-match"; import { clearThreadAccountMap } from "../../codex/routing"; @@ -177,6 +179,20 @@ function applyProviderPatchFields( next.liveModels = rawBody.liveModels; touched = true; } + if (Object.hasOwn(rawBody, "requestPacing")) { + const value = rawBody.requestPacing; + if (value === null) { + delete next.requestPacing; + } else { + if (!isPlainRecord(value)) return { error: "requestPacing must be a plain object or null" }; + const pacingError = requestPacingConfigError(value); + if (pacingError) return { error: pacingError }; + // `requestPacingConfigError` is the runtime narrowing boundary above; keep the + // assertion explicit because a generic plain record cannot express `enabled`. + next.requestPacing = structuredClone(value) as unknown as OcxProviderConfig["requestPacing"]; + } + touched = true; + } // The Models page edits the catalog hints in place; keep them on the existing // provider mutation path so validation, cache invalidation, and convergence stay unified (#1073). if (Object.hasOwn(rawBody, "contextWindow")) { @@ -304,6 +320,22 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise [ + providerName, + providerRequestPacingStatus(providerName, provider), + ]), + )); + } + if (url.pathname === "/api/providers" && req.method === "GET") { return jsonResponse(Object.entries(config.providers).map(([name, p]) => ({ name, adapter: p.adapter, baseUrl: publicProviderBaseUrl(p.baseUrl), defaultModel: p.defaultModel, @@ -312,6 +344,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise 0, allowPrivateNetwork: p.allowPrivateNetwork === true, liveModels: p.liveModels !== false, + requestPacing: p.requestPacing, models: p.models ?? [], contextWindow: p.contextWindow, modelContextWindows: p.modelContextWindows, @@ -437,6 +470,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise key === "requestPacing"); + if (applied.editorTouched && !pacingOnly) { const providerError = providerManagementConfigError(name, next); if (providerError) return jsonResponse({ error: providerError }, 400); const resolvedError = await providerDestinationResolvedError(name, next); @@ -575,7 +613,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { settleObservedHostResponse(); @@ -2917,7 +2918,7 @@ async function handleResponsesInner( : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2), connectTimeoutMs: config.connectTimeoutMs ?? 200_000, stallTimeoutSec: config.stallTimeoutSec, - fetchImpl: providerFetch(route.provider), + fetchImpl: providerFetch(route.provider, route.providerName, route.modelId), onRequestBuilt: request => recordAdapterReasoning(logCtx, request), ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}), onUsage: usage => { @@ -3224,6 +3225,7 @@ async function handleResponsesInner( try { if (activeAdapter.fetchResponse) { noteAttemptSend(logCtx.activeAttempt, inputTokenEstimate); + await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal); upstreamResponse = await activeAdapter.fetchResponse(builtInitialRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, @@ -3237,7 +3239,7 @@ async function handleResponsesInner( method: builtInitialRequest.method, headers: builtInitialRequest.headers, body: builtInitialRequest.body, - }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)); + }, recovery), upstream.signal, connectMs, parsed.stream, providerFetch(route.provider, route.providerName, route.modelId)); }, { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url) }, ); @@ -3312,11 +3314,13 @@ async function handleResponsesInner( noteAttemptSend(logCtx.activeAttempt, retryEstimate, recovery); try { try { - return activeAdapter.fetchResponse - ? await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream }) - : await fetchWithHeaderTimeout(retryRequest.url, { - method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, - }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider)); + if (activeAdapter.fetchResponse) { + await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, upstream.signal); + return await activeAdapter.fetchResponse(retryRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, stream: parsed.stream }); + } + return await fetchWithHeaderTimeout(retryRequest.url, { + method: retryRequest.method, headers: retryRequest.headers, body: retryRequest.body, + }, upstream.signal, connectMs, parsed.stream, providerFetch(route.provider, route.providerName, route.modelId)); } finally { retryRequest.releaseBodyObservation?.(); } @@ -3621,6 +3625,7 @@ async function handleResponsesInner( try { if (activeAdapter.fetchResponse) { noteAttemptSend(logCtx.activeAttempt, continuationEstimate, replayKind); + await waitForProviderRequestSlot(route.providerName, route.provider, nextParsed.modelId, upstream.signal); return await activeAdapter.fetchResponse(builtContinuationRequest, { abortSignal: upstream.signal, timeoutMs: connectMs, @@ -3640,7 +3645,7 @@ async function handleResponsesInner( upstream.signal, connectMs, nextParsed.stream, - providerFetch(route.provider), + providerFetch(route.provider, route.providerName, nextParsed.modelId), ); }, { abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url) }, diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index b4affdc0ab..b2d36bab94 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -97,6 +97,7 @@ import { } from "../relay"; import { hasResponsesItemIdRepair, relaySseWithResponsesItemIdRepair } from "../responses-item-id-repair"; import type { EffectiveSubagentRoster, SpawnAgentSurface } from "../../codex/catalog"; +import { waitForProviderRequestSlot } from "../../providers/request-pacing"; export function disableResponsesRequestTimeout(req: Request, server: Pick, "timeout"> | undefined): boolean { @@ -131,18 +132,33 @@ export function safeOriginLabel(url: string): string { -export function providerFetch(provider: OcxProviderConfig): typeof globalThis.fetch { +interface PaceAwareFetch { + waitForPacing?: (signal?: AbortSignal) => Promise; + unpacedFetch?: typeof globalThis.fetch; +} + +export function providerFetch(provider: OcxProviderConfig, providerName?: string, modelId?: string): typeof globalThis.fetch { const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch; // ChatGPT Codex backend: streaming turns ride the responses_websockets // transport (measured ~3s faster TTFT than the SSE POST queue); everything // else keeps the provider's HTTP fetch. See ws-upstream.ts for the details. - const wrapped = (input: Parameters[0], init?: RequestInit) => { + const unpaced = async (input: Parameters[0], init?: RequestInit) => { if (typeof input === "string" && init && shouldUseCodexWsUpstream(input, init)) { return codexWsUpstreamFetch(input, init, base); } return base(input, init); }; - return wrapped as typeof globalThis.fetch; + const waitForPacing = (signal?: AbortSignal) => providerName + ? waitForProviderRequestSlot(providerName, provider, modelId, signal) + : Promise.resolve(); + const wrapped = async (input: Parameters[0], init?: RequestInit) => { + await waitForPacing(init?.signal ?? undefined); + return unpaced(input, init); + }; + const preconnect = (...args: Parameters): void => { + base.preconnect?.(...args); + }; + return Object.assign(wrapped, { preconnect, waitForPacing, unpacedFetch: Object.assign(unpaced, { preconnect }) }); } @@ -156,6 +172,9 @@ export async function fetchWithHeaderTimeout( executor: typeof globalThis.fetch = globalThis.fetch, manualRedirect = false, ): Promise { + const pacing = executor as typeof globalThis.fetch & PaceAwareFetch; + await pacing.waitForPacing?.(abortSignal); + const fetchExecutor = pacing.unpacedFetch ?? executor; const timeout = new AbortController(); const timer = setTimeout(() => { if (!timeout.signal.aborted) timeout.abort(new DOMException("Timeout elapsed", "TimeoutError")); @@ -167,7 +186,7 @@ export async function fetchWithHeaderTimeout( headers.set("accept-encoding", "identity"); } try { - return await executor(url, { + return await fetchExecutor(url, { ...init, headers, // Credential-bearing sends opt into manual redirects so a 3xx is relayed diff --git a/src/types.ts b/src/types.ts index d24811f430..e79cec69f3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1226,12 +1226,28 @@ export interface ProviderCostOverlay { cacheWrite: number; } +export interface RequestPacingRule { + /** Evenly spread request starts to this many requests per minute. */ + requestsPerMinute?: number; + /** Minimum delay between request starts. The slower configured value wins. */ + minIntervalMs?: number; +} + +export interface ProviderRequestPacingConfig extends RequestPacingRule { + /** False preserves legacy behavior with no client-side waiting. */ + enabled: boolean; + /** Exact upstream model-id overrides; other models inherit the provider rule. */ + models?: Record; +} + /** * One configured provider entry. `authMode` (default `"key"`) decides whether same-target 429 * retries are allowed; OAuth/forward credentials and local runtimes are never replayed. */ export interface OcxProviderConfig { adapter: string; + /** Optional outbound request-start pacing shared by this provider and its model overrides. */ + requestPacing?: ProviderRequestPacingConfig; /** Cursor MCP compatibility bounds; positive integers when configured. */ mcpMaxTools?: number; mcpMaxSchemaBytes?: number; diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1b09e8fdde..03aee9a3be 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -335,6 +335,15 @@ once the server observes the client disconnect (Bun propagates it asynchronously cancelled with 499 before any replay; because the propagation is async, a replay may precede the cancel if the interval elapses first (bounded by the same `attempts` budget). +Provider-level `requestPacing` is the proactive companion to `retryOn429`. It reserves outbound +request-start slots before transport work begins, so a known RPM ceiling does not have to fail once +before the proxy reacts. One provider-wide lane enforces the aggregate ceiling. Exact model lanes +may add a slower interval without lowering the provider-wide interval or blocking an otherwise +eligible sibling model. Queue wait is abort-aware and happens before the response-header timeout is +armed. The shared fetch boundary covers HTTP and Responses WebSocket sends; explicit adapter +`fetchResponse` calls reserve the same lane at their call sites. Custom `runTurn` transports stay +outside this policy because they own their complete request lifecycle. + [Decision Log] - 목적과 의도: Prevent Kiro progress from becoming a false final answer, reject invalid empty completion retries, and stop concurrent transient 429s from consuming independent retry budgets. - 기존 구현 및 제약 조건: Kiro text has no trustworthy phase; stop metadata arrives only at stream end; the private completion tool is adapter-owned; normal parallel tool traffic must remain parallel; client cancellation must interrupt all waits. diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 7e1ee8c553..c5d32be225 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -439,6 +439,69 @@ describe("provider management validation", () => { expect(secretNameError).toContain("[REDACTED]"); }); + test("provider request pacing PATCH persists provider and model limits without catalog churn", async () => { + if (existsSync(TEST_DIR)) rmSync(TEST_DIR, { recursive: true }); + mkdirSync(TEST_DIR, { recursive: true }); + process.env.OPENCODEX_HOME = TEST_DIR; + const liveConfig: OcxConfig = { + port: 0, + hostname: "127.0.0.1", + defaultProvider: "nvidia", + providers: { + nvidia: { + adapter: "openai-chat", + baseUrl: "https://integrate.api.nvidia.com/v1", + apiKey: "sk-nvidia", + }, + }, + }; + saveConfig(liveConfig); + let catalogRefreshes = 0; + const request = async (path: string, init?: RequestInit) => { + const req = new Request(`http://127.0.0.1${path}`, init); + return handleManagementAPI(req, new URL(req.url), liveConfig, { + createManagementConvergeCodex: catalogConvergenceFactory(() => { catalogRefreshes += 1; }), + }); + }; + const policy = { + enabled: true, + requestsPerMinute: 38, + minIntervalMs: 1_600, + models: { "deepseek-ai/deepseek-v4-flash-0731": { requestsPerMinute: 10 } }, + }; + + const saved = await request("/api/providers?name=nvidia", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestPacing: policy }), + }); + expect(saved?.status).toBe(200); + expect(liveConfig.providers.nvidia?.requestPacing).toEqual(policy); + expect(loadConfig().providers.nvidia?.requestPacing).toEqual(policy); + expect(catalogRefreshes).toBe(0); + + const providers = await request("/api/providers"); + expect((await providers?.json()).find((row: { name: string }) => row.name === "nvidia").requestPacing).toEqual(policy); + const status = await request("/api/provider-request-pacing?name=nvidia"); + expect(await status?.json()).toMatchObject({ provider: "nvidia", enabled: true, queued: 0, nextSlotInMs: 0 }); + + const invalid = await request("/api/providers?name=nvidia", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestPacing: { enabled: true, requestsPerMinute: -1 } }), + }); + expect(invalid?.status).toBe(400); + expect(liveConfig.providers.nvidia?.requestPacing).toEqual(policy); + + const timerOverflow = await request("/api/providers?name=nvidia", { + method: "PATCH", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ requestPacing: { enabled: true, requestsPerMinute: 0.001 } }), + }); + expect(timerOverflow?.status).toBe(400); + expect(liveConfig.providers.nvidia?.requestPacing).toEqual(policy); + }); + test("provider discovery status is additive and omitted before an attempt", async () => { markProviderDiscoveryFailed("auth-broken", { reason: "http", httpStatus: 401 }); try { diff --git a/tests/request-pacing.test.ts b/tests/request-pacing.test.ts new file mode 100644 index 0000000000..ff2441822a --- /dev/null +++ b/tests/request-pacing.test.ts @@ -0,0 +1,144 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { + providerRequestPacingStatus, + requestPacingIntervalMs, + resetProviderRequestPacingForTest, + waitForProviderRequestSlot, +} from "../src/providers/request-pacing"; +import { providerFetch } from "../src/server/responses/fetch-helpers"; +import { fetchWithHeaderTimeout } from "../src/server/responses/fetch-helpers"; +import type { OcxProviderConfig } from "../src/types"; + +afterEach(() => resetProviderRequestPacingForTest()); + +function provider(requestPacing: OcxProviderConfig["requestPacing"]): OcxProviderConfig { + return { adapter: "openai-chat", baseUrl: "https://example.test/v1", requestPacing }; +} + +describe("requestPacingIntervalMs", () => { + test("uses the slower of provider RPM, provider delay, and model override", () => { + const configured = provider({ + enabled: true, + requestsPerMinute: 120, + minIntervalMs: 700, + models: { + slow: { requestsPerMinute: 30 }, + attemptedFast: { requestsPerMinute: 600 }, + }, + }); + expect(requestPacingIntervalMs(configured, "ordinary")).toBe(700); + expect(requestPacingIntervalMs(configured, "slow")).toBe(2_000); + expect(requestPacingIntervalMs(configured, "attemptedFast")).toBe(700); + }); + + test("supports model-only pacing while unrelated models remain unpaced", () => { + const configured = provider({ enabled: true, models: { slow: { minIntervalMs: 900 } } }); + expect(requestPacingIntervalMs(configured, "slow")).toBe(900); + expect(requestPacingIntervalMs(configured, "other")).toBe(0); + }); +}); + +describe("provider request pacing queue", () => { + test("spaces concurrent starts in one provider FIFO and exposes queue state", async () => { + const starts: number[] = []; + const fetchImpl = Object.assign(async () => { + starts.push(Date.now()); + return new Response("ok"); + }, { preconnect() {} }) as typeof globalThis.fetch; + const configured = { + ...provider({ enabled: true, requestsPerMinute: 600 }), + fetch: fetchImpl, + } as OcxProviderConfig & { fetch: typeof globalThis.fetch }; + const send = providerFetch(configured, "demo", "model-a"); + const pending = [send("https://example.test/v1/chat/completions"), send("https://example.test/v1/chat/completions"), send("https://example.test/v1/chat/completions")]; + await Bun.sleep(10); + expect(providerRequestPacingStatus("demo", configured).queued).toBe(2); + await Promise.all(pending); + expect(starts).toHaveLength(3); + expect(starts[1] - starts[0]).toBeGreaterThanOrEqual(85); + expect(starts[2] - starts[1]).toBeGreaterThanOrEqual(85); + const status = providerRequestPacingStatus("demo", configured); + expect(status.queued).toBe(0); + expect(status.lastModelId).toBe("model-a"); + }); + + test("aborted queued requests leave immediately and never consume a start", async () => { + const configured = provider({ enabled: true, minIntervalMs: 1_000 }); + await waitForProviderRequestSlot("demo", configured, "first"); + const controller = new AbortController(); + const queued = waitForProviderRequestSlot("demo", configured, "cancelled", controller.signal); + expect(providerRequestPacingStatus("demo", configured).queued).toBe(1); + controller.abort(); + await expect(queued).rejects.toHaveProperty("name", "AbortError"); + expect(providerRequestPacingStatus("demo", configured).queued).toBe(0); + }); + + test("a slow model override does not slow other models beyond the provider interval", async () => { + const starts: Array<{ model: string; at: number }> = []; + const configured = provider({ + enabled: true, + minIntervalMs: 80, + models: { slow: { minIntervalMs: 400 } }, + }); + await waitForProviderRequestSlot("demo", configured, "slow"); + starts.push({ model: "slow", at: Date.now() }); + await waitForProviderRequestSlot("demo", configured, "fast"); + starts.push({ model: "fast", at: Date.now() }); + expect(starts[1].at - starts[0].at).toBeGreaterThanOrEqual(65); + expect(starts[1].at - starts[0].at).toBeLessThan(250); + }); + + test("the same model still observes its slower model override", async () => { + const configured = provider({ + enabled: true, + minIntervalMs: 50, + models: { slow: { minIntervalMs: 180 } }, + }); + const first = Date.now(); + await waitForProviderRequestSlot("demo", configured, "slow"); + await waitForProviderRequestSlot("demo", configured, "slow"); + expect(Date.now() - first).toBeGreaterThanOrEqual(160); + }); + + test("a model waiting on its override does not block another eligible model", async () => { + const configured = provider({ + enabled: true, + minIntervalMs: 60, + models: { slow: { minIntervalMs: 350 } }, + }); + await waitForProviderRequestSlot("demo", configured, "slow"); + const started = Date.now(); + const secondSlow = waitForProviderRequestSlot("demo", configured, "slow"); + const fast = waitForProviderRequestSlot("demo", configured, "fast"); + await fast; + expect(Date.now() - started).toBeGreaterThanOrEqual(45); + expect(Date.now() - started).toBeLessThan(220); + await secondSlow; + }); + + test("disabled policies preserve the unpaced legacy path", async () => { + const configured = provider({ enabled: false, requestsPerMinute: 1 }); + const started = Date.now(); + await Promise.all([ + waitForProviderRequestSlot("demo", configured, "a"), + waitForProviderRequestSlot("demo", configured, "b"), + ]); + expect(Date.now() - started).toBeLessThan(50); + expect(providerRequestPacingStatus("demo", configured).enabled).toBe(false); + }); + + test("queue waiting does not consume the response-header timeout budget", async () => { + const fetchImpl = Object.assign(async () => { + await Bun.sleep(20); + return new Response("ok"); + }, { preconnect() {} }) as typeof globalThis.fetch; + const configured = { + ...provider({ enabled: true, minIntervalMs: 120 }), + fetch: fetchImpl, + } as OcxProviderConfig & { fetch: typeof globalThis.fetch }; + const executor = providerFetch(configured, "demo", "model-a"); + await fetchWithHeaderTimeout("https://example.test/v1/chat/completions", {}, new AbortController().signal, 50, false, executor); + const second = await fetchWithHeaderTimeout("https://example.test/v1/chat/completions", {}, new AbortController().signal, 50, false, executor); + expect(second.status).toBe(200); + }); +}); From 3000c9ef6f2de120f42a49d49d0691ab07d242b7 Mon Sep 17 00:00:00 2001 From: kimrinking-cell <248360754+kimrinking-cell@users.noreply.github.com> Date: Thu, 13 Aug 2026 14:26:10 +0900 Subject: [PATCH 02/13] docs: replace pacing screenshot with English UI --- assets/request-pacing-dashboard.jpg | Bin 49672 -> 49076 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/request-pacing-dashboard.jpg b/assets/request-pacing-dashboard.jpg index 94986cb74ee24083c21404a4ad767b6bc036c391..8a4f6008cf437cf00db94d1b32ce6b392a05ce5d 100644 GIT binary patch delta 39412 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 Y=3yx_B?b9r)^O+^B8p#H@Snc_0_81>mjD0& delta 40034 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 Date: Thu, 13 Aug 2026 16:14:11 +0900 Subject: [PATCH 03/13] fix: bound provider request pacing queue --- src/providers/request-pacing.ts | 81 ++++++++++++++++++++++++++++++--- 1 file changed, 74 insertions(+), 7 deletions(-) diff --git a/src/providers/request-pacing.ts b/src/providers/request-pacing.ts index efd519502d..ee5972699a 100644 --- a/src/providers/request-pacing.ts +++ b/src/providers/request-pacing.ts @@ -1,9 +1,28 @@ import type { OcxProviderConfig, RequestPacingRule } from "../types"; +export const REQUEST_PACING_MAX_QUEUE_DEPTH = 256; +export const REQUEST_PACING_MAX_QUEUE_AGE_MS = 60_000; + +export type RequestPacingQueueOverloadReason = "queue_full" | "queue_expired"; + +export class RequestPacingQueueOverloadError extends Error { + constructor( + public readonly providerName: string, + public readonly reason: RequestPacingQueueOverloadReason, + public readonly retryAfterSeconds: number, + ) { + super(reason === "queue_full" + ? `request pacing queue for provider '${providerName}' is full` + : `request pacing queue for provider '${providerName}' exceeded the maximum queued age`); + this.name = "RequestPacingQueueOverloadError"; + } +} + interface Waiter { modelId?: string; providerIntervalMs: number; modelIntervalMs: number; + queuedAt: number; signal?: AbortSignal; resolve: () => void; reject: (reason: unknown) => void; @@ -65,6 +84,31 @@ function requestPacingIntervals(provider: OcxProviderConfig, modelId?: string): }; } +function waiterReadyAt(state: ProviderPacer, modelId: string | undefined): number { + return Math.max( + state.providerNextStartAt, + modelId ? (state.modelNextStartAt.get(modelId) ?? 0) : 0, + ); +} + +function pacingRetryAfterSeconds(state: ProviderPacer, modelId: string | undefined, now: number): number { + return Math.max(1, Math.ceil(Math.max(0, waiterReadyAt(state, modelId) - now) / 1000)); +} + +function rejectExpiredWaiters(providerName: string, state: ProviderPacer, now: number): void { + for (let index = state.queue.length - 1; index >= 0; index -= 1) { + const waiter = state.queue[index]!; + if (now - waiter.queuedAt < REQUEST_PACING_MAX_QUEUE_AGE_MS) continue; + state.queue.splice(index, 1); + if (waiter.abort) waiter.signal?.removeEventListener("abort", waiter.abort); + waiter.reject(new RequestPacingQueueOverloadError( + providerName, + "queue_expired", + pacingRetryAfterSeconds(state, waiter.modelId, now), + )); + } +} + function runQueue(providerName: string, state: ProviderPacer): void { if (state.active) return; if (state.queue.length === 0) { @@ -77,8 +121,11 @@ function runQueue(providerName: string, state: ProviderPacer): void { for (const [modelId, readyAt] of state.modelNextStartAt) { if (readyAt <= now) state.modelNextStartAt.delete(modelId); } + rejectExpiredWaiters(providerName, state, now); + if (state.queue.length === 0) return; + const providerReadyAt = Math.max(now, state.providerNextStartAt); - let waiterIndex = state.queue.findIndex(waiter => { + const waiterIndex = state.queue.findIndex(waiter => { const modelReadyAt = waiter.modelId ? (state.modelNextStartAt.get(waiter.modelId) ?? 0) : 0; return Math.max(providerReadyAt, modelReadyAt) <= now; }); @@ -86,7 +133,9 @@ function runQueue(providerName: string, state: ProviderPacer): void { let earliestAt = Number.POSITIVE_INFINITY; for (const waiter of state.queue) { const modelReadyAt = waiter.modelId ? (state.modelNextStartAt.get(waiter.modelId) ?? 0) : 0; - earliestAt = Math.min(earliestAt, Math.max(providerReadyAt, modelReadyAt)); + const readyAt = Math.max(providerReadyAt, modelReadyAt); + const expiresAt = waiter.queuedAt + REQUEST_PACING_MAX_QUEUE_AGE_MS; + earliestAt = Math.min(earliestAt, readyAt, expiresAt); } const delayMs = Math.max(0, earliestAt - now); state.timer = setTimeout(() => { @@ -134,8 +183,25 @@ export async function waitForProviderRequestSlot( queue: [], providerNextStartAt: 0, modelNextStartAt: new Map(), active: false, }; pacers.set(providerName, state); + + // Give already-eligible or expired waiters a chance to leave before applying the + // admission bound to the newest request. This preserves FIFO-ish fairness while + // keeping the retained queue strictly bounded under burst load. + if (state.timer) { + clearTimeout(state.timer); + state.timer = undefined; + } + runQueue(providerName, state); + if (state.queue.length >= REQUEST_PACING_MAX_QUEUE_DEPTH) { + throw new RequestPacingQueueOverloadError( + providerName, + "queue_full", + pacingRetryAfterSeconds(state, modelId, Date.now()), + ); + } + await new Promise((resolve, reject) => { - const waiter: Waiter = { modelId, ...intervals, signal, resolve, reject }; + const waiter: Waiter = { modelId, ...intervals, queuedAt: Date.now(), signal, resolve, reject }; waiter.abort = () => { const index = state.queue.indexOf(waiter); if (index >= 0) state.queue.splice(index, 1); @@ -169,10 +235,11 @@ export function providerRequestPacingStatus( const state = pacers.get(providerName); let nextSlotAt = state?.providerNextStartAt ?? 0; if (state && state.queue.length > 0) { - nextSlotAt = Math.min(...state.queue.map(waiter => Math.max( - state.providerNextStartAt, - waiter.modelId ? (state.modelNextStartAt.get(waiter.modelId) ?? 0) : 0, - ))); + let earliestQueuedSlotAt = Number.POSITIVE_INFINITY; + for (const waiter of state.queue) { + earliestQueuedSlotAt = Math.min(earliestQueuedSlotAt, waiterReadyAt(state, waiter.modelId)); + } + if (Number.isFinite(earliestQueuedSlotAt)) nextSlotAt = earliestQueuedSlotAt; } return { provider: providerName, From a60e56e6e876e8116673ccbefac840462ca0f2b6 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:17:00 +0900 Subject: [PATCH 04/13] fix: isolate pacing-only provider updates --- .../provider-workspace/ProviderSettings.tsx | 26 ++++++++++++++----- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/gui/src/components/provider-workspace/ProviderSettings.tsx b/gui/src/components/provider-workspace/ProviderSettings.tsx index 44c7a83241..48151ad290 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -215,13 +215,25 @@ export default function ProviderSettings({ if (pacingEnabled && !pacingDraft.requestsPerMinute && !pacingDraft.minIntervalMs && !pacingDraft.models) { setMsg({ ok: false, text: t("pws.pacingRuleRequired") }); return false; } - const patch: ProviderUpdatePatch = { adapter: adapter.trim(), baseUrl: nextBaseUrl, defaultModel: defaultModel.trim(), authMode, note: note.trim(), allowPrivateNetwork }; - if (pacingDirty) patch.requestPacing = pacingDraft; - // Keep omitted legacy values omitted unless the user actually changes this toggle. - // Otherwise an unrelated settings save manufactures `liveModels: true` provenance. - if (liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels; - if (supportsApiKeyTransport) patch.apiKeyTransport = apiKeyTransport; - else if (item.apiKeyTransport !== undefined) patch.apiKeyTransport = ""; + const pacingOnly = pacingDirty && !dirty; + const patch: ProviderUpdatePatch = pacingOnly + ? { requestPacing: pacingDraft } + : { + adapter: adapter.trim(), + baseUrl: nextBaseUrl, + defaultModel: defaultModel.trim(), + authMode, + note: note.trim(), + allowPrivateNetwork, + ...(pacingDirty ? { requestPacing: pacingDraft } : {}), + }; + if (!pacingOnly) { + // Keep omitted legacy values omitted unless the user actually changes this toggle. + // Otherwise an unrelated settings save manufactures `liveModels: true` provenance. + if (liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels; + if (supportsApiKeyTransport) patch.apiKeyTransport = apiKeyTransport; + else if (item.apiKeyTransport !== undefined) patch.apiKeyTransport = ""; + } const res = await onUpdateProvider(item.name, patch); setMsg(res.ok ? { ok: true, text: t("pws.settingsSaved") } : { ok: false, text: res.error || t("prov.saveFailed") }); return res.ok; From 1bc37f97aba36ec42da3c89ff35ef307869e3404 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:17:18 +0900 Subject: [PATCH 05/13] test: cover pacing-only provider patch --- gui/tests/provider-settings-request-pacing.test.tsx | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/gui/tests/provider-settings-request-pacing.test.tsx b/gui/tests/provider-settings-request-pacing.test.tsx index 530411b9c6..1d12a1d07d 100644 --- a/gui/tests/provider-settings-request-pacing.test.tsx +++ b/gui/tests/provider-settings-request-pacing.test.tsx @@ -67,12 +67,13 @@ test("settings saves provider pacing and a slower model override", async () => { const save = container.querySelector(".pwi-settings-sticky-bar .btn-primary")!; await act(async () => { save.click(); await Promise.resolve(); }); - expect(patches).toHaveLength(1); - expect(patches[0]?.requestPacing).toEqual({ - enabled: true, - requestsPerMinute: 38, - models: { "deepseek-ai/deepseek-v4-flash-0731": { requestsPerMinute: 10 } }, - }); + expect(patches).toEqual([{ + requestPacing: { + enabled: true, + requestsPerMinute: 38, + models: { "deepseek-ai/deepseek-v4-flash-0731": { requestsPerMinute: 10 } }, + }, + }]); expect(container.textContent).toContain("deepseek-ai/deepseek-v4-flash-0731"); await act(async () => { root.unmount(); }); }); From 79a63b8fc8f231dc37b25fbdf0f784b0beb5d377 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:18:03 +0900 Subject: [PATCH 06/13] fix: preserve pacing overload errors --- src/server/responses/upstream-error.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/server/responses/upstream-error.ts b/src/server/responses/upstream-error.ts index 7de21398fb..c7f33fc9e5 100644 --- a/src/server/responses/upstream-error.ts +++ b/src/server/responses/upstream-error.ts @@ -5,7 +5,12 @@ * #553 looking for an adapter URL bug that does not exist. Name the likely cause and the * command that settles it. */ +import { RequestPacingQueueOverloadError } from "../../providers/request-pacing"; + export function describeUpstreamConnectFailure(err: unknown, connectMs: number): string { + // Local pacing admission is not a transport failure. Let the outer response boundary + // preserve its retryable 429 identity instead of laundering it into a 502. + if (err instanceof RequestPacingQueueOverloadError) throw err; if (err instanceof Error && err.name === "TimeoutError") { return `Provider connect timeout after ${connectMs}ms`; } From 8ee17ab504e6e7c8d5253dce3ae8a6a2dd11dba1 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:18:17 +0900 Subject: [PATCH 07/13] fix: exclude pacing overload from transport health --- src/lib/upstream-reachability.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/upstream-reachability.ts b/src/lib/upstream-reachability.ts index 5147f1f695..8a9222d58f 100644 --- a/src/lib/upstream-reachability.ts +++ b/src/lib/upstream-reachability.ts @@ -23,6 +23,7 @@ * MUST stay a leaf module: imports nothing from server.ts or adapters. */ +import { RequestPacingQueueOverloadError } from "../providers/request-pacing"; import { UpstreamRetryEvidenceError } from "./upstream-retry"; export const PRE_CONNECT_REACHABILITY_CODES = new Set([ @@ -68,6 +69,9 @@ export type TransportFailureKind = "timeout" | "connect_neutral" | "connect_erro * account-attributed behavior. */ export function classifyTransportFailureKind(err: unknown): TransportFailureKind { + // A local pacing admission failure happened before transport classification and must + // remain a client-visible overload, not account or host health evidence. + if (err instanceof RequestPacingQueueOverloadError) throw err; const evidence = err instanceof UpstreamRetryEvidenceError ? err : undefined; const rejection = evidence ? evidence.cause : err; if (rejection instanceof Error && rejection.name === "TimeoutError") return "timeout"; From c3aa40bfa73e0ce9aa73815e8e9491c02a88e08f Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:18:24 +0900 Subject: [PATCH 08/13] feat: map pacing overload to retryable 429 --- src/server/responses/pacing-overload.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 src/server/responses/pacing-overload.ts diff --git a/src/server/responses/pacing-overload.ts b/src/server/responses/pacing-overload.ts new file mode 100644 index 0000000000..6ed30b85a7 --- /dev/null +++ b/src/server/responses/pacing-overload.ts @@ -0,0 +1,13 @@ +import { formatErrorResponse } from "../../bridge"; +import { RequestPacingQueueOverloadError } from "../../providers/request-pacing"; + +/** Convert local request-pacing admission failures into a retryable HTTP response. */ +export function requestPacingOverloadResponse(error: unknown): Response | undefined { + if (!(error instanceof RequestPacingQueueOverloadError)) return undefined; + return formatErrorResponse( + 429, + "rate_limit_error", + error.message, + { retryAfter: String(error.retryAfterSeconds) }, + ); +} From de3ab947a9364267576060ec244d7f006f2ff8a6 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:18:48 +0900 Subject: [PATCH 09/13] fix: surface pacing overload as 429 --- src/server/responses/policy-fallback.ts | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/src/server/responses/policy-fallback.ts b/src/server/responses/policy-fallback.ts index 7e9cbd3c74..ff4b176d61 100644 --- a/src/server/responses/policy-fallback.ts +++ b/src/server/responses/policy-fallback.ts @@ -5,6 +5,7 @@ import { finishRequestAttempt, type RequestLogContext } from "../request-log"; import type { OcxConfig } from "../../types"; import type { RouteCandidateTrace, RouteDecisionTraceV1 } from "../../routing/trace"; import { handleResponses as handleResponsesCore } from "./core"; +import { requestPacingOverloadResponse } from "./pacing-overload"; type CoreHandler = typeof handleResponsesCore; type CoreOptions = Parameters[3]; @@ -122,7 +123,14 @@ export async function handleResponsesWithPolicyFallback( // Core owns the client-facing parse/decompression error. } - let response = await runCore(req, config, logCtx, options); + let response: Response; + try { + response = await runCore(req, config, logCtx, options); + } catch (error) { + const overload = requestPacingOverloadResponse(error); + if (overload) return overload; + throw error; + } const initialTrace = logCtx.routeDecision; const initialRequestedModel = logCtx.requestedModel; if (!rawBody || !isPolicyDecision(initialTrace)) return response; @@ -140,7 +148,13 @@ export async function handleResponsesWithPolicyFallback( finishFailedPolicyAttempt(logCtx, response.status); const retryRequest = requestWithCandidate(req, rawBody, next); try { - response = await runCore(retryRequest, config, logCtx, options); + try { + response = await runCore(retryRequest, config, logCtx, options); + } catch (error) { + const overload = requestPacingOverloadResponse(error); + if (overload) return overload; + throw error; + } } finally { logCtx.requestedModel = initialRequestedModel; logCtx.routeDecision = initialTrace; From 36f4fba7a9f57d675cc8611a5bf596739618d1a6 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:19:02 +0900 Subject: [PATCH 10/13] fix: preserve compact pacing overload status --- src/server/responses.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/src/server/responses.ts b/src/server/responses.ts index 172baccbcc..2a446619a2 100644 --- a/src/server/responses.ts +++ b/src/server/responses.ts @@ -1,10 +1,25 @@ // AUTO-SPLIT facade: original responses.ts body moved into ./responses/* modules. // Public surface preserved exactly; importers keep using "src/server/responses". +import { handleResponsesCompact as handleResponsesCompactImpl } from "./responses/compact"; +import { requestPacingOverloadResponse } from "./responses/pacing-overload"; + export { buildToolBridgeMaps, isV1CollabSurface, collabSurface, multiAgentGuidanceText, V2_GUIDANCE_CHAR_BUDGET, injectDeveloperMessage } from "./responses/collaboration"; export type { MultiAgentGuidanceOptions, MultiAgentGuidanceDeps } from "./responses/collaboration"; export { hasUnreadableEncryptedAgentTask, sanitizeEncryptedContentInPlace } from "./responses/encrypted-payload"; -export { COMPACT_RESPONSE_MAX_BYTES, bufferCompactResponse, handleResponsesCompact } from "./responses/compact"; +export { COMPACT_RESPONSE_MAX_BYTES, bufferCompactResponse } from "./responses/compact"; export { disableResponsesRequestTimeout, safeHostLabel, fetchWithHeaderTimeout } from "./responses/fetch-helpers"; export { sidecarOutcomeRecorder, isShadowSourceModel, codexLogAccountId, usesCodexForwardPoolAuth, codexForwardTerminalOutcomeRecorder, decodeRequestErrorResponse, buildComboChildHeaders, linkAbortSignal } from "./responses/core"; export { handleResponses, handleResponsesWithPolicyFallback, rankPolicyFallbackCandidates } from "./responses/policy-fallback"; -export { adapterNeedsForcedContinuation } from "./responses/core"; \ No newline at end of file +export { adapterNeedsForcedContinuation } from "./responses/core"; + +export async function handleResponsesCompact( + ...args: Parameters +): Promise { + try { + return await handleResponsesCompactImpl(...args); + } catch (error) { + const overload = requestPacingOverloadResponse(error); + if (overload) return overload; + throw error; + } +} From 7596cdc7c71af465b03ce061eb75534d66b1503c Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:19:44 +0900 Subject: [PATCH 11/13] testability: expose pacing limits to focused tests --- src/providers/request-pacing.ts | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/src/providers/request-pacing.ts b/src/providers/request-pacing.ts index ee5972699a..d76f12bdde 100644 --- a/src/providers/request-pacing.ts +++ b/src/providers/request-pacing.ts @@ -3,6 +3,9 @@ import type { OcxProviderConfig, RequestPacingRule } from "../types"; export const REQUEST_PACING_MAX_QUEUE_DEPTH = 256; export const REQUEST_PACING_MAX_QUEUE_AGE_MS = 60_000; +let maxQueueDepth = REQUEST_PACING_MAX_QUEUE_DEPTH; +let maxQueueAgeMs = REQUEST_PACING_MAX_QUEUE_AGE_MS; + export type RequestPacingQueueOverloadReason = "queue_full" | "queue_expired"; export class RequestPacingQueueOverloadError extends Error { @@ -98,7 +101,7 @@ function pacingRetryAfterSeconds(state: ProviderPacer, modelId: string | undefin function rejectExpiredWaiters(providerName: string, state: ProviderPacer, now: number): void { for (let index = state.queue.length - 1; index >= 0; index -= 1) { const waiter = state.queue[index]!; - if (now - waiter.queuedAt < REQUEST_PACING_MAX_QUEUE_AGE_MS) continue; + if (now - waiter.queuedAt < maxQueueAgeMs) continue; state.queue.splice(index, 1); if (waiter.abort) waiter.signal?.removeEventListener("abort", waiter.abort); waiter.reject(new RequestPacingQueueOverloadError( @@ -134,7 +137,7 @@ function runQueue(providerName: string, state: ProviderPacer): void { for (const waiter of state.queue) { const modelReadyAt = waiter.modelId ? (state.modelNextStartAt.get(waiter.modelId) ?? 0) : 0; const readyAt = Math.max(providerReadyAt, modelReadyAt); - const expiresAt = waiter.queuedAt + REQUEST_PACING_MAX_QUEUE_AGE_MS; + const expiresAt = waiter.queuedAt + maxQueueAgeMs; earliestAt = Math.min(earliestAt, readyAt, expiresAt); } const delayMs = Math.max(0, earliestAt - now); @@ -192,7 +195,7 @@ export async function waitForProviderRequestSlot( state.timer = undefined; } runQueue(providerName, state); - if (state.queue.length >= REQUEST_PACING_MAX_QUEUE_DEPTH) { + if (state.queue.length >= maxQueueDepth) { throw new RequestPacingQueueOverloadError( providerName, "queue_full", @@ -251,7 +254,17 @@ export function providerRequestPacingStatus( }; } +export function setProviderRequestPacingLimitsForTest(limits: { + maxQueueDepth?: number; + maxQueueAgeMs?: number; +}): void { + if (limits.maxQueueDepth !== undefined) maxQueueDepth = limits.maxQueueDepth; + if (limits.maxQueueAgeMs !== undefined) maxQueueAgeMs = limits.maxQueueAgeMs; +} + export function resetProviderRequestPacingForTest(): void { for (const state of pacers.values()) if (state.timer) clearTimeout(state.timer); pacers.clear(); + maxQueueDepth = REQUEST_PACING_MAX_QUEUE_DEPTH; + maxQueueAgeMs = REQUEST_PACING_MAX_QUEUE_AGE_MS; } From a38bcac13f18a0c3ad03db181446ea62281d2d43 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:20:09 +0900 Subject: [PATCH 12/13] test: cover bounded pacing overload --- tests/request-pacing.test.ts | 44 ++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/request-pacing.test.ts b/tests/request-pacing.test.ts index ff2441822a..7debbc372c 100644 --- a/tests/request-pacing.test.ts +++ b/tests/request-pacing.test.ts @@ -1,12 +1,15 @@ import { afterEach, describe, expect, test } from "bun:test"; import { providerRequestPacingStatus, + RequestPacingQueueOverloadError, requestPacingIntervalMs, resetProviderRequestPacingForTest, + setProviderRequestPacingLimitsForTest, waitForProviderRequestSlot, } from "../src/providers/request-pacing"; import { providerFetch } from "../src/server/responses/fetch-helpers"; import { fetchWithHeaderTimeout } from "../src/server/responses/fetch-helpers"; +import { requestPacingOverloadResponse } from "../src/server/responses/pacing-overload"; import type { OcxProviderConfig } from "../src/types"; afterEach(() => resetProviderRequestPacingForTest()); @@ -73,6 +76,47 @@ describe("provider request pacing queue", () => { expect(providerRequestPacingStatus("demo", configured).queued).toBe(0); }); + test("rejects newest admission when the provider queue is full", async () => { + setProviderRequestPacingLimitsForTest({ maxQueueDepth: 2, maxQueueAgeMs: 5_000 }); + const configured = provider({ enabled: true, minIntervalMs: 1_000 }); + await waitForProviderRequestSlot("demo", configured, "first"); + const controller = new AbortController(); + const queued = [ + waitForProviderRequestSlot("demo", configured, "second", controller.signal), + waitForProviderRequestSlot("demo", configured, "third", controller.signal), + ]; + expect(providerRequestPacingStatus("demo", configured).queued).toBe(2); + await expect(waitForProviderRequestSlot("demo", configured, "newest")).rejects.toMatchObject({ + name: "RequestPacingQueueOverloadError", + reason: "queue_full", + providerName: "demo", + }); + expect(providerRequestPacingStatus("demo", configured).queued).toBe(2); + controller.abort(); + await Promise.allSettled(queued); + }); + + test("expires a queued request at the bounded queued-age deadline", async () => { + setProviderRequestPacingLimitsForTest({ maxQueueAgeMs: 25 }); + const configured = provider({ enabled: true, minIntervalMs: 1_000 }); + await waitForProviderRequestSlot("demo", configured, "first"); + const queued = waitForProviderRequestSlot("demo", configured, "stale"); + expect(providerRequestPacingStatus("demo", configured).queued).toBe(1); + await expect(queued).rejects.toMatchObject({ + name: "RequestPacingQueueOverloadError", + reason: "queue_expired", + providerName: "demo", + }); + expect(providerRequestPacingStatus("demo", configured).queued).toBe(0); + }); + + test("maps pacing admission overload to 429 with Retry-After", async () => { + const response = requestPacingOverloadResponse(new RequestPacingQueueOverloadError("demo", "queue_full", 3)); + expect(response?.status).toBe(429); + expect(response?.headers.get("Retry-After")).toBe("3"); + expect(await response?.json()).toMatchObject({ error: { type: "rate_limit_error" } }); + }); + test("a slow model override does not slow other models beyond the provider interval", async () => { const starts: Array<{ model: string; at: number }> = []; const configured = provider({ From 332406302d8e00c8f1467463af2b05aab4ed3595 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:22:26 +0900 Subject: [PATCH 13/13] test: keep pacing overload out of policy failover --- tests/routing-policy-fallback.test.ts | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/tests/routing-policy-fallback.test.ts b/tests/routing-policy-fallback.test.ts index 8ba1193e1b..dc812c794b 100644 --- a/tests/routing-policy-fallback.test.ts +++ b/tests/routing-policy-fallback.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test"; +import { RequestPacingQueueOverloadError } from "../src/providers/request-pacing"; import type { OcxConfig } from "../src/types"; import { beginRequestAttempt, type RequestLogContext } from "../src/server/request-log"; import type { RouteDecisionTraceV1 } from "../src/routing/trace"; @@ -92,6 +93,21 @@ describe("policy candidate fallback", () => { expect(logCtx.activeAttempt).toBe(logCtx.attempts?.[1]); }); + test("returns local pacing overload without switching policy candidates", async () => { + const trace = policyTrace(); + const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext; + let calls = 0; + const response = await handleResponsesWithPolicyFallback(request(), {} as OcxConfig, logCtx, {}, { + runCore: async () => { + calls += 1; + throw new RequestPacingQueueOverloadError("provider-a", "queue_full", 2); + }, + }); + expect(response.status).toBe(429); + expect(response.headers.get("Retry-After")).toBe("2"); + expect(calls).toBe(1); + }); + test("does not switch candidates for terminal client/input failures", async () => { const trace = policyTrace(); const logCtx = { requestedModel: "policy/daily", routeDecision: trace, attempts: [] } as unknown as RequestLogContext;