From 04b82512f7a4703a276f7663a5e5740333bbce0f Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 12 Aug 2026 15:21:03 +0800 Subject: [PATCH 01/28] feat(codex): publish verified routed service tiers --- .../content/docs/guides/codex-app-models.md | 8 +- .../docs/reference/configuration/providers.md | 1 + src/codex/catalog/aggregation.ts | 5 ++ src/codex/catalog/effort.ts | 9 +++ src/codex/catalog/parsing.ts | 4 +- src/codex/catalog/provider-fetch.ts | 16 +++- src/config.ts | 12 +++ src/providers/derive.ts | 13 +++ src/providers/registry.ts | 2 + src/providers/service-tier.ts | 81 +++++++++++++++++++ src/router.ts | 6 ++ src/routing/capability.ts | 8 +- src/routing/compatibility/behavior.ts | 6 +- src/server/auth-cors.ts | 3 + src/server/management/provider-routes.ts | 24 ++++++ src/server/responses/core.ts | 29 ++++--- src/types.ts | 2 + structure/03_catalog-and-subagents.md | 3 +- structure/04_transports-and-sidecars.md | 10 +++ tests/codex-catalog.test.ts | 22 +++++ tests/management-provider-validation.test.ts | 14 ++++ tests/service-tier-capability.test.ts | 63 +++++++++++++++ 22 files changed, 322 insertions(+), 19 deletions(-) create mode 100644 src/providers/service-tier.ts diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index fedb39f476..0c53c74274 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -200,8 +200,12 @@ But the model catalog and runtime request tier id use `priority`. opencodex pres Native OpenAI passthrough models keep fast support; routed providers are capability-gated — `service_tier` is stripped only when the provider declares `supportsServiceTier: false` (the registry classifies canonical OpenAI as `true`, DeepSeek and Volcengine Ark as `false`), while unclassified -custom gateways keep caller-supplied values untouched and never get an injection. The fast option is -never advertised where it cannot be honored, and custom gateways can opt in explicitly with `true`. +custom gateways keep caller-supplied values untouched and never get an injection. A custom gateway +can opt in globally with `supportsServiceTier: true`, or narrowly with +`modelSupportsServiceTier: { "verified-model": true }`; an exact `false` narrows a provider +default of `true`, while provider-level `false` remains fail-closed. The same final adapter/model +decision controls both catalog metadata and runtime injection, so the fast option is never +advertised where it cannot be honored. ## Subagent selection diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index e4c20b5fc4..8b960b9af2 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -68,6 +68,7 @@ differing backup and rewrites known legacy namespaced selected ids to bare ids. | `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. | | `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. | +| `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. An exact `false` can narrow a provider default of `true`; an explicit provider-level `false` remains fail-closed and cannot be reopened by the map. Only an exact model declared `true` on an `openai-chat` or `openai-responses` route publishes Fast metadata and receives global Fast injection. Unknown models remain unclassified. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | | `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. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | diff --git a/src/codex/catalog/aggregation.ts b/src/codex/catalog/aggregation.ts index 7fb2c04892..a4736ab4b0 100644 --- a/src/codex/catalog/aggregation.ts +++ b/src/codex/catalog/aggregation.ts @@ -177,6 +177,11 @@ export function deriveComboCatalogModel( ...(members.every(member => member.parallelToolCalls === true) ? { parallelToolCalls: true } : {}), + ...(members.every(member => member.supportsServiceTier === true) + ? { supportsServiceTier: true } + : members.some(member => member.supportsServiceTier === false) + ? { supportsServiceTier: false } + : {}), ...(members.some(member => member.supportsReasoningSummaries === false) ? { supportsReasoningSummaries: false } : {}), }; } diff --git a/src/codex/catalog/effort.ts b/src/codex/catalog/effort.ts index 4e1c9fa7fe..e8e334511f 100644 --- a/src/codex/catalog/effort.ts +++ b/src/codex/catalog/effort.ts @@ -139,6 +139,15 @@ export function applyCatalogModelMetadata(entry: RawEntry, model?: CatalogModel) if (typeof model.supportsReasoningSummaries === "boolean") { entry.supports_reasoning_summaries = model.supportsReasoningSummaries; } + if (model.supportsServiceTier === true) { + entry.default_service_tier = null; + entry.service_tiers = [{ + id: "priority", + name: "Fast", + description: "1.5x speed, increased usage", + }]; + entry.additional_speed_tiers = ["fast"]; + } } export function applyReasoningLevels( diff --git a/src/codex/catalog/parsing.ts b/src/codex/catalog/parsing.ts index bf1d165181..705a0d2dbe 100644 --- a/src/codex/catalog/parsing.ts +++ b/src/codex/catalog/parsing.ts @@ -123,6 +123,8 @@ export interface CatalogModel { codexForwardNativeCapabilityAlias?: boolean; /** Whether Codex may send Responses text.verbosity for this routed model. */ supportsVerbosity?: boolean; + /** Whether this exact routed model has a verified OpenAI-compatible service tier. */ + supportsServiceTier?: boolean; supportsReasoningSummaries?: boolean; /** Normalized upstream capability names retained for management/API consumers (#485 follow-up). */ capabilities?: string[]; @@ -405,7 +407,7 @@ export function normalizeRoutedCatalogEntry(entry: RawEntry, parallelToolCalls = delete entry.service_tiers; delete entry.default_service_tier; // Routed rows cloned from native templates must not inherit OpenAI-only summary delivery. - // Per-model routed opt-ins can be added once provider metadata exposes this capability. + // Explicit provider/model metadata is re-applied after this normalization step. delete entry.supports_reasoning_summaries; const isCursorEntry = typeof entry.slug === "string" && entry.slug.startsWith("cursor/"); // `supports_search_tool` selects Codex's deferred tool-discovery surface; it is not the hosted diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index f617f23022..1438a62eec 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -32,6 +32,7 @@ import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; +import { canForwardServiceTierForModel, supportsServiceTierForModel } from "../../providers/service-tier"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; @@ -561,6 +562,7 @@ function providerCatalogFingerprint(name: string, prov: OcxProviderConfig): Reco defRe: prov.modelDefaultReasoningEfforts ?? null, rsSum: prov.modelSupportsReasoningSummaries ?? null, rsDel: prov.modelReasoningSummaryDelivery ?? null, + serviceTier: prov.modelSupportsServiceTier ?? null, noVis: [...(prov.noVisionModels ?? [])].sort(), ptc: prov.parallelToolCalls ?? null, gMode: prov.googleMode ?? null, @@ -632,8 +634,13 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, const reasoningEfforts = configuredReasoningEfforts(prov, model.id); const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort; const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); + const supportsServiceTier = supportsServiceTierForModel(prov, model.id); + const serviceTierSupported = typeof supportsServiceTier === "boolean" + ? canForwardServiceTierForModel(prov, model.id, name) + : undefined; + const { supportsServiceTier: _staleServiceTier, ...modelWithoutServiceTier } = model; const hinted = { - ...model, + ...modelWithoutServiceTier, ...(configuredCap !== undefined ? { contextWindow: typeof model.contextWindow === "number" && model.contextWindow > 0 @@ -652,6 +659,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), + ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier: serviceTierSupported } : {}), ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}), // Default-on for openai-chat providers (explicit false opts out); other adapters // advertise only on explicit opt-in. @@ -1740,6 +1748,7 @@ async function gatherRoutedModelsUncached( const replacedByRoutedSlug = new Map(all.map(model => [routedSlug(model.provider, model.id), model])); const customModels = (config.customModels ?? []).map(cm => { const rawProvider = config.providers[cm.provider]; + const effectiveProvider = enrichedByName.get(cm.provider) ?? rawProvider; // Registry routing backfills an omitted authMode on the built-in OpenAI provider to // forward. Keep the catalog projection on the same contract while still failing closed // for every explicit non-forward mode and every non-canonical endpoint. @@ -1795,6 +1804,11 @@ async function gatherRoutedModelsUncached( // verbatim instead of being replaced by the replaced row's metadata. ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), + ...(effectiveProvider && typeof supportsServiceTierForModel(effectiveProvider, cm.modelId) === "boolean" + ? { + supportsServiceTier: canForwardServiceTierForModel(effectiveProvider, cm.modelId, cm.provider), + } + : {}), }; // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, diff --git a/src/config.ts b/src/config.ts index b71a1a34c7..0d0a624d25 100644 --- a/src/config.ts +++ b/src/config.ts @@ -703,6 +703,7 @@ const providerConfigSchema = z.object({ statelessResponses: z.boolean().optional(), requiresAdjacentResponsesToolResults: z.boolean().optional(), supportsServiceTier: z.boolean().optional(), + modelSupportsServiceTier: z.record(z.string().min(1), z.boolean()).optional(), preserveResponsesReasoningContent: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), noStructuredOutputModels: z.array(z.string().min(1)) @@ -1483,6 +1484,17 @@ const configSchema = z.object({ message: reasoningSummariesError, }); } + const serviceTierModelsError = booleanRecordConfigError( + (provider as { modelSupportsServiceTier?: unknown }).modelSupportsServiceTier, + "modelSupportsServiceTier", + ); + if (serviceTierModelsError) { + ctx.addIssue({ + code: "custom", + path: ["providers", redactSecretString(name), "modelSupportsServiceTier"], + message: serviceTierModelsError, + }); + } const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( (provider as { modelReasoningSummaryDelivery?: unknown }).modelReasoningSummaryDelivery, (provider as { modelSupportsReasoningSummaries?: unknown }).modelSupportsReasoningSummaries, diff --git a/src/providers/derive.ts b/src/providers/derive.ts index e43ed866b9..712e2f020d 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -365,6 +365,17 @@ function applyReasoningSummaryDefaults( }; } +function applyServiceTierModelDefaults( + prov: OcxProviderConfig, + defaults: Readonly> | undefined, +): void { + if (!defaults) return; + prov.modelSupportsServiceTier = { + ...defaults, + ...(prov.modelSupportsServiceTier ?? {}), + }; +} + /** * Last-resort enrichment for a provider whose NAME matches no registry id. * @@ -392,6 +403,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // which vendor endpoint is this row talking to — and is already restricted to fixed key // destinations, so a templated or overridable base URL cannot be claimed by it. enrichReasoningSummariesByDestination(prov); + applyServiceTierModelDefaults(prov, registryEntryForProviderDestination(prov)?.modelSupportsServiceTier); return; } const explicitDirectReasoning: DirectReasoningEffortOverrides = { @@ -440,6 +452,7 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) prov.supportsServiceTier = entry.supportsServiceTier; if (prov.preserveResponsesReasoningContent === undefined && entry.preserveResponsesReasoningContent !== undefined) prov.preserveResponsesReasoningContent = entry.preserveResponsesReasoningContent; applyReasoningSummaryDefaults(prov, entry.modelSupportsReasoningSummaries); + applyServiceTierModelDefaults(prov, entry.modelSupportsServiceTier); // Registry-only repair policy (#938): fill only when the runtime provider has // no explicit policy, and deep-clone so saved/user values never alias the // registry constant. diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 60cce440fe..8c383511f7 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -207,6 +207,8 @@ export interface ProviderRegistryEntry { * (and the canonical openai seed comparison keeps its exact key set). */ supportsServiceTier?: boolean; + /** Registry default for exact model service-tier capability; explicit config keys win. */ + modelSupportsServiceTier?: Record; /** Registry default for plaintext reasoning replay; see `OcxProviderConfig.preserveResponsesReasoningContent`. Registry-only like `supportsServiceTier`. */ preserveResponsesReasoningContent?: boolean; /** Registry defaults for per-model Codex reasoning propagation; explicit user keys win during enrichment. */ diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts new file mode 100644 index 0000000000..5678319a0f --- /dev/null +++ b/src/providers/service-tier.ts @@ -0,0 +1,81 @@ +import type { OcxProviderConfig } from "../types"; +import { MODEL_ADAPTER_OVERRIDE_ALLOWED } from "../types"; +import { providerModelWireDefault, type InboundWire } from "./registry"; + +/** OpenAI-compatible adapters that can carry the standard `service_tier` field. */ +export const SERVICE_TIER_ADAPTERS = new Set(["openai-chat", "openai-responses"]); + +type ServiceTierCapabilityProvider = Pick< + OcxProviderConfig, + "adapter" | "supportsServiceTier" | "modelSupportsServiceTier" | "modelAdapters" | "baseUrl" | "authMode" +>; + +/** + * Read a model map by exact model identity. Service-tier capability is deliberately + * stricter than the older model metadata maps: a family key or a colon-qualified + * fallback must not silently advertise Fast for a sibling model that was never verified. + * A case-insensitive exact match keeps hand-edited ids consistent with the other maps + * without widening the model scope. + */ +function exactModelValue( + record: Record | undefined, + modelId: string, +): T | undefined { + if (!record) return undefined; + if (Object.prototype.hasOwnProperty.call(record, modelId)) return record[modelId]; + const folded = modelId.toLowerCase(); + for (const [key, value] of Object.entries(record)) { + if (key.toLowerCase() === folded) return value; + } + return undefined; +} + +/** + * Resolve the declared provider/model capability. An explicit provider-level false is a + * fail-closed boundary and cannot be reopened by a model map. Otherwise an exact model + * declaration wins over the provider default, including an explicit false. The resolver is + * provider-local: the caller must first resolve the final provider, so identical bare model ids + * on two providers cannot share capability state. + */ +export function supportsServiceTierForModel( + provider: Pick, + modelId: string, +): boolean | undefined { + if (provider.supportsServiceTier === false) return false; + return exactModelValue(provider.modelSupportsServiceTier, modelId) + ?? provider.supportsServiceTier; +} + +/** Resolve an explicit model wire override for catalog-time capability projection. */ +export function serviceTierAdapterForModel( + providerName: string, + provider: Pick, + modelId: string, + inbound: InboundWire = "responses", +): string { + // Keep this lookup identical to resolveWireProtocolOverride(): configured model-adapter + // entries are exact-case keys, while registry defaults intentionally normalize ids there. + const configured = provider.modelAdapters?.[modelId]; + if (configured !== undefined && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)) return configured; + return providerModelWireDefault( + providerName, + provider, + modelId, + MODEL_ADAPTER_OVERRIDE_ALLOWED, + inbound, + ) ?? provider.adapter; +} + +/** Whether the final provider/model pair can actually publish/send OpenAI service tiers. */ +export function canForwardServiceTierForModel( + provider: ServiceTierCapabilityProvider, + modelId: string, + providerName?: string, + inbound: InboundWire = "responses", +): boolean { + const adapter = providerName === undefined + ? provider.adapter + : serviceTierAdapterForModel(providerName, provider, modelId, inbound); + return SERVICE_TIER_ADAPTERS.has(adapter) + && supportsServiceTierForModel(provider, modelId) === true; +} diff --git a/src/router.ts b/src/router.ts index 6d258f2944..7950425c3c 100644 --- a/src/router.ts +++ b/src/router.ts @@ -103,6 +103,7 @@ export function knownModelIdsForProvider(provName: string, prov: OcxProviderConf registry?.modelDefaultReasoningEfforts, registry?.modelReasoningEffortMap, registry?.modelMaxOutputTokens, + registry?.modelSupportsServiceTier, ]) { for (const id of Object.keys(map ?? {})) ids.add(id); } @@ -293,6 +294,10 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ? mergePositiveNumberCaps(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens) : mergeRecordFill(registryEntry.modelMaxInputTokens, provider.modelMaxInputTokens); const modelMaxOutputTokens = mergeRecordFill(registryEntry.modelMaxOutputTokens, provider.modelMaxOutputTokens); + const modelSupportsServiceTier = mergeRecordFill( + registryEntry.modelSupportsServiceTier, + provider.modelSupportsServiceTier, + ); const noVisionModels = mergeStringArray(registryEntry.noVisionModels, provider.noVisionModels); const noReasoningModels = mergeStringArray(registryEntry.noReasoningModels, provider.noReasoningModels); const noTemperatureModels = mergeStringArray(registryEntry.noTemperatureModels, provider.noTemperatureModels); @@ -383,6 +388,7 @@ export function routedProviderConfig(providerName: string, provider: OcxProvider ...(modelInputModalities ? { modelInputModalities } : {}), ...(modelMaxInputTokens ? { modelMaxInputTokens } : {}), ...(modelMaxOutputTokens ? { modelMaxOutputTokens } : {}), + ...(modelSupportsServiceTier ? { modelSupportsServiceTier } : {}), ...(modelReasoningEfforts ? { modelReasoningEfforts } : {}), ...(modelDefaultReasoningEfforts ? { modelDefaultReasoningEfforts } : {}), ...(reasoningEffortMap ? { reasoningEffortMap } : {}), diff --git a/src/routing/capability.ts b/src/routing/capability.ts index b02083f4fd..291b5204fc 100644 --- a/src/routing/capability.ts +++ b/src/routing/capability.ts @@ -12,6 +12,7 @@ import type { OcxConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; +import { SERVICE_TIER_ADAPTERS, supportsServiceTierForModel } from "../providers/service-tier"; import { applyProviderContextCap, providerContextCap } from "../providers/context-cap"; import { PROVIDER_REGISTRY } from "../providers/registry"; import { @@ -185,9 +186,12 @@ export function candidateCapabilityEvidence( ?? catalogRow?.reasoningEfforts ?? (isNative ? nativeReasoningEfforts(modelId) : undefined); - const tierSupport = provider?.supportsServiceTier - ?? registryEntry?.supportsServiceTier; + const tierSupport = (provider ? supportsServiceTierForModel(provider, modelId) : undefined) + ?? (registryEntry ? supportsServiceTierForModel(registryEntry, modelId) : undefined); + const tierAdapter = provider?.adapter ?? registryEntry?.adapter; const serviceTier = tierSupport === true + && tierAdapter !== undefined + && SERVICE_TIER_ADAPTERS.has(tierAdapter) ? "supported" : tierSupport === false ? "unsupported" : "unknown"; diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 9a3f8f2242..1fe8f80ee3 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -1,5 +1,6 @@ import type { OcxConfig, OcxProviderConfig } from "../../types"; import { PROVIDER_REGISTRY, type ProviderAuthKind } from "../../providers/registry"; +import { supportsServiceTierForModel } from "../../providers/service-tier"; import { localFingerprint } from "../../lab/digest"; import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types"; @@ -116,7 +117,10 @@ export function resolveProductionBehaviorValues( "auth.mode": behaviorRow("provider_config", authMode), "auth.transport": behaviorRow("provider_config", authTransportFor(effective, adapter, authMode)), "responses.stateful": behaviorRow("provider_config", effective.statelessResponses !== true), - "responses.serviceTier": behaviorRow("provider_config", effective.supportsServiceTier ?? null), + "responses.serviceTier": behaviorRow( + "provider_config", + supportsServiceTierForModel(effective, modelId) ?? null, + ), "responses.snapshotRepair": behaviorRow("provider_config", effective.responsesSnapshotRepair === true), "responses.itemIdRepair": behaviorRow("provider_config", effective.responsesItemIdRepair ?? null), "limits.contextWindow": behaviorRow( diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index dd7742cc02..eb3bdcf1cc 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -489,6 +489,8 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (maxInputError) return `provider ${name} ${maxInputError}`; const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries"); if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`; + const serviceTierModelsError = booleanRecordConfigError(raw.modelSupportsServiceTier, "modelSupportsServiceTier"); + if (serviceTierModelsError) return `provider ${name} ${serviceTierModelsError}`; const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( raw.modelReasoningSummaryDelivery, raw.modelSupportsReasoningSummaries, @@ -583,6 +585,7 @@ export function safeConfigDTO(config: OcxConfig): unknown { "models", "contextWindow", "modelContextWindows", + "modelSupportsServiceTier", "defaultMaxOutputTokens", "modelMaxOutputTokens", "openRouterRouting", diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 116fee697a..378c70f574 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -216,6 +216,29 @@ function applyProviderPatchFields( } touched = true; } + if (Object.hasOwn(rawBody, "modelSupportsServiceTier")) { + const value = rawBody.modelSupportsServiceTier; + if (value === null) { + delete next.modelSupportsServiceTier; + } else { + if (!isPlainRecord(value)) return { error: "modelSupportsServiceTier must be a plain object or null" }; + const capabilities: Record = { ...(next.modelSupportsServiceTier ?? {}) }; + for (const [model, supported] of Object.entries(value)) { + if (!model.trim()) return { error: "modelSupportsServiceTier keys must be nonblank model ids" }; + if (supported === null) { + delete capabilities[model]; + continue; + } + if (typeof supported !== "boolean") { + return { error: "modelSupportsServiceTier values must be booleans or null" }; + } + capabilities[model] = supported; + } + if (Object.keys(capabilities).length > 0) next.modelSupportsServiceTier = capabilities; + else delete next.modelSupportsServiceTier; + } + touched = true; + } if (Object.hasOwn(rawBody, "noStructuredOutputModels")) { const value = rawBody.noStructuredOutputModels; if (value === null) { @@ -315,6 +338,7 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise).service_tier = tier; @@ -1089,8 +1092,8 @@ async function applyFinalRouteRequestNormalization(args: { } parsed.options.serviceTier = tier; } - applyServiceTierGate(route.provider, parsed._rawBody, parsed.options); - if (route.provider.adapter === "openai-responses" && route.provider.supportsServiceTier === false) { + applyServiceTierGate(route.provider, parsed._rawBody, parsed.options, route.modelId); + if (SERVICE_TIER_ADAPTERS.has(route.provider.adapter) && modelServiceTierSupport === false) { logCtx.requestedServiceTier = undefined; logCtx.requestedSpeedLabel = undefined; } @@ -1144,10 +1147,9 @@ async function applyFinalRouteRequestNormalization(args: { } } recordAttemptRequestedEffort(logCtx); - logCtx.modelSupportsServiceTier = catalogModelSupportsServiceTier( - route.modelId, - logCtx.requestedServiceTier ?? logCtx.configuredServiceTier, - ); + logCtx.modelSupportsServiceTier = SERVICE_TIER_ADAPTERS.has(route.provider.adapter) + ? modelServiceTierSupport + : undefined; } @@ -1487,8 +1489,13 @@ export function applyServiceTierGate( provider: OcxProviderConfig, rawBody: unknown, options: { serviceTier?: string }, + modelId?: string, ): void { - if (provider.adapter !== "openai-responses" || provider.supportsServiceTier !== false) return; + if (!SERVICE_TIER_ADAPTERS.has(provider.adapter)) return; + const support = modelId === undefined + ? provider.supportsServiceTier + : supportsServiceTierForModel(provider, modelId); + if (support !== false) return; if (rawBody && typeof rawBody === "object") { delete (rawBody as Record).service_tier; } diff --git a/src/types.ts b/src/types.ts index f98ed13f81..762ed7d2e0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1318,6 +1318,8 @@ export interface OcxProviderConfig { * An explicit config value always wins over the registry default. */ supportsServiceTier?: boolean; + /** Exact upstream model ids that override the provider-level service-tier capability. */ + modelSupportsServiceTier?: Record; /** * Responses upstream whose native contract accepts plaintext reasoning replay * (DeepSeek documents reasoning items with plaintext content). When set, the diff --git a/structure/03_catalog-and-subagents.md b/structure/03_catalog-and-subagents.md index d37f686965..d3821cd3c1 100644 --- a/structure/03_catalog-and-subagents.md +++ b/structure/03_catalog-and-subagents.md @@ -18,7 +18,8 @@ and all account-selector clones and drop that model family from raw `/v1/models`); - applies exact provider/model compatibility exclusions after live discovery and metadata augmentation, so upstream-advertised but uncallable rows never enter dashboard or Codex pickers; -- strips native-only service tier and WebSocket metadata unless explicitly enabled; +- strips native-only service tier and WebSocket metadata unless the final routed provider/model + explicitly enables the verified OpenAI-compatible service tier; - backs up the pristine catalog once per catalog: the copy is keyed by a hash of the catalog path (`catalog-backup-.json`), and the legacy unsuffixed `catalog-backup.json` is retained in addition for the default catalog, so a restore resolves the backup for the catalog it is restoring diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1db9a6fe1d..d1955ddfbc 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -38,6 +38,16 @@ state. `openai-apikey` uses its configured key and canonical API base URL. Missi within their route; neither route falls through to the other. See [`08_openai-provider-tiers.md`](08_openai-provider-tiers.md). +### Routed service-tier capability + +OpenAI-compatible service-tier support is resolved only after the final provider/model wire is +known. `supportsServiceTier` remains the provider fallback, while the exact +`modelSupportsServiceTier` map can override it per upstream model, including an explicit `false`. +The catalog and request path share this decision: a routed row publishes `service_tiers` only when +the resolved `openai-chat` or `openai-responses` adapter is explicitly capable, and the final-route +normalizer applies the same gate to `service_tier`. Capability is namespaced by the selected provider +and model; model-name similarity and adapter type alone never opt a gateway in. + `POST /v1/responses/compact` handles remote compaction v1 before the generic `/v1/responses` branch and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through to GUI static serving. diff --git a/tests/codex-catalog.test.ts b/tests/codex-catalog.test.ts index 3b4eb00a52..cf586dc801 100644 --- a/tests/codex-catalog.test.ts +++ b/tests/codex-catalog.test.ts @@ -2526,6 +2526,28 @@ describe("Codex catalog routed normalization", () => { expect(routed?.base_instructions).toContain("claude-sonnet-4-6"); expect(routed?.default_reasoning_level).toBe("medium"); }); + + test("buildCatalogEntries restores Fast metadata only for an explicit routed capability", () => { + const entries = buildCatalogEntries(nativeTemplate(), [], [ + { provider: "verified-relay", id: "gpt-5.6-sol", supportsServiceTier: true }, + { provider: "unverified-relay", id: "gpt-5.6-sol" }, + { provider: "blocked-relay", id: "gpt-5.6-sol", supportsServiceTier: false }, + ]); + const verified = entries.find(e => e.slug === "verified-relay/gpt-5.6-sol"); + const unverified = entries.find(e => e.slug === "unverified-relay/gpt-5.6-sol"); + const blocked = entries.find(e => e.slug === "blocked-relay/gpt-5.6-sol"); + + expect(verified?.service_tiers).toEqual([{ + id: "priority", + name: "Fast", + description: "1.5x speed, increased usage", + }]); + expect(verified?.additional_speed_tiers).toEqual(["fast"]); + expect(unverified).not.toHaveProperty("service_tiers"); + expect(unverified).not.toHaveProperty("additional_speed_tiers"); + expect(blocked).not.toHaveProperty("service_tiers"); + expect(blocked).not.toHaveProperty("additional_speed_tiers"); + }); test("buildCatalogEntries advertises parallel tool calls only for Cursor routed models", () => { const entries = buildCatalogEntries(nativeTemplate(), [], [ { provider: "cursor", id: "composer-2.5", owned_by: "cursor" }, diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 3cb411aa2c..d47910ab32 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -2515,9 +2515,11 @@ describe("provider management validation", () => { adapter: "openai-chat", baseUrl: "https://relay.example.test/v1", apiKey: "sk-existing", + allowPrivateNetwork: true, models: ["wide", "narrow"], contextWindow: 256_000, modelContextWindows: { narrow: 64_000 }, + modelSupportsServiceTier: { narrow: false }, }, }, }; @@ -2544,20 +2546,24 @@ describe("provider management validation", () => { expect(rows.find(row => row.name === "relay")).toMatchObject({ contextWindow: 256_000, modelContextWindows: { narrow: 64_000 }, + modelSupportsServiceTier: { narrow: false }, }); const updated = await request("PATCH", { contextWindow: 350_000, modelContextWindows: { wide: 350_000 }, + modelSupportsServiceTier: { wide: true }, }); expect(updated?.status).toBe(200); expect(liveConfig.providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelSupportsServiceTier: { wide: true, narrow: false }, }); expect(loadConfig().providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelSupportsServiceTier: { wide: true, narrow: false }, }); for (const invalid of [ @@ -2570,24 +2576,32 @@ describe("provider management validation", () => { { modelContextWindows: { wide: 1e100 } }, { modelContextWindows: { "": 100_000 } }, { modelContextWindows: { wide: -1 } }, + { modelSupportsServiceTier: { wide: "yes" } }, + { modelSupportsServiceTier: { "": true } }, ]) { expect((await request("PATCH", invalid))?.status).toBe(400); } expect(liveConfig.providers.relay).toMatchObject({ contextWindow: 350_000, modelContextWindows: { wide: 350_000, narrow: 64_000 }, + modelSupportsServiceTier: { wide: true, narrow: false }, }); expect((await request("PATCH", { modelContextWindows: { wide: null } }))?.status).toBe(200); expect(liveConfig.providers.relay.modelContextWindows).toEqual({ narrow: 64_000 }); + expect((await request("PATCH", { modelSupportsServiceTier: { wide: null } }))?.status).toBe(200); + expect(liveConfig.providers.relay.modelSupportsServiceTier).toEqual({ narrow: false }); + const cleared = await request("PATCH", { contextWindow: null, modelContextWindows: null, + modelSupportsServiceTier: null, }); expect(cleared?.status).toBe(200); expect(liveConfig.providers.relay.contextWindow).toBeUndefined(); expect(liveConfig.providers.relay.modelContextWindows).toBeUndefined(); + expect(liveConfig.providers.relay.modelSupportsServiceTier).toBeUndefined(); }); test("provider PATCH manages custom headers with merge and clear semantics", async () => { diff --git a/tests/service-tier-capability.test.ts b/tests/service-tier-capability.test.ts index cec418598d..41a6e67613 100644 --- a/tests/service-tier-capability.test.ts +++ b/tests/service-tier-capability.test.ts @@ -11,6 +11,8 @@ import { providerConfigSeed, enrichProviderFromRegistry } from "../src/providers import { getProviderRegistryEntry } from "../src/providers/registry"; import type { RequestLogContext } from "../src/server/request-log"; import { applyServiceTierGate, handleResponses } from "../src/server/responses/core"; +import { supportsServiceTierForModel } from "../src/providers/service-tier"; +import { serviceTierAdapterForModel } from "../src/providers/service-tier"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; describe("registry capability reaches saved configs without overriding them", () => { @@ -43,6 +45,40 @@ describe("registry capability reaches saved configs without overriding them", () }); }); +describe("service-tier capability is exact-model and provider-scoped", () => { + test("an exact model entry overrides the provider fallback in both directions", () => { + const provider: OcxProviderConfig = { + adapter: "openai-responses", + baseUrl: "https://relay.example.test/v1", + supportsServiceTier: true, + modelSupportsServiceTier: { "gpt-5.6-sol": false, "gpt-5.6-terra": true }, + }; + expect(supportsServiceTierForModel(provider, "gpt-5.6-sol")).toBe(false); + expect(supportsServiceTierForModel(provider, "gpt-5.6-terra")).toBe(true); + expect(supportsServiceTierForModel(provider, "gpt-5.6-luna")).toBe(true); + expect(supportsServiceTierForModel({ + supportsServiceTier: false, + modelSupportsServiceTier: { "gpt-5.6-sol": true }, + }, "gpt-5.6-sol")).toBe(false); + }); + + test("the same bare model id remains independent across providers", () => { + expect(supportsServiceTierForModel({ supportsServiceTier: true }, "same-model")).toBe(true); + expect(supportsServiceTierForModel({ supportsServiceTier: false }, "same-model")).toBe(false); + expect(supportsServiceTierForModel({ modelSupportsServiceTier: { "same-model": true } }, "same-model")).toBe(true); + }); + + test("catalog-time wire resolution follows exact model adapters", () => { + const provider = { + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + modelAdapters: { "responses-model": "openai-responses" }, + } as const; + expect(serviceTierAdapterForModel("custom-relay", provider, "responses-model")).toBe("openai-responses"); + expect(serviceTierAdapterForModel("custom-relay", provider, "chat-model")).toBe("openai-chat"); + }); +}); + describe("applyServiceTierGate fails closed", () => { test("a supported provider is untouched, including a caller-supplied tier", () => { const body = { model: "m", service_tier: "flex" }; @@ -76,6 +112,19 @@ describe("applyServiceTierGate fails closed", () => { applyServiceTierGate({ adapter: "openai-chat", baseUrl: "https://api.deepseek.com" }, body, options); expect(body.service_tier).toBe("priority"); }); + + test("an exact unsupported model strips a caller tier even when the provider default is true", () => { + const body = { model: "gpt-5.6-sol", service_tier: "priority" }; + const options: { serviceTier?: string } = { serviceTier: "priority" }; + applyServiceTierGate({ + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + supportsServiceTier: true, + modelSupportsServiceTier: { "gpt-5.6-sol": false }, + }, body, options, "gpt-5.6-sol"); + expect(body).not.toHaveProperty("service_tier"); + expect(options.serviceTier).toBeUndefined(); + }); }); describe("the gate fires on the live handleResponses path", () => { @@ -175,4 +224,18 @@ describe("the gate fires on the live handleResponses path", () => { const optedIn = await drive("custom-gw", { ...custom(), supportsServiceTier: true }, "some-model", { service_tier: "priority" }); expect(optedIn.service_tier).toBe("priority"); }); + + test("custom Chat provider injects Fast only for an explicitly capable model", async () => { + const custom = (): OcxProviderConfig => ({ + adapter: "openai-chat", + baseUrl: "https://gateway.example.com/v1", + apiKey: "sk-test", + supportsServiceTier: true, + modelSupportsServiceTier: { "verified-model": true, "blocked-model": false }, + }); + const supported = await drive("custom-chat", custom(), "verified-model", {}, true); + expect(supported.service_tier).toBe("priority"); + const blocked = await drive("custom-chat", custom(), "blocked-model", { service_tier: "priority" }, true); + expect(blocked).not.toHaveProperty("service_tier"); + }); }); From 5fc8328aed2b3fc3c2a9c5899579ba9a28e53777 Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 12 Aug 2026 17:19:44 +0800 Subject: [PATCH 02/28] refactor(management): isolate provider capability projection --- src/server/auth-cors.ts | 3 -- src/server/management/config-routes.ts | 3 +- .../management/provider-capability-config.ts | 48 +++++++++++++++++++ src/server/management/provider-routes.ts | 10 ++++ tests/management-provider-validation.test.ts | 32 +++++++++++++ 5 files changed, 92 insertions(+), 4 deletions(-) create mode 100644 src/server/management/provider-capability-config.ts diff --git a/src/server/auth-cors.ts b/src/server/auth-cors.ts index eb3bdcf1cc..dd7742cc02 100644 --- a/src/server/auth-cors.ts +++ b/src/server/auth-cors.ts @@ -489,8 +489,6 @@ export function providerManagementConfigError(name: unknown, provider: unknown): if (maxInputError) return `provider ${name} ${maxInputError}`; const reasoningSummariesError = booleanRecordConfigError(raw.modelSupportsReasoningSummaries, "modelSupportsReasoningSummaries"); if (reasoningSummariesError) return `provider ${name} ${reasoningSummariesError}`; - const serviceTierModelsError = booleanRecordConfigError(raw.modelSupportsServiceTier, "modelSupportsServiceTier"); - if (serviceTierModelsError) return `provider ${name} ${serviceTierModelsError}`; const reasoningSummaryDeliveryError = reasoningSummaryDeliveryRecordConfigError( raw.modelReasoningSummaryDelivery, raw.modelSupportsReasoningSummaries, @@ -585,7 +583,6 @@ export function safeConfigDTO(config: OcxConfig): unknown { "models", "contextWindow", "modelContextWindows", - "modelSupportsServiceTier", "defaultMaxOutputTokens", "modelMaxOutputTokens", "openRouterRouting", diff --git a/src/server/management/config-routes.ts b/src/server/management/config-routes.ts index 2e171587be..82ab4c96cb 100644 --- a/src/server/management/config-routes.ts +++ b/src/server/management/config-routes.ts @@ -85,6 +85,7 @@ import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from ". import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; +import { withProviderServiceTierDTO } from "./provider-capability-config"; import { applySystemEnvToggle } from "../system-env"; import { getCachedStartupHealth, invalidateStartupHealthCache } from "../startup-health-cache"; import { runWindowsTrayAction } from "../windows-tray-control"; @@ -136,7 +137,7 @@ export async function handleConfigRoutes(ctx: ManagementContext): Promise | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined; + const entries = Object.entries(value).filter(([model, supported]) => + model.trim().length > 0 && typeof supported === "boolean", + ); + return Object.fromEntries(entries) as Record; +} + +/** + * Add the provider editor's capability map to the already secret-free config + * DTO. `safeConfigDTO` remains the owner of auth/cors redaction; this helper + * only projects a boolean model capability used by the management UI. + */ +export function withProviderServiceTierDTO(dto: unknown, config: OcxConfig): unknown { + if (!dto || typeof dto !== "object" || Array.isArray(dto)) return dto; + const root = dto as { providers?: unknown }; + if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) return dto; + + const providers = root.providers as Record; + const projectedProviders: Record = { ...providers }; + for (const [name, provider] of Object.entries(config.providers)) { + const dtoProvider = providers[name]; + if (!dtoProvider || typeof dtoProvider !== "object" || Array.isArray(dtoProvider)) continue; + const capabilities = publicServiceTierRecord(provider.modelSupportsServiceTier); + if (capabilities === undefined) continue; + projectedProviders[name] = { ...(dtoProvider as Record), modelSupportsServiceTier: capabilities }; + } + return { ...root, providers: projectedProviders }; +} diff --git a/src/server/management/provider-routes.ts b/src/server/management/provider-routes.ts index 378c70f574..742de592b5 100644 --- a/src/server/management/provider-routes.ts +++ b/src/server/management/provider-routes.ts @@ -71,6 +71,7 @@ import { filterRequestLogs, getRequestLogEntries, type RequestLogEntry } from ". import { estimateComboCost, estimateRequestCost, normalizeCostTokens, tokensPerSecond } from "../../usage/cost"; import type { PersistedUsageAttempt } from "../../usage/log"; import { isAllowedRequestOrigin, jsonResponse, providerManagementConfigError, publicProviderBaseUrl, safeConfigDTO } from "../auth-cors"; +import { providerServiceTierConfigError } from "./provider-capability-config"; import { applySystemEnvToggle } from "../system-env"; import { LOCAL_PROVIDER_RELOAD_NAME_HEADER, @@ -429,6 +430,8 @@ export async function handleProviderRoutes(ctx: ManagementContext): Promise { } finally { resolvedError.mockRestore(); } + test("service-tier validation and public projection stay in the management boundary", () => { + expect(providerServiceTierConfigError("relay", { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + modelSupportsServiceTier: { verified: true, blocked: false }, + })).toBeNull(); + expect(providerServiceTierConfigError("relay", { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + modelSupportsServiceTier: { verified: "yes" }, + })).toContain("modelSupportsServiceTier.verified must be a boolean"); + + const config = { + providers: { + relay: { + adapter: "openai-chat", + baseUrl: "https://relay.example/v1", + apiKey: "sk-never-project", + modelSupportsServiceTier: { verified: true }, + }, + }, + } as unknown as OcxConfig; + const dto = withProviderServiceTierDTO( + { providers: { relay: { hasApiKey: true } } }, + config, + ) as { providers: { relay: Record } }; + expect(dto.providers.relay).toMatchObject({ + hasApiKey: true, + modelSupportsServiceTier: { verified: true }, + }); + expect(JSON.stringify(dto)).not.toContain("sk-never-project"); }); test("validates and exposes structured-output model opt-outs", () => { From eedf039f9c87e73ac1756070c93d342491cd8d58 Mon Sep 17 00:00:00 2001 From: Kartik Date: Fri, 14 Aug 2026 07:31:20 +0530 Subject: [PATCH 03/28] feat(responses): guard empty completions with one identical-turn retry Port codex-router PR #145: a 200 that completes with no output text and no tool call is a failure the client cannot see (it silently records the turn as done). Hold pre-content adapter events, suppress the terminal of an empty turn, retry the IDENTICAL request once, and surface empty_completion_retry_failed when the retry is also empty or fails upstream. Usage is merged across both attempts so the request log meters the whole turn. Kill switch: OCX_EMPTY_COMPLETION_RETRY=0 restores the previous behavior. Compaction turns and combo attempts are excluded. --- src/server/responses/core.ts | 88 ++++- .../responses/empty-completion-guard.ts | 239 +++++++++++++ tests/empty-completion-guard.test.ts | 329 ++++++++++++++++++ 3 files changed, 646 insertions(+), 10 deletions(-) create mode 100644 src/server/responses/empty-completion-guard.ts create mode 100644 tests/empty-completion-guard.test.ts diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 0b21ba180b..513c0fddba 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -79,7 +79,7 @@ import { import { buildWebSearchTool, planWebSearch, runWithWebSearch, shouldResolveOpenAiWebSearchSidecar } from "../../web-search"; import { buildImageTool, buildVideoTool, planImageBridge, planVideoBridge, runWithImageBridge, clampImageMaxRounds, IMAGE_GEN_TOOL_NAME, VIDEO_GEN_TOOL_NAME } from "../../images"; import { describeImagesInPlace, isModelTextOnly, planVisionSidecar, resolveOpenAiVisionModel, shouldResolveOpenAiVisionSidecar, stripImagesInPlace } from "../../vision"; -import { createAdapterEventQueue, preflightAdapterEvents } from "../../adapters/run-turn-queue"; +import { createAdapterEventQueue, preflightAdapterEvents, type AdapterEventQueue } from "../../adapters/run-turn-queue"; import { applyCodexAuthContextToProvider, CodexAccountCooldownError, @@ -236,6 +236,10 @@ import { createRoutedCustomToolRestoreBlockRewrite } from "../responses-custom-t import { createGithubCopilotResponsesBlockRewrite } from "../github-copilot-responses-repair"; import { responsesJsonToSseStream } from "../responses-json-events"; import { guardTerminalEventStream } from "./terminal-guard"; +import { + emptyCompletionRetryEnabled, + guardEmptyCompletionEventStream, +} from "./empty-completion-guard"; /** * Adapters whose continuation state must survive Codex's store:false requests. @@ -3133,22 +3137,37 @@ async function handleResponsesInner( return wsResponse; } + // Empty-completion guard (codex-router PR #145 port): a 200 that completes with no output + // text and no tool call is a failure the client cannot see — it silently records the turn as + // done. The guard holds pre-content adapter events, suppresses the terminal of an empty + // turn, retries the IDENTICAL request once, and surfaces a stated error when the retry is + // empty or fails. Kill switch: OCX_EMPTY_COMPLETION_RETRY=0. Compaction turns and combo + // attempts keep their own machinery (the combo preflight already handles empty streams). + const emptyCompletionGuardEnabled = + emptyCompletionRetryEnabled() + && !options.comboAttempt + && !routedCompaction; + if (adapter.runTurn) { const runTurnAbort = new AbortController(); linkAbortSignal(runTurnAbort, options.abortSignal); const queue = createAdapterEventQueue({ onBacklogExceeded: () => runTurnAbort.abort(), }); - const runTurn = async (): Promise => { + // One attempt of the runTurn transport, against an explicit queue. The + // empty-completion guard re-invokes the IDENTICAL turn (same parsed request, + // same forwarded headers, same abort signal) through a fresh queue, so the + // attempt body must not capture the first queue. + const runTurnAttempt = async (targetQueue: AdapterEventQueue): Promise => { try { noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens); await adapter.runTurn?.( parsed, { headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal, translatorBudget }, - queue.push, + targetQueue.push, ); } catch (err) { - queue.push({ + targetQueue.push({ type: "error", message: err instanceof Error ? err.message : String(err), }); @@ -3158,9 +3177,20 @@ async function handleResponsesInner( if (!logCtx.conversationId && parsed._cursorConversationId) { logCtx.conversationId = normalizeLogConversationId(parsed._cursorConversationId); } - queue.close(); + targetQueue.close(); } }; + const runTurn = async (): Promise => runTurnAttempt(queue); + // The empty-completion retry re-runs the turn against a fresh queue: the + // first queue is closed once its attempt settles, and pushing into it after + // close is a silent no-op. + const runTurnRetrySource = (): AsyncIterable => { + const retryQueue = createAdapterEventQueue({ + onBacklogExceeded: () => runTurnAbort.abort(), + }); + void runTurnAttempt(retryQueue); + return retryQueue.stream(); + }; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; if (parsed.stream) { @@ -3176,8 +3206,16 @@ async function handleResponsesInner( } eventSource = preflight.stream; } + const guardedSource = emptyCompletionGuardEnabled + ? guardEmptyCompletionEventStream({ + firstEvents: eventSource, + // Identical-turn retry: same parsed request, same headers, same + // signal — run the adapter transport again against a fresh queue. + continuation: runTurnRetrySource, + }) + : eventSource; const sseStream = bridgeToResponsesSSE( - eventSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + guardedSource, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => { runTurnAbort.abort(); queue.close(); @@ -3220,7 +3258,17 @@ async function handleResponsesInner( } await runTurn(); - const events = await queue.collect(); + const firstAttemptEvents = await queue.collect(); + let events: AdapterEvent[]; + if (emptyCompletionGuardEnabled) { + events = []; + for await (const event of guardEmptyCompletionEventStream({ + firstEvents: (async function* () { yield* firstAttemptEvents; })(), + continuation: runTurnRetrySource, + })) events.push(event); + } else { + events = firstAttemptEvents; + } if (options.comboAttempt) { const firstMeaningful = events.find(event => event.type !== "heartbeat"); if (!firstMeaningful || firstMeaningful.type === "error") { @@ -3932,9 +3980,19 @@ async function handleResponsesInner( continuation: fetchTerminalGuardContinuation, }) : initialEventStream; + // The empty-completion guard sits OUTSIDE the terminal guard: a completed + // turn with no text and no tool call is retried with the IDENTICAL request + // (fetchTerminalGuardContinuation(parsed) replays the cached byte-identical + // request — same body, same headers, same signal). + const guardedEventStream = emptyCompletionGuardEnabled + ? guardEmptyCompletionEventStream({ + firstEvents: eventStream, + continuation: () => fetchTerminalGuardContinuation(parsed), + }) + : eventStream; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; const sseStream = bridgeToResponsesSSE( - eventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, + guardedEventStream, parsed._responseModelId ?? parsed.modelId, toolNsMap, freeformToolNames, toolSearchToolNames, () => upstream.abort(), 2_000, { translatorBudget, @@ -3979,17 +4037,27 @@ async function handleResponsesInner( let events: AdapterEvent[]; try { const initialEvents = await activeAdapter.parseResponse(upstreamResponse, translatorBudget); + let guardedEvents: AdapterEvent[]; if (terminalGuardEnabled) { - events = []; + guardedEvents = []; for await (const event of guardTerminalEventStream({ parsed, firstEvents: (async function* () { yield* initialEvents; })(), adapterName: activeAdapter.name, maxAutoContinuations: 1, continuation: fetchTerminalGuardContinuation, + })) guardedEvents.push(event); + } else { + guardedEvents = initialEvents; + } + if (emptyCompletionGuardEnabled) { + events = []; + for await (const event of guardEmptyCompletionEventStream({ + firstEvents: (async function* () { yield* guardedEvents; })(), + continuation: () => fetchTerminalGuardContinuation(parsed), })) events.push(event); } else { - events = initialEvents; + events = guardedEvents; } } finally { cleanupUpstreamAbort(); diff --git a/src/server/responses/empty-completion-guard.ts b/src/server/responses/empty-completion-guard.ts new file mode 100644 index 0000000000..d113a3af10 --- /dev/null +++ b/src/server/responses/empty-completion-guard.ts @@ -0,0 +1,239 @@ +import type { AdapterEvent, OcxUsage } from "../../types"; + +/** + * Empty-completion guard for Responses turns (port of codex-router's + * empty-completion-guard + single retry, PR #145). + * + * Failure mode: the upstream answers 200 and completes the turn but never + * produced output text or a tool call (a reasoning-only stream that ends with + * nothing is the canonical shape). The client has no code path for "the model + * said nothing", so it silently records the turn as done — the "random stop" + * nobody can explain. The guard holds pre-content events (reasoning deltas + * are deliberately NOT content), and when a terminal event arrives with no + * content it suppresses the terminal and retries the IDENTICAL turn once + * (same request bytes, same headers). If the retry is also empty — or fails + * upstream — the client sees a stated failure instead of a second silent + * success. + * + * Kill switch on the pattern of the router's CODEX_ROUTER_EMPTY_COMPLETION_RETRY: + * setting OCX_EMPTY_COMPLETION_RETRY=0 turns the guard off entirely and + * restores the previous relay behavior exactly. + */ +export const EMPTY_COMPLETION_RETRY_ENV = "OCX_EMPTY_COMPLETION_RETRY"; + +export function emptyCompletionRetryEnabled( + env: Record = process.env, +): boolean { + // "0" disables; anything else (including unset) enables — same contract as + // the router's CODEX_ROUTER_EMPTY_COMPLETION_RETRY. + const configured = env[EMPTY_COMPLETION_RETRY_ENV]; + return configured !== "0"; +} + +/** Surfaced when the single retry was also empty or failed upstream. */ +export const EMPTY_COMPLETION_RETRY_FAILED_CODE = "empty_completion_retry_failed"; + +/** + * Terminal stop reasons the bridge renders as a visible `response.incomplete` + * (max_tokens / content_filter). Those are already a stated failure, not the + * silent empty success this guard exists to catch, and retrying the identical + * request would burn tokens for the same truncated result. + */ +const VISIBLE_INCOMPLETE_STOP_REASONS: Record = { + max_tokens: true, + content_filter: true, +}; + +/** + * Content means something the client can act on: output text or a tool call + * (web-search cells included). Reasoning deltas are deliberately not content — + * a turn that streams only reasoning and then completes with nothing is + * exactly the empty completion this guard exists to catch. Empty text deltas + * (some batch adapters always carry `""`) are not content either. + */ +export function isContentEvent(event: AdapterEvent): boolean { + switch (event.type) { + case "text_delta": + return event.text.length > 0; + case "tool_call_start": + case "tool_call_delta": + case "tool_call_end": + case "web_search_call_begin": + case "web_search_call_end": + return true; + default: + return false; + } +} + +export function emptyCompletionRetryFailedEvent( + usage?: OcxUsage, + retryFailedUpstream = false, +): Extract { + return { + type: "error", + status: 502, + errorType: "upstream_error", + code: EMPTY_COMPLETION_RETRY_FAILED_CODE, + message: retryFailedUpstream + ? "The model returned an empty completion and the retry failed upstream." + : "The model returned an empty completion. opencodex retried once and the completion was empty again.", + ...(usage ? { usage } : {}), + }; +} + +/** + * Sum two usage snapshots. Same semantics as terminal-guard's mergeUsage and + * request-log's aggregateAttemptUsage: token totals add across the attempts; + * `estimated` wins when either attempt only estimated. + */ +export function mergeUsage( + first: OcxUsage | undefined, + second: OcxUsage | undefined, +): OcxUsage | undefined { + if (!first) return second; + if (!second) return first; + const sumOptional = (key: keyof OcxUsage): number | undefined => { + const left = first[key]; + const right = second[key]; + return typeof left === "number" || typeof right === "number" + ? (typeof left === "number" ? left : 0) + (typeof right === "number" ? right : 0) + : undefined; + }; + const cachedInputTokens = sumOptional("cachedInputTokens"); + const cacheReadInputTokens = sumOptional("cacheReadInputTokens"); + const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens"); + const reasoningOutputTokens = sumOptional("reasoningOutputTokens"); + const inputTokens = first.inputTokens + second.inputTokens; + const outputTokens = first.outputTokens + second.outputTokens; + return { + inputTokens, + outputTokens, + totalTokens: inputTokens + outputTokens, + ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), + ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}), + ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}), + ...(reasoningOutputTokens !== undefined ? { reasoningOutputTokens } : {}), + ...(first.estimated || second.estimated ? { estimated: true } : {}), + }; +} + +export interface EmptyCompletionGuardOptions { + firstEvents: AsyncIterable; + /** + * Re-run the IDENTICAL turn: same request body, same headers, same signal. + * Receives no arguments — the request must not be modified between attempts. + */ + continuation: () => AsyncIterable | Promise>; + /** How many times an empty completion is retried; default 1 (the router's single retry). */ + maxRetries?: number; +} + +/** + * Watch an adapter event stream for the empty-completion failure mode. Events + * are held until the turn produces content or ends: reasoning and other + * pre-content events stay buffered (released in order on first content), the + * terminal is withheld, and an empty terminal triggers one identical-turn + * retry through `continuation`. Usage is merged across attempts so the bridge + * and request log meter the whole turn, not just the attempt that succeeded. + * + * Heartbeats always pass through untouched: they feed the bridge's stall + * watchdog, so holding them behind the content gate would trip false + * upstream_stall_timeout failures on slow reasoning-only turns. + */ +export async function* guardEmptyCompletionEventStream( + options: EmptyCompletionGuardOptions, +): AsyncGenerator { + const maxRetries = Math.max(0, Math.floor(options.maxRetries ?? 1)); + let source = options.firstEvents; + let held: AdapterEvent[] = []; + let sawContent = false; + let retries = 0; + let usage: OcxUsage | undefined; + + const withUsage = (event: AdapterEvent & { usage?: OcxUsage }): AdapterEvent => { + const merged = mergeUsage(usage, event.usage); + return merged ? { ...event, ...(merged ? { usage: merged } : {}) } : event; + }; + const releaseHeld = (): AdapterEvent[] => { + const released = held; + held = []; + return released; + }; + + while (true) { + let terminalSeen = false; + for await (const event of source) { + if (event.type === "heartbeat") { + yield event; + continue; + } + if (sawContent) { + // Buffered content is already flowing; everything downstream passes + // through. The final done carries usage merged across every attempt. + yield event.type === "done" ? withUsage(event) : event; + continue; + } + if (isContentEvent(event)) { + sawContent = true; + yield* releaseHeld(); + yield event; + continue; + } + if (event.type === "done") { + usage = mergeUsage(usage, event.usage); + if (VISIBLE_INCOMPLETE_STOP_REASONS[event.stopReason ?? ""]) { + // Rendered as response.incomplete: a stated failure, not the silent + // empty success this guard exists to catch. + yield* releaseHeld(); + yield { ...event, ...(usage ? { usage } : {}) }; + return; + } + if (retries < maxRetries) { + // Suppress the terminal: the client must never see a completed event + // for a turn that produced nothing. Retry the identical turn. + retries += 1; + try { + source = await options.continuation(); + } catch { + yield emptyCompletionRetryFailedEvent(usage); + return; + } + terminalSeen = true; + break; + } + // The retry was also empty: a stated failure, not a second silent + // success. + yield emptyCompletionRetryFailedEvent(usage); + return; + } + if (event.type === "error") { + if (retries > 0 && event.status !== 499) { + // The retry failed upstream. Its body cannot reach the client (the + // 200 head went out with the first attempt), so state the failure in + // the stream's own error framing — same move as the router's + // empty_completion_retry_failed. Client cancels (499) pass through. + yield emptyCompletionRetryFailedEvent(mergeUsage(usage, event.usage), true); + return; + } + yield* releaseHeld(); + yield withUsage(event); + return; + } + if (event.type === "incomplete") { + // A structured incomplete is already a visible failure; never convert + // it into an empty completion. + yield* releaseHeld(); + yield withUsage(event); + return; + } + held.push(event); + } + if (!terminalSeen) { + // The source ended without a terminal event (truncated stream). Release + // what was held so the bridge can mark the stream incomplete. + yield* releaseHeld(); + return; + } + } +} diff --git a/tests/empty-completion-guard.test.ts b/tests/empty-completion-guard.test.ts new file mode 100644 index 0000000000..26a709c590 --- /dev/null +++ b/tests/empty-completion-guard.test.ts @@ -0,0 +1,329 @@ +import { describe, expect, test } from "bun:test"; +import { + EMPTY_COMPLETION_RETRY_ENV, + EMPTY_COMPLETION_RETRY_FAILED_CODE, + emptyCompletionRetryEnabled, + guardEmptyCompletionEventStream, + isContentEvent, +} from "../src/server/responses/empty-completion-guard"; +import type { AdapterEvent } from "../src/types"; + +function collect(source: AsyncIterable): Promise { + const events: AdapterEvent[] = []; + return (async () => { + for await (const event of source) events.push(event); + return events; + })(); +} + +function eventsOf(...items: AdapterEvent[]): AsyncIterable { + return (async function* () { yield* items; })(); +} + +describe("empty-completion guard content classification", () => { + test("output text is content", () => { + expect(isContentEvent({ type: "text_delta", text: "hello" })).toBe(true); + }); + + test("tool calls are content", () => { + expect(isContentEvent({ type: "tool_call_start", id: "c1", name: "run" })).toBe(true); + expect(isContentEvent({ type: "tool_call_delta", arguments: "{}" })).toBe(true); + expect(isContentEvent({ type: "tool_call_end" })).toBe(true); + expect(isContentEvent({ type: "web_search_call_begin", id: "w1" })).toBe(true); + expect(isContentEvent({ type: "web_search_call_end", id: "w1", queries: [] })).toBe(true); + }); + + test("reasoning alone is NOT content", () => { + expect(isContentEvent({ type: "thinking_delta", thinking: "let me think" })).toBe(false); + expect(isContentEvent({ type: "thinking_signature", signature: "sig" })).toBe(false); + expect(isContentEvent({ type: "redacted_thinking", data: "blob" })).toBe(false); + expect(isContentEvent({ type: "reasoning_raw_delta", text: "raw" })).toBe(false); + }); + + test("empty text deltas are not content", () => { + expect(isContentEvent({ type: "text_delta", text: "" })).toBe(false); + }); + + test("heartbeats and internal boundaries are not content", () => { + expect(isContentEvent({ type: "heartbeat" })).toBe(false); + expect(isContentEvent({ type: "assistant_boundary" })).toBe(false); + }); +}); + +describe("empty-completion guard kill switch", () => { + test("enabled by default, disabled by OCX_EMPTY_COMPLETION_RETRY=0", () => { + expect(emptyCompletionRetryEnabled({})).toBe(true); + expect(emptyCompletionRetryEnabled({ [EMPTY_COMPLETION_RETRY_ENV]: "1" })).toBe(true); + expect(emptyCompletionRetryEnabled({ [EMPTY_COMPLETION_RETRY_ENV]: "0" })).toBe(false); + }); +}); + +describe("empty-completion guard retry", () => { + test("buffers pre-content events and releases them on first content", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "thinking..." }, + { type: "text_delta", text: "answer" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 2 } }, + ), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events).toEqual([ + { type: "thinking_delta", thinking: "thinking..." }, + { type: "text_delta", text: "answer" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 2 } }, + ]); + }); + + test("a reasoning-only terminal turn is retried once and the identical-turn retry succeeds", async () => { + let continuations = 0; + const retryParsedSeen: string[] = []; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "first attempt" }, + { type: "reasoning_raw_delta", text: "raw" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 0 } }, + ), + continuation: () => { + continuations += 1; + return eventsOf( + { type: "thinking_delta", thinking: "second attempt" }, + { type: "text_delta", text: "finally an answer" }, + { type: "done", usage: { inputTokens: 20, outputTokens: 5 } }, + ); + }, + })); + + expect(continuations).toBe(1); + expect(retryParsedSeen).toEqual([]); + // The first attempt's buffered reasoning is released in order, then the + // retry's reasoning, then the content, then the merged-usage terminal. + expect(events).toEqual([ + { type: "thinking_delta", thinking: "first attempt" }, + { type: "reasoning_raw_delta", text: "raw" }, + { type: "thinking_delta", thinking: "second attempt" }, + { type: "text_delta", text: "finally an answer" }, + { type: "done", usage: { inputTokens: 30, outputTokens: 5, totalTokens: 35 } }, + ]); + }); + + test("usage is merged across attempts", async () => { + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "..." }, + { type: "done", usage: { inputTokens: 100, outputTokens: 0, cachedInputTokens: 40 } }, + ), + continuation: () => eventsOf( + { type: "tool_call_start", id: "c1", name: "run" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done", usage: { inputTokens: 200, outputTokens: 30, reasoningOutputTokens: 12 } }, + ), + })); + + const done = events.at(-1) as Extract; + expect(done.usage).toEqual({ + inputTokens: 300, + outputTokens: 30, + totalTokens: 330, + cachedInputTokens: 40, + reasoningOutputTokens: 12, + }); + }); + + test("both attempts empty surfaces empty_completion_retry_failed", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "first" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 0 } }, + ), + continuation: () => { + continuations += 1; + return eventsOf( + { type: "thinking_delta", thinking: "second" }, + { type: "done", usage: { inputTokens: 12, outputTokens: 0 } }, + ); + }, + })); + + expect(continuations).toBe(1); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + status: 502, + errorType: "upstream_error", + code: EMPTY_COMPLETION_RETRY_FAILED_CODE, + usage: { inputTokens: 22, outputTokens: 0, totalTokens: 22 }, + }); + // The silent completed event never reaches the client. + expect(events.some(event => event.type === "done")).toBe(false); + }); + + test("a failed retry surfaces empty_completion_retry_failed", async () => { + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "first" }, + { type: "done", usage: { inputTokens: 10, outputTokens: 0 } }, + ), + continuation: () => eventsOf( + { type: "error", status: 502, message: "upstream died", usage: { inputTokens: 8, outputTokens: 0 } }, + ), + })); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + status: 502, + errorType: "upstream_error", + code: EMPTY_COMPLETION_RETRY_FAILED_CODE, + usage: { inputTokens: 18, outputTokens: 0, totalTokens: 18 }, + }); + }); + + test("a continuation that throws surfaces empty_completion_retry_failed", async () => { + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf({ type: "done" }), + continuation: () => { + throw new Error("continuation exploded"); + }, + })); + + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ + type: "error", + code: EMPTY_COMPLETION_RETRY_FAILED_CODE, + }); + }); + + test("max_tokens completions pass through without retrying", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "..." }, + { type: "done", stopReason: "max_tokens", usage: { inputTokens: 5, outputTokens: 1 } }, + ), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events).toEqual([ + { type: "thinking_delta", thinking: "..." }, + { type: "done", stopReason: "max_tokens", usage: { inputTokens: 5, outputTokens: 1 } }, + ]); + }); + + test("a tool-call-only turn is content and is not retried", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "tool_call_start", id: "c1", name: "exec_command" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done" }, + ), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events.map(event => event.type)).toEqual([ + "tool_call_start", "tool_call_delta", "tool_call_end", "done", + ]); + }); + + test("first-attempt errors and incompletes pass through untouched", async () => { + let continuations = 0; + const incomplete = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "..." }, + { type: "incomplete", reason: "content_filter", retryable: false }, + ), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + expect(continuations).toBe(0); + expect(incomplete).toEqual([ + { type: "thinking_delta", thinking: "..." }, + { type: "incomplete", reason: "content_filter", retryable: false }, + ]); + + const error = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "error", status: 401, message: "bad key" }, + ), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + expect(continuations).toBe(0); + expect(error).toEqual([{ type: "error", status: 401, message: "bad key" }]); + }); + + test("maxRetries 0 (kill-switch behavior) surfaces the failure immediately", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "..." }, + { type: "done" }, + ), + maxRetries: 0, + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events).toHaveLength(1); + expect(events[0]).toMatchObject({ type: "error", code: EMPTY_COMPLETION_RETRY_FAILED_CODE }); + }); + + test("heartbeats pass through immediately even before content", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "heartbeat" }, + { type: "thinking_delta", thinking: "..." }, + { type: "done" }, + ), + continuation: () => { + continuations += 1; + return eventsOf({ type: "text_delta", text: "ok" }, { type: "done" }); + }, + })); + + expect(continuations).toBe(1); + expect(events.map(event => event.type)).toEqual([ + "heartbeat", "thinking_delta", "text_delta", "done", + ]); + }); + + test("a truncated first source (no terminal) releases held events and ends", async () => { + let continuations = 0; + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf({ type: "thinking_delta", thinking: "..." }), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events).toEqual([{ type: "thinking_delta", thinking: "..." }]); + }); +}); From 6f295f9513a618577451ba9d6bbf8775a983ee0c Mon Sep 17 00:00:00 2001 From: Yuxin Qiao <104957188+Yuxin-Qiao@users.noreply.github.com> Date: Wed, 12 Aug 2026 18:43:12 +0800 Subject: [PATCH 04/28] fix(codex): gate service tier on final adapter --- src/providers/service-tier.ts | 18 ++++++++++++++-- src/server/responses/core.ts | 30 +++++++++++++++++++++------ tests/service-tier-capability.test.ts | 19 ++++++++++++++++- 3 files changed, 58 insertions(+), 9 deletions(-) diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index 5678319a0f..49ebe22baa 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -73,9 +73,23 @@ export function canForwardServiceTierForModel( providerName?: string, inbound: InboundWire = "responses", ): boolean { + return serviceTierSupportForModel(provider, modelId, providerName, inbound) === true; +} + +/** + * Return the tri-state capability after resolving the model's final wire adapter. + * `false` means either an explicit provider/model denial or an adapter that cannot carry the + * field; `undefined` keeps the existing conservative contract for an unclassified OpenAI wire. + */ +export function serviceTierSupportForModel( + provider: ServiceTierCapabilityProvider, + modelId: string, + providerName?: string, + inbound: InboundWire = "responses", +): boolean | undefined { const adapter = providerName === undefined ? provider.adapter : serviceTierAdapterForModel(providerName, provider, modelId, inbound); - return SERVICE_TIER_ADAPTERS.has(adapter) - && supportsServiceTierForModel(provider, modelId) === true; + if (!SERVICE_TIER_ADAPTERS.has(adapter)) return false; + return supportsServiceTierForModel(provider, modelId); } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index c0b26b2c5e..d149a722e7 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -117,7 +117,7 @@ import { ForwardAdmissionCredentialError, validateForwardAdmissionCredential } f import { createTranslatorBudget, isTranslatorBudgetExceededError, type TranslatorBudget } from "../../lib/translator-budget"; import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; -import { SERVICE_TIER_ADAPTERS, supportsServiceTierForModel } from "../../providers/service-tier"; +import { SERVICE_TIER_ADAPTERS, serviceTierSupportForModel } from "../../providers/service-tier"; import { slugsEquivalent } from "../../providers/slug-codec"; import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; import { isUsageDebugEnabled } from "../../usage/debug"; @@ -1081,7 +1081,12 @@ async function applyFinalRouteRequestNormalization(args: { // Fast mode override only where the final provider/model route explicitly documents // service-tier support. The same model-scoped resolver is used by catalog generation. - const modelServiceTierSupport = supportsServiceTierForModel(route.provider, route.modelId); + const modelServiceTierSupport = serviceTierSupportForModel( + route.provider, + route.modelId, + route.providerName, + inboundWire, + ); if (config.fastMode !== undefined && SERVICE_TIER_ADAPTERS.has(route.provider.adapter) && modelServiceTierSupport === true) { @@ -1092,8 +1097,15 @@ async function applyFinalRouteRequestNormalization(args: { } parsed.options.serviceTier = tier; } - applyServiceTierGate(route.provider, parsed._rawBody, parsed.options, route.modelId); - if (SERVICE_TIER_ADAPTERS.has(route.provider.adapter) && modelServiceTierSupport === false) { + applyServiceTierGate( + route.provider, + parsed._rawBody, + parsed.options, + route.modelId, + route.providerName, + inboundWire, + ); + if (modelServiceTierSupport === false) { logCtx.requestedServiceTier = undefined; logCtx.requestedSpeedLabel = undefined; } @@ -1490,11 +1502,17 @@ export function applyServiceTierGate( rawBody: unknown, options: { serviceTier?: string }, modelId?: string, + providerName?: string, + inbound: InboundWire = "responses", ): void { - if (!SERVICE_TIER_ADAPTERS.has(provider.adapter)) return; + // A direct unit caller without a model id retains the historical tri-state behavior for + // adapters outside the OpenAI service-tier family. Once a model is known, resolve the final + // model adapter as well: an explicit override to Anthropic (or another non-OpenAI wire) must + // not carry a caller-supplied `service_tier` through a route that cannot forward it. + if (modelId === undefined && !SERVICE_TIER_ADAPTERS.has(provider.adapter)) return; const support = modelId === undefined ? provider.supportsServiceTier - : supportsServiceTierForModel(provider, modelId); + : serviceTierSupportForModel(provider, modelId, providerName, inbound); if (support !== false) return; if (rawBody && typeof rawBody === "object") { delete (rawBody as Record).service_tier; diff --git a/tests/service-tier-capability.test.ts b/tests/service-tier-capability.test.ts index 41a6e67613..b446dd3133 100644 --- a/tests/service-tier-capability.test.ts +++ b/tests/service-tier-capability.test.ts @@ -11,7 +11,7 @@ import { providerConfigSeed, enrichProviderFromRegistry } from "../src/providers import { getProviderRegistryEntry } from "../src/providers/registry"; import type { RequestLogContext } from "../src/server/request-log"; import { applyServiceTierGate, handleResponses } from "../src/server/responses/core"; -import { supportsServiceTierForModel } from "../src/providers/service-tier"; +import { canForwardServiceTierForModel, supportsServiceTierForModel } from "../src/providers/service-tier"; import { serviceTierAdapterForModel } from "../src/providers/service-tier"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; @@ -76,6 +76,11 @@ describe("service-tier capability is exact-model and provider-scoped", () => { } as const; expect(serviceTierAdapterForModel("custom-relay", provider, "responses-model")).toBe("openai-responses"); expect(serviceTierAdapterForModel("custom-relay", provider, "chat-model")).toBe("openai-chat"); + expect(canForwardServiceTierForModel({ + ...provider, + adapter: "anthropic", + supportsServiceTier: true, + }, "anthropic-model", "custom-relay")).toBe(false); }); }); @@ -125,6 +130,18 @@ describe("applyServiceTierGate fails closed", () => { expect(body).not.toHaveProperty("service_tier"); expect(options.serviceTier).toBeUndefined(); }); + + test("a final non-service-tier adapter strips the caller tier after route resolution", () => { + const body = { model: "anthropic-model", service_tier: "priority" }; + const options: { serviceTier?: string } = { serviceTier: "priority" }; + applyServiceTierGate({ + adapter: "anthropic", + baseUrl: "https://api.anthropic.com", + supportsServiceTier: true, + }, body, options, "anthropic-model", undefined); + expect(body).not.toHaveProperty("service_tier"); + expect(options.serviceTier).toBeUndefined(); + }); }); describe("the gate fires on the live handleResponses path", () => { From 0005baaec372346d2dd8390be3d6072ff1722edb 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 05/28] 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 | 29 +-- 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, 899 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 e4c20b5fc4..b663a38caf 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 873d6f0730..f8fb166205 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -23,6 +23,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, @@ -56,6 +81,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(() => { @@ -67,10 +100,14 @@ export default function ProviderSettings({ setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(savedLiveModels); + 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, savedLiveModels, baseUrlChoices]); + }, [item.adapter, item.baseUrl, item.defaultModel, item.authMode, item.apiKeyTransport, item.keyOptional, item.note, item.allowPrivateNetwork, savedLiveModels, 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 @@ -112,6 +149,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 ?? "") @@ -120,8 +178,10 @@ export default function ProviderSettings({ || note.trim() !== (item.note ?? "") || allowPrivateNetwork !== (item.allowPrivateNetwork ?? false) || liveModels !== savedLiveModels; + 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); @@ -155,7 +215,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 (liveModelDiscoverySupported && liveModels !== (item.liveModels !== false)) patch.liveModels = liveModels; @@ -204,6 +268,8 @@ export default function ProviderSettings({ setDefaultModel(item.defaultModel ?? ""); setAuthMode(initialAuth); setApiKeyTransport(item.apiKeyTransport ?? "x-api-key"); setNote(item.note ?? ""); setAllowPrivateNetwork(item.allowPrivateNetwork ?? false); setLiveModels(savedLiveModels); 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)); }; @@ -216,6 +282,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 85ae253fc9..84400f4f95 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -1697,6 +1697,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 beff0e4875..7a5912dff9 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -1202,6 +1202,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 baf1ac39d8..82c5dcad31 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -1135,6 +1135,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 61f17667c3..b4be50020a 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -1724,6 +1724,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 c350514259..86e68f3695 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -1186,6 +1186,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 de25ae8b16..9458ebfbe0 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -1193,6 +1193,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 ff667bffa3..6362985434 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -983,6 +983,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 ad87a7e599..348dc3c78b 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -1717,6 +1717,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 fcbfac1319..53b680cc09 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 f40ee8067a..89fbcd0d63 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 b71a1a34c7..a0b234a99b 100644 --- a/src/config.ts +++ b/src/config.ts @@ -688,6 +688,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). @@ -695,6 +722,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 116fee697a..ae6ccf3d2e 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(); @@ -3004,7 +3005,7 @@ async function handleResponsesInner( : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2), connectTimeoutMs: config.connectTimeoutMs ?? 200_000, stallTimeoutSec: config.stallTimeoutSec, - fetchImpl: providerFetch(route.provider, options.codexWsRuntimeIdentity), + fetchImpl: providerFetch(route.provider, route.providerName, route.modelId, options.codexWsRuntimeIdentity), onRequestBuilt: request => recordAdapterReasoning(logCtx, request), ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}), onUsage: usage => { @@ -3315,6 +3316,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, @@ -3329,7 +3331,7 @@ async function handleResponsesInner( headers: builtInitialRequest.headers, body: builtInitialRequest.body, }, recovery), upstream.signal, connectMs, parsed.stream, - providerFetch(route.provider, options.codexWsRuntimeIdentity)); + providerFetch(route.provider, route.providerName, route.modelId, options.codexWsRuntimeIdentity)); }, { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url) }, ); @@ -3404,12 +3406,14 @@ 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, options.codexWsRuntimeIdentity)); + 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, options.codexWsRuntimeIdentity)); } finally { retryRequest.releaseBodyObservation?.(); } @@ -3718,6 +3722,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, @@ -3737,7 +3742,7 @@ async function handleResponsesInner( upstream.signal, connectMs, nextParsed.stream, - providerFetch(route.provider, options.codexWsRuntimeIdentity), + providerFetch(route.provider, route.providerName, nextParsed.modelId, options.codexWsRuntimeIdentity), ); }, { abortSignal: upstream.signal, label: safeHostLabel(builtContinuationRequest.url) }, diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index f780c0aa0f..227af853c6 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -102,6 +102,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 { @@ -136,21 +137,38 @@ export function safeOriginLabel(url: string): string { +interface PaceAwareFetch { + waitForPacing?: (signal?: AbortSignal) => Promise; + unpacedFetch?: typeof globalThis.fetch; +} + export function providerFetch( provider: OcxProviderConfig, + providerName?: string, + modelId?: string, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), ): 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, runtime)) { return codexWsUpstreamFetch(input, init, base, runtime); } 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 }) }); } @@ -164,6 +182,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")); @@ -175,7 +196,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 f98ed13f81..f597e9cb1d 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1261,12 +1261,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 1db9a6fe1d..76a5a6fdb5 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -373,6 +373,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 3cb411aa2c..0616447e13 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -440,6 +440,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 d954e6ce971efcce7f08a7eb502c182281d9d697 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 06/28] 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 07/28] 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 78acb79fdd13ed035b822831776b171c8480af86 Mon Sep 17 00:00:00 2001 From: Hanbin Noh <282618027+hanbinnoh@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:29:25 +0900 Subject: [PATCH 08/28] test(chat): cover native credential isolation and tool calls (#1467) --- tests/chat-completions-endpoint.test.ts | 83 +++++++++++++++++++++++++ 1 file changed, 83 insertions(+) diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index a7ecca3993..d0b05f2a54 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -715,6 +715,89 @@ test("POST /v1/responses honors the per-model response_format opt-out", async () } }); +test("chat-native does not forward ChatGPT account headers to third-party providers", async () => { + const seen: Array<{ authorization: string | null; account: string | null }> = []; + const upstream = Bun.serve({ + port: 0, + fetch(req) { + seen.push({ + authorization: req.headers.get("authorization"), + account: req.headers.get("chatgpt-account-id"), + }); + return Response.json({ + id: "chatcmpl_safe", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + }, + }); + writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({ + tokens: { access_token: "chat-main-access", account_id: "chat-main-account" }, + })); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`, { + apiKey: "third-party-key", + })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: false, + messages: [{ role: "user", content: "hi" }], + }), + }); + expect(response.status).toBe(200); + expect(seen).toEqual([{ authorization: "Bearer third-party-key", account: null }]); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-native non-stream fold preserves tool calls and finish reason", async () => { + const upstream = Bun.serve({ + port: 0, + fetch() { + return new Response([ + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { role: "assistant", tool_calls: [{ index: 0, id: "call_1", type: "function", function: { name: "lookup", arguments: '{"q":' } }] } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: { tool_calls: [{ index: 0, function: { arguments: '"x"}' } }] } }] })}\n\n`, + `data: ${JSON.stringify({ choices: [{ index: 0, delta: {}, finish_reason: "tool_calls" }], usage: { prompt_tokens: 2, completion_tokens: 1 } })}\n\n`, + "data: [DONE]\n\n", + ].join(""), { headers: { "content-type": "text/event-stream" } }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: false, + messages: [{ role: "user", content: "use lookup" }], + tools: [{ type: "function", function: { name: "lookup", parameters: { type: "object" } } }], + }), + }); + expect(response.status).toBe(200); + const json = await response.json() as { + choices: Array<{ finish_reason: string; message: { tool_calls?: unknown[] } }>; + }; + expect(json.choices[0]?.finish_reason).toBe("tool_calls"); + expect(json.choices[0]?.message.tool_calls).toEqual([{ + id: "call_1", + type: "function", + function: { name: "lookup", arguments: '{"q":"x"}' }, + }]); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("POST /v1/chat/completions direct mode forwards caller Authorization", async () => { const seen: Array<{ authorization: string | null }> = []; const upstream = Bun.serve({ From ad90489421d8d5d7d4cc1a71f04955009a5d6c80 Mon Sep 17 00:00:00 2001 From: Hanbin Noh <282618027+hanbinnoh@users.noreply.github.com> Date: Thu, 13 Aug 2026 11:07:00 +0900 Subject: [PATCH 09/28] test(chat): prove native bounded reader releases on overflow (#1467) The non-streaming chat-native path now charges upstream bytes incrementally and cancels the locked reader on budget overflow. Cover overflow returning 413 and that the server remains responsive for the next turn, and document the invalid-JSON passthrough contract. No further product-code blocker per Ingwannu review. Refs #1467 --- tests/chat-completions-endpoint.test.ts | 64 +++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index d0b05f2a54..00276cb466 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -798,6 +798,70 @@ test("chat-native non-stream fold preserves tool calls and finish reason", async } }); + +test("chat-native non-stream budget overflow returns 413 without hanging", async () => { + // Two sequential requests: first overflows the bounded JSON reader (413), second proves server still responsive. + let calls = 0; + const upstream = Bun.serve({ + port: 0, + fetch() { + calls += 1; + if (calls === 1) { + const chunk = new TextEncoder().encode('{"id":"chatcmpl_x","object":"chat.completion","choices":[{"index":0,"message":{"role":"assistant","content":"' + "y".repeat(33 * 1024 * 1024) + '"},"finish_reason":"stop"}],"usage":{"prompt_tokens":1,"completion_tokens":1}}'); + return new Response(chunk, { headers: { "content-type": "application/json" } }); + } + return Response.json({ id: "chatcmpl_ok", object: "chat.completion", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/\$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: false, messages: [{ role: "user", content: "hi" }] }), + }); + expect(response.status).toBe(413); + const json = await response.json() as { error?: { code?: string } }; + expect(json.error?.code).toBe("translation_buffer_limit"); + const response2 = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: false, messages: [{ role: "user", content: "hi" }] }), + }); + expect(response2.status).toBe(200); + // Prove the overflow path released the body/reader: second request consumed a fresh upstream response. + expect(calls).toBe(2); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-native non-stream invalid JSON is forwarded as 200 passthrough", async () => { + // The native JSON path forwards verbatim when parsing fails (bridges legacy behavior for untrusted upstreams). + const upstream = Bun.serve({ + port: 0, + fetch() { + return new Response("not-json-at-all", { headers: { "content-type": "application/json" } }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/\$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: false, messages: [{ role: "user", content: "hi" }] }), + }); + expect(response.status).toBe(200); + expect(await response.text()).toBe("not-json-at-all"); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + test("POST /v1/chat/completions direct mode forwards caller Authorization", async () => { const seen: Array<{ authorization: string | null }> = []; const upstream = Bun.serve({ From 1cda81eed4cb7b3cad62fd9a5881d20ecf33549a Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:17:00 +0900 Subject: [PATCH 10/28] 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 f8fb166205..37cf171bed 100644 --- a/gui/src/components/provider-workspace/ProviderSettings.tsx +++ b/gui/src/components/provider-workspace/ProviderSettings.tsx @@ -218,13 +218,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 (liveModelDiscoverySupported && 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 (liveModelDiscoverySupported && 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 baa1d914aeb9e83fa6f0c1f291288d21e417cede Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:17:18 +0900 Subject: [PATCH 11/28] 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 5000fe2603caca50a206c1574b65fd6ffcb3446c Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:18:03 +0900 Subject: [PATCH 12/28] 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 a8ae30fc189b78211c8887bee47da1008ed60855 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:18:17 +0900 Subject: [PATCH 13/28] 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 b02d5e5790b2d57e126039dcd7d76f23818999b9 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:18:24 +0900 Subject: [PATCH 14/28] 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 30da74ed81a59088bdcbc70a5c3e12f52cfe6fc3 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:18:48 +0900 Subject: [PATCH 15/28] 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 05a1627d81..eabb70cf25 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]; @@ -133,7 +134,14 @@ export async function handleResponsesWithPolicyFallback( // Core owns the client-facing parse/decompression error. } - let response = await runCore(req, config, logCtx, coreOptions); + let response: Response; + try { + response = await runCore(req, config, logCtx, coreOptions); + } 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; @@ -151,7 +159,13 @@ export async function handleResponsesWithPolicyFallback( finishFailedPolicyAttempt(logCtx, response.status); const retryRequest = requestWithCandidate(req, rawBody, next); try { - response = await runCore(retryRequest, config, logCtx, coreOptions); + try { + response = await runCore(retryRequest, config, logCtx, coreOptions); + } catch (error) { + const overload = requestPacingOverloadResponse(error); + if (overload) return overload; + throw error; + } } finally { logCtx.requestedModel = initialRequestedModel; logCtx.routeDecision = initialTrace; From 79f1484f49384a7c458e40e03d81b749dcb24b2f Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:19:02 +0900 Subject: [PATCH 16/28] 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 75ed592cbcb0a00b7de163d23d62a0b053dd5241 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:19:44 +0900 Subject: [PATCH 17/28] 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 75fc4a47914104aaa4e8b9c1528db158efe57083 Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:20:09 +0900 Subject: [PATCH 18/28] 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 864346a9a73987a8c8df8b14856eb77bbfb71afc Mon Sep 17 00:00:00 2001 From: kimrinking-cell Date: Thu, 13 Aug 2026 16:22:26 +0900 Subject: [PATCH 19/28] 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 cc0c511999..a81d1ddd79 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"; @@ -99,6 +100,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; From 859d898fb95e7dbc338f34363db1971a6abe1396 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 15:46:17 +0900 Subject: [PATCH 20/28] fix(codex): reconcile routed tiers with chat wire opt-in --- .../content/docs/guides/codex-app-models.md | 4 +- .../docs/reference/configuration/providers.md | 5 +- src/codex/catalog/provider-fetch.ts | 18 +++--- src/providers/registry.ts | 2 +- src/providers/service-tier.ts | 7 ++- src/routing/capability.ts | 10 ++-- src/routing/compatibility/behavior.ts | 4 +- src/types.ts | 5 +- structure/04_transports-and-sidecars.md | 8 ++- tests/management-provider-validation.test.ts | 2 + tests/service-tier-capability.test.ts | 55 +++++++++++++++++++ 11 files changed, 91 insertions(+), 29 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 0c53c74274..2fd550714b 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -205,7 +205,9 @@ can opt in globally with `supportsServiceTier: true`, or narrowly with `modelSupportsServiceTier: { "verified-model": true }`; an exact `false` narrows a provider default of `true`, while provider-level `false` remains fail-closed. The same final adapter/model decision controls both catalog metadata and runtime injection, so the fast option is never -advertised where it cannot be honored. +advertised where it cannot be honored. An `openai-chat` destination must also set +`chatServiceTier: true`, matching the adapter's existing wire-level opt-in; Responses routes do +not need that extra flag. ## Subagent selection diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 8b960b9af2..790188d857 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -67,8 +67,9 @@ 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. | | `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. | -| `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. An exact `false` can narrow a provider default of `true`; an explicit provider-level `false` remains fail-closed and cannot be reopened by the map. Only an exact model declared `true` on an `openai-chat` or `openai-responses` route publishes Fast metadata and receives global Fast injection. Unknown models remain unclassified. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | +| `supportsServiceTier?` | `boolean` | Tri-state `service_tier` capability fallback. `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. An `openai-chat` route also requires `chatServiceTier: true` before this capability can be emitted on the wire. | +| `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. An exact `false` can narrow a provider default of `true`; an explicit provider-level `false` remains fail-closed and cannot be reopened by the map. Undeclared models fall back to `supportsServiceTier`. A capable `openai-responses` route publishes Fast metadata and receives global Fast injection; `openai-chat` also requires `chatServiceTier: true`. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | +| `chatServiceTier?` | `boolean` | Wire-level opt-in for serializing `service_tier` on `/chat/completions`. Required in addition to the resolved provider/model capability because many strict OpenAI-compatible Chat gateways reject this extension. | | `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. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index 1438a62eec..d31f67533c 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -32,7 +32,7 @@ import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; -import { canForwardServiceTierForModel, supportsServiceTierForModel } from "../../providers/service-tier"; +import { serviceTierSupportForModel } from "../../providers/service-tier"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; @@ -634,10 +634,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, const reasoningEfforts = configuredReasoningEfforts(prov, model.id); const defaultReasoningEffort = modelRecordValue(prov.modelDefaultReasoningEfforts, model.id) ?? model.defaultReasoningEffort; const supportsReasoningSummaries = configuredReasoningSummarySupport(prov, model.id); - const supportsServiceTier = supportsServiceTierForModel(prov, model.id); - const serviceTierSupported = typeof supportsServiceTier === "boolean" - ? canForwardServiceTierForModel(prov, model.id, name) - : undefined; + const supportsServiceTier = serviceTierSupportForModel(prov, model.id, name); const { supportsServiceTier: _staleServiceTier, ...modelWithoutServiceTier } = model; const hinted = { ...modelWithoutServiceTier, @@ -659,7 +656,7 @@ export function applyProviderConfigHints(name: string, prov: OcxProviderConfig, : {}), ...(defaultReasoningEffort ? { defaultReasoningEffort } : {}), ...(typeof supportsReasoningSummaries === "boolean" ? { supportsReasoningSummaries } : {}), - ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier: serviceTierSupported } : {}), + ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), ...(prov.adapter === "kiro" ? { supportsVerbosity: false } : {}), // Default-on for openai-chat providers (explicit false opts out); other adapters // advertise only on explicit opt-in. @@ -1771,6 +1768,9 @@ async function gatherRoutedModelsUncached( ? nativeDefaultReasoningEffort(cm.modelId) : undefined; const supportsReasoningSummaries = configuredReasoningSummarySupport(rawProvider, cm.modelId); + const supportsServiceTier = effectiveProvider + ? serviceTierSupportForModel(effectiveProvider, cm.modelId, cm.provider) + : undefined; const base: CatalogModel = { id: cm.modelId, provider: cm.provider, @@ -1804,11 +1804,7 @@ async function gatherRoutedModelsUncached( // verbatim instead of being replaced by the replaced row's metadata. ...(Array.isArray(cm.reasoningEfforts) ? { reasoningEfforts: [...cm.reasoningEfforts] } : {}), ...(cm.defaultReasoningEffort ? { defaultReasoningEffort: cm.defaultReasoningEffort } : {}), - ...(effectiveProvider && typeof supportsServiceTierForModel(effectiveProvider, cm.modelId) === "boolean" - ? { - supportsServiceTier: canForwardServiceTierForModel(effectiveProvider, cm.modelId, cm.provider), - } - : {}), + ...(typeof supportsServiceTier === "boolean" ? { supportsServiceTier } : {}), }; // #962: the dedupe below drops the provider-derived row this custom row replaces. Inherit that // row's provider capability metadata (reasoning ladder, default effort, parallel tool calls, diff --git a/src/providers/registry.ts b/src/providers/registry.ts index 8c383511f7..200e7ecfca 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -200,7 +200,7 @@ export interface ProviderRegistryEntry { */ requiresAdjacentResponsesToolResults?: boolean; /** - * Registry default for the provider's Responses `service_tier` support; see + * Registry default for the provider's `service_tier` support; see * `OcxProviderConfig.supportsServiceTier`. Registry-only: backfilled (never * overriding) at enrich/route time and deliberately NOT seeded into saved * config, so an explicit user value stays distinguishable from the default diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index 49ebe22baa..d725e38b9e 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -7,7 +7,7 @@ export const SERVICE_TIER_ADAPTERS = new Set(["openai-chat", "openai-responses"] type ServiceTierCapabilityProvider = Pick< OcxProviderConfig, - "adapter" | "supportsServiceTier" | "modelSupportsServiceTier" | "modelAdapters" | "baseUrl" | "authMode" + "adapter" | "supportsServiceTier" | "modelSupportsServiceTier" | "modelAdapters" | "baseUrl" | "authMode" | "chatServiceTier" >; /** @@ -91,5 +91,10 @@ export function serviceTierSupportForModel( ? provider.adapter : serviceTierAdapterForModel(providerName, provider, modelId, inbound); if (!SERVICE_TIER_ADAPTERS.has(adapter)) return false; + // The current OpenAI Chat adapter deliberately serializes this extension only for + // providers that opted in. Treat that serializer decision as authoritative so catalog + // metadata, routing evidence, fast-mode injection, and caller-tier stripping cannot claim + // support that the final request builder will silently omit. + if (adapter === "openai-chat" && provider.chatServiceTier !== true) return false; return supportsServiceTierForModel(provider, modelId); } diff --git a/src/routing/capability.ts b/src/routing/capability.ts index 291b5204fc..0d7d5cde5a 100644 --- a/src/routing/capability.ts +++ b/src/routing/capability.ts @@ -12,7 +12,7 @@ import type { OcxConfig } from "../types"; import { isCanonicalOpenAiForwardProvider, OPENAI_CODEX_PROVIDER_ID } from "../providers/openai-tiers"; -import { SERVICE_TIER_ADAPTERS, supportsServiceTierForModel } from "../providers/service-tier"; +import { serviceTierSupportForModel } from "../providers/service-tier"; import { applyProviderContextCap, providerContextCap } from "../providers/context-cap"; import { PROVIDER_REGISTRY } from "../providers/registry"; import { @@ -186,12 +186,10 @@ export function candidateCapabilityEvidence( ?? catalogRow?.reasoningEfforts ?? (isNative ? nativeReasoningEfforts(modelId) : undefined); - const tierSupport = (provider ? supportsServiceTierForModel(provider, modelId) : undefined) - ?? (registryEntry ? supportsServiceTierForModel(registryEntry, modelId) : undefined); - const tierAdapter = provider?.adapter ?? registryEntry?.adapter; + const tierSupport = provider + ? serviceTierSupportForModel(provider, modelId, providerName) + : registryEntry ? serviceTierSupportForModel(registryEntry, modelId, providerName) : undefined; const serviceTier = tierSupport === true - && tierAdapter !== undefined - && SERVICE_TIER_ADAPTERS.has(tierAdapter) ? "supported" : tierSupport === false ? "unsupported" : "unknown"; diff --git a/src/routing/compatibility/behavior.ts b/src/routing/compatibility/behavior.ts index 1fe8f80ee3..5eaa46c4fc 100644 --- a/src/routing/compatibility/behavior.ts +++ b/src/routing/compatibility/behavior.ts @@ -1,6 +1,6 @@ import type { OcxConfig, OcxProviderConfig } from "../../types"; import { PROVIDER_REGISTRY, type ProviderAuthKind } from "../../providers/registry"; -import { supportsServiceTierForModel } from "../../providers/service-tier"; +import { serviceTierSupportForModel } from "../../providers/service-tier"; import { localFingerprint } from "../../lab/digest"; import type { LabBehaviorSource, LabBehaviorValues } from "../../lab/live/types"; @@ -119,7 +119,7 @@ export function resolveProductionBehaviorValues( "responses.stateful": behaviorRow("provider_config", effective.statelessResponses !== true), "responses.serviceTier": behaviorRow( "provider_config", - supportsServiceTierForModel(effective, modelId) ?? null, + serviceTierSupportForModel(effective, modelId, providerName) ?? null, ), "responses.snapshotRepair": behaviorRow("provider_config", effective.responsesSnapshotRepair === true), "responses.itemIdRepair": behaviorRow("provider_config", effective.responsesItemIdRepair ?? null), diff --git a/src/types.ts b/src/types.ts index 762ed7d2e0..3d90136be4 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1309,8 +1309,9 @@ export interface OcxProviderConfig { */ requiresAdjacentResponsesToolResults?: boolean; /** - * Whether this provider's Responses route honours the OpenAI `service_tier` - * parameter. Tri-state: `true` lets fast mode inject/remove the field (an unset + * Provider fallback for the OpenAI `service_tier` parameter. On Responses routes this + * is the complete wire opt-in; Chat routes additionally require `chatServiceTier`. + * Tri-state: `true` lets fast mode inject/remove the field (an unset * fast mode preserves a caller-supplied value); `false` strips the field and * never injects, because an upstream documented as not supporting the parameter * must not receive it; absent (`undefined`) leaves the provider unclassified — diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index d1955ddfbc..240351863d 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -44,9 +44,11 @@ OpenAI-compatible service-tier support is resolved only after the final provider known. `supportsServiceTier` remains the provider fallback, while the exact `modelSupportsServiceTier` map can override it per upstream model, including an explicit `false`. The catalog and request path share this decision: a routed row publishes `service_tiers` only when -the resolved `openai-chat` or `openai-responses` adapter is explicitly capable, and the final-route -normalizer applies the same gate to `service_tier`. Capability is namespaced by the selected provider -and model; model-name similarity and adapter type alone never opt a gateway in. +the resolved adapter is capable, and the final-route normalizer applies the same gate to +`service_tier`. `openai-responses` uses the resolved provider/model declaration directly; +`openai-chat` additionally requires its existing `chatServiceTier` serializer opt-in. Capability is +namespaced by the selected provider and model; model-name similarity and adapter type alone never +opt a gateway in. `POST /v1/responses/compact` handles remote compaction v1 before the generic `/v1/responses` branch and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through diff --git a/tests/management-provider-validation.test.ts b/tests/management-provider-validation.test.ts index 51e38ba3b4..8ff0066f16 100644 --- a/tests/management-provider-validation.test.ts +++ b/tests/management-provider-validation.test.ts @@ -240,6 +240,8 @@ describe("provider management validation", () => { } finally { resolvedError.mockRestore(); } + }); + test("service-tier validation and public projection stay in the management boundary", () => { expect(providerServiceTierConfigError("relay", { adapter: "openai-chat", diff --git a/tests/service-tier-capability.test.ts b/tests/service-tier-capability.test.ts index b446dd3133..b1e77b6e4c 100644 --- a/tests/service-tier-capability.test.ts +++ b/tests/service-tier-capability.test.ts @@ -13,6 +13,8 @@ import type { RequestLogContext } from "../src/server/request-log"; import { applyServiceTierGate, handleResponses } from "../src/server/responses/core"; import { canForwardServiceTierForModel, supportsServiceTierForModel } from "../src/providers/service-tier"; import { serviceTierAdapterForModel } from "../src/providers/service-tier"; +import { candidateCapabilityEvidence } from "../src/routing/capability"; +import { resolveProductionBehaviorValues } from "../src/routing/compatibility/behavior"; import type { OcxConfig, OcxProviderConfig } from "../src/types"; describe("registry capability reaches saved configs without overriding them", () => { @@ -81,6 +83,15 @@ describe("service-tier capability is exact-model and provider-scoped", () => { adapter: "anthropic", supportsServiceTier: true, }, "anthropic-model", "custom-relay")).toBe(false); + expect(canForwardServiceTierForModel({ + ...provider, + supportsServiceTier: true, + }, "chat-model", "custom-relay")).toBe(false); + expect(canForwardServiceTierForModel({ + ...provider, + supportsServiceTier: true, + chatServiceTier: true, + }, "chat-model", "custom-relay")).toBe(true); }); }); @@ -144,6 +155,49 @@ describe("applyServiceTierGate fails closed", () => { }); }); +describe("routing evidence uses the final model adapter", () => { + const relay = (overrides: Partial = {}): OcxProviderConfig => ({ + adapter: "openai-chat", + baseUrl: "https://relay.example.test/v1", + chatServiceTier: true, + supportsServiceTier: true, + modelSupportsServiceTier: { blocked: false }, + ...overrides, + }); + + test("model declarations and wire opt-ins reach routing and compatibility evidence", () => { + const config = { + port: 10100, + defaultProvider: "relay", + providers: { relay: relay() }, + } as OcxConfig; + expect(candidateCapabilityEvidence(config, "relay", "verified").serviceTier).toBe("supported"); + expect(candidateCapabilityEvidence(config, "relay", "blocked").serviceTier).toBe("unsupported"); + expect(candidateCapabilityEvidence({ + ...config, + providers: { relay: relay({ chatServiceTier: false }) }, + }, "relay", "verified").serviceTier).toBe("unsupported"); + + const mixedProvider = relay({ + adapter: "openai-responses", + chatServiceTier: false, + modelAdapters: { chat: "openai-chat" }, + }); + const mixedConfig = { ...config, providers: { relay: mixedProvider } }; + expect(candidateCapabilityEvidence(mixedConfig, "relay", "verified").serviceTier).toBe("supported"); + expect(candidateCapabilityEvidence(mixedConfig, "relay", "chat").serviceTier).toBe("unsupported"); + + const behavior = resolveProductionBehaviorValues( + mixedConfig, + "relay", + "chat", + mixedProvider, + "service-tier-test-salt", + ); + expect(behavior?.["responses.serviceTier"]?.value).toBe(false); + }); +}); + describe("the gate fires on the live handleResponses path", () => { const originalFetch = globalThis.fetch; afterEach(() => { globalThis.fetch = originalFetch; }); @@ -248,6 +302,7 @@ describe("the gate fires on the live handleResponses path", () => { baseUrl: "https://gateway.example.com/v1", apiKey: "sk-test", supportsServiceTier: true, + chatServiceTier: true, modelSupportsServiceTier: { "verified-model": true, "blocked-model": false }, }); const supported = await drive("custom-chat", custom(), "verified-model", {}, true); From 78eff2c62639fc65e0733de635ef7fe5bcdb9ef4 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 15:49:17 +0900 Subject: [PATCH 21/28] fix: rebuild pacing across current response transports --- .../ja/reference/configuration/providers.md | 2 +- .../ko/reference/configuration/providers.md | 2 +- .../docs/reference/configuration/providers.md | 2 +- .../ru/reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- .../reference/configuration/providers.md | 2 +- src/images/loop.ts | 12 ++- src/providers/request-pacing.ts | 60 +++++++------ src/server/responses/core.ts | 10 ++- src/server/responses/fetch-helpers.ts | 14 ++- structure/04_transports-and-sidecars.md | 4 +- tests/images/loop.test.ts | 3 + tests/request-pacing.test.ts | 90 +++++++++++++------ 13 files changed, 136 insertions(+), 69 deletions(-) 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 c680faf8ed..4a890d6f14 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -56,7 +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? }` | オプションの送信開始間隔調整。プロバイダー制限は全モデルに適用され、モデル別設定は遅延を増やす場合のみ有効です。キュー待機は応答ヘッダーのタイムアウトを消費しません。 | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 上流の使用量、請求、レート制限表示とは別の、クライアント側の送信開始間隔調整です。プロバイダー制限は全モデルに適用され、`models` は上流の正確なモデル ID に一致し、遅延を増やす場合のみ有効です。キュー待機は応答ヘッダーのタイムアウトを消費しません。HTTP、Responses WebSocket、明示的なアダプターの `fetchResponse`/`runTurn` 送信を対象にします。 | | `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 eaea77f373..79bb85bcc2 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -56,7 +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는 지연을 더 늘릴 때만 적용됩니다. 큐 대기는 응답 헤더 타임아웃을 소모하지 않습니다. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 업스트림 사용량, 과금, rate-limit 지표와 별개인 선택적 클라이언트 측 아웃바운드 요청 시작 속도 조절입니다. Provider 제한은 모든 모델에 적용되고 `models` 항목은 정확한 업스트림 모델 ID와 일치하며 지연을 더 늘릴 때만 적용됩니다. 큐 대기는 응답 헤더 타임아웃을 소모하지 않습니다. HTTP, Responses WebSocket, 명시적 어댑터 `fetchResponse`/`runTurn` 전송을 포함합니다. | | `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 b663a38caf..6b34e0002c 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -66,7 +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. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Optional client-side outbound request-start pacing, separate from upstream usage, billing, and rate-limit indicators. RPM is converted to an even interval; `minIntervalMs` may impose a longer interval. Provider limits apply across all models, while `models` entries use exact upstream model IDs (for example `nvidia/llama-3.1-nemotron-ultra-253b-v1`) and can only add delay. Queue waits do not consume the upstream response-header timeout. HTTP, Responses WebSocket, and explicit adapter `fetchResponse`/`runTurn` dispatches are covered. | | `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 f0de52a3a9..89c2cd52b8 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -69,7 +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? }` | Опциональная равномерная задержка начала исходящих запросов. Лимит провайдера действует на все модели, а правила моделей могут только увеличить задержку. Ожидание очереди не расходует таймаут заголовков ответа. | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | Опциональное клиентское выравнивание начала исходящих запросов, отдельное от учёта использования, биллинга и индикаторов rate limit апстрима. Лимит провайдера действует на все модели, а `models` сопоставляется с точными ID моделей апстрима и может только увеличить задержку. Ожидание очереди не расходует таймаут заголовков ответа. Поддерживаются HTTP, Responses WebSocket и явные вызовы адаптеров `fetchResponse`/`runTurn`. | | `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 2ac6f65e93..5ccabf5d10 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,7 +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? }` | 可选的出站请求启动节流。提供商限制适用于所有模型,模型规则只能增加延迟。排队等待不计入响应头超时。 | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 可选的客户端出站请求启动节流,与上游用量、计费和限流指标相互独立。提供商限制适用于所有模型,`models` 按上游模型精确 ID 匹配且只能增加延迟。排队等待不计入响应头超时。覆盖 HTTP、Responses WebSocket 以及显式适配器 `fetchResponse`/`runTurn` 调用。 | | `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 c4f5419a0f..2ea1148660 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,7 +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? }` | 選用的出站請求啟動節流。供應商限制適用於所有模型,模型規則只能增加延遲。排隊等待不計入回應標頭逾時。 | +| `requestPacing?` | `{ enabled, requestsPerMinute?, minIntervalMs?, models? }` | 選用的用戶端出站請求啟動節流,與上游用量、計費及限流指標彼此獨立。供應商限制適用於所有模型,`models` 依上游模型精確 ID 比對且只能增加延遲。排隊等待不計入回應標頭逾時。涵蓋 HTTP、Responses WebSocket 及明確的適配器 `fetchResponse`/`runTurn` 呼叫。 | | `responsesPath?` | `string` | Key-auth `openai-responses` 請求的相對資源路徑。必須以 `/` 開頭且不含 scheme、query 或 fragment。 | | `disabled?` | `boolean` | 將供應商保留在磁碟上但排除於路由與模型/目錄清單。 | | `apiKey?` | `string` | API 金鑰,或在請求時解析的 `${ENV_VAR}` / `$ENV_VAR` 參考。 | diff --git a/src/images/loop.ts b/src/images/loop.ts index 5042aab128..74fdfd4076 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -247,6 +247,8 @@ export interface ImageBridgeDeps { stallTimeoutSec?: number; /** Provider-specific fetch (e.g. xAI transport wrapper). Falls back to global fetch. */ fetchImpl?: typeof globalThis.fetch; + /** Reserve the routed provider's next request-start slot before each adapter dispatch. */ + waitForRequestSlot?: (signal?: AbortSignal) => Promise; /** Raw adapter usage at the terminal event, pre wire-normalization (see bridgeToResponsesSSE onUsage). */ onUsage?: (usage: OcxUsage | undefined) => void; /** @@ -380,6 +382,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise internalAbort.abort("runTurn backlog exceeded"), }); @@ -457,6 +460,11 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise => { + headerDeadline.clear(); + await deps.waitForRequestSlot?.(signal); + headerDeadline = clearableDeadline(connectTimeoutMs, signal); + }; try { /** * Build and fetch one image-bridge iteration on the given adapter, under the iteration @@ -487,6 +495,7 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise { + async (retryRecovery) => { + await paceThenResetHeaderDeadline(); // Record every helper-driven send (the callback runs for the first attempt and // each connection-reset replay); preserve the caller's recovery kind // (rate-limit-429 / key-429) when the retry layer supplies none. diff --git a/src/providers/request-pacing.ts b/src/providers/request-pacing.ts index d76f12bdde..a1c907d6f6 100644 --- a/src/providers/request-pacing.ts +++ b/src/providers/request-pacing.ts @@ -36,12 +36,18 @@ interface ProviderPacer { queue: Waiter[]; providerNextStartAt: number; modelNextStartAt: Map; - timer?: ReturnType; - active: boolean; + timer?: unknown; lastStartedAt?: number; lastModelId?: string; } +export interface RequestPacingRuntime { + now: () => number; + setTimer: (callback: () => void, delayMs: number) => unknown; + clearTimer: (handle: unknown) => void; + enqueueMicrotask: (callback: () => void) => void; +} + export interface ProviderRequestPacingStatus { provider: string; enabled: boolean; @@ -52,6 +58,13 @@ export interface ProviderRequestPacingStatus { } const pacers = new Map(); +const defaultRuntime: RequestPacingRuntime = { + now: Date.now, + setTimer: (callback, delayMs) => setTimeout(callback, delayMs), + clearTimer: handle => clearTimeout(handle as ReturnType), + enqueueMicrotask: queueMicrotask, +}; +let runtime = defaultRuntime; function abortReason(signal: AbortSignal): unknown { return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); @@ -113,14 +126,13 @@ function rejectExpiredWaiters(providerName: string, state: ProviderPacer, now: n } function runQueue(providerName: string, state: ProviderPacer): void { - if (state.active) return; if (state.queue.length === 0) { - if (state.timer) clearTimeout(state.timer); + if (state.timer) runtime.clearTimer(state.timer); state.timer = undefined; return; } if (state.timer) return; - const now = Date.now(); + const now = runtime.now(); for (const [modelId, readyAt] of state.modelNextStartAt) { if (readyAt <= now) state.modelNextStartAt.delete(modelId); } @@ -141,35 +153,24 @@ function runQueue(providerName: string, state: ProviderPacer): void { earliestAt = Math.min(earliestAt, readyAt, expiresAt); } const delayMs = Math.max(0, earliestAt - now); - state.timer = setTimeout(() => { + state.timer = runtime.setTimer(() => { 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(); + const startedAt = runtime.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)); + runtime.enqueueMicrotask(() => runQueue(providerName, state)); } export async function waitForProviderRequestSlot( @@ -183,7 +184,7 @@ export async function waitForProviderRequestSlot( if (signal?.aborted) throw abortReason(signal); const state = pacers.get(providerName) ?? { - queue: [], providerNextStartAt: 0, modelNextStartAt: new Map(), active: false, + queue: [], providerNextStartAt: 0, modelNextStartAt: new Map(), }; pacers.set(providerName, state); @@ -191,7 +192,7 @@ export async function waitForProviderRequestSlot( // 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); + runtime.clearTimer(state.timer); state.timer = undefined; } runQueue(providerName, state); @@ -199,17 +200,17 @@ export async function waitForProviderRequestSlot( throw new RequestPacingQueueOverloadError( providerName, "queue_full", - pacingRetryAfterSeconds(state, modelId, Date.now()), + pacingRetryAfterSeconds(state, modelId, runtime.now()), ); } await new Promise((resolve, reject) => { - const waiter: Waiter = { modelId, ...intervals, queuedAt: Date.now(), signal, resolve, reject }; + const waiter: Waiter = { modelId, ...intervals, queuedAt: runtime.now(), 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); + runtime.clearTimer(state.timer); state.timer = undefined; } reject(abortReason(signal!)); @@ -223,7 +224,7 @@ export async function waitForProviderRequestSlot( return; } if (state.timer) { - clearTimeout(state.timer); + runtime.clearTimer(state.timer); state.timer = undefined; } runQueue(providerName, state); @@ -233,7 +234,7 @@ export async function waitForProviderRequestSlot( export function providerRequestPacingStatus( providerName: string, provider: OcxProviderConfig, - now = Date.now(), + now = runtime.now(), ): ProviderRequestPacingStatus { const state = pacers.get(providerName); let nextSlotAt = state?.providerNextStartAt ?? 0; @@ -262,9 +263,14 @@ export function setProviderRequestPacingLimitsForTest(limits: { if (limits.maxQueueAgeMs !== undefined) maxQueueAgeMs = limits.maxQueueAgeMs; } +export function setProviderRequestPacingRuntimeForTest(nextRuntime: RequestPacingRuntime): void { + runtime = nextRuntime; +} + export function resetProviderRequestPacingForTest(): void { - for (const state of pacers.values()) if (state.timer) clearTimeout(state.timer); + for (const state of pacers.values()) if (state.timer) runtime.clearTimer(state.timer); pacers.clear(); maxQueueDepth = REQUEST_PACING_MAX_QUEUE_DEPTH; maxQueueAgeMs = REQUEST_PACING_MAX_QUEUE_AGE_MS; + runtime = defaultRuntime; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index a5a439b117..4b32c8378a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2989,6 +2989,12 @@ async function handleResponsesInner( && (tc.name === "image_generation" || imgPlan.toolNames.has(tc.name))) { parsed.options.toolChoice = { ...tc, name: IMAGE_GEN_TOOL_NAME }; } + const imageProviderFetch = providerFetch( + route.provider, + route.providerName, + route.modelId, + options.codexWsRuntimeIdentity, + ); const imgResponse = await runWithImageBridge({ parsed, adapter, incomingMeta: { headers: selectedForwardHeaders, abortSignal: options.abortSignal, translatorBudget }, @@ -3005,7 +3011,8 @@ async function handleResponsesInner( : clampImageMaxRounds(config.images?.videoMaxRounds ?? 2), connectTimeoutMs: config.connectTimeoutMs ?? 200_000, stallTimeoutSec: config.stallTimeoutSec, - fetchImpl: providerFetch(route.provider, route.providerName, route.modelId, options.codexWsRuntimeIdentity), + waitForRequestSlot: imageProviderFetch.waitForPacing, + fetchImpl: imageProviderFetch.unpacedFetch ?? imageProviderFetch, onRequestBuilt: request => recordAdapterReasoning(logCtx, request), ...(vidPlan?.timeoutMs ? { videoTimeoutMs: vidPlan.timeoutMs } : {}), onUsage: usage => { @@ -3140,6 +3147,7 @@ async function handleResponsesInner( const queue = createAdapterEventQueue({ onBacklogExceeded: () => runTurnAbort.abort(), }); + await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal); const runTurn = async (): Promise => { try { noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens); diff --git a/src/server/responses/fetch-helpers.ts b/src/server/responses/fetch-helpers.ts index 227af853c6..a8bd8ba8f0 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -137,17 +137,19 @@ export function safeOriginLabel(url: string): string { -interface PaceAwareFetch { +export interface PaceAwareFetch { waitForPacing?: (signal?: AbortSignal) => Promise; unpacedFetch?: typeof globalThis.fetch; } +export type ProviderFetch = typeof globalThis.fetch & PaceAwareFetch; + export function providerFetch( provider: OcxProviderConfig, providerName?: string, modelId?: string, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), -): typeof globalThis.fetch { +): ProviderFetch { 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 @@ -168,7 +170,11 @@ export function providerFetch( const preconnect = (...args: Parameters): void => { base.preconnect?.(...args); }; - return Object.assign(wrapped, { preconnect, waitForPacing, unpacedFetch: Object.assign(unpaced, { preconnect }) }); + return Object.assign(wrapped, { + preconnect, + waitForPacing, + unpacedFetch: Object.assign(unpaced, { preconnect }), + }); } @@ -182,7 +188,7 @@ export async function fetchWithHeaderTimeout( executor: typeof globalThis.fetch = globalThis.fetch, manualRedirect = false, ): Promise { - const pacing = executor as typeof globalThis.fetch & PaceAwareFetch; + const pacing = executor as ProviderFetch; await pacing.waitForPacing?.(abortSignal); const fetchExecutor = pacing.unpacedFetch ?? executor; const timeout = new AbortController(); diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 76a5a6fdb5..8562cb7366 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -379,8 +379,8 @@ before the proxy reacts. One provider-wide lane enforces the aggregate ceiling. 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. +`fetchResponse` and `runTurn` dispatches reserve the same lane at their call sites. Image-bridge +iterations reserve before arming their per-attempt response-header deadline. [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. diff --git a/tests/images/loop.test.ts b/tests/images/loop.test.ts index 688ff18ae1..4106d0b1d9 100644 --- a/tests/images/loop.test.ts +++ b/tests/images/loop.test.ts @@ -209,6 +209,7 @@ describe("runWithImageBridge", () => { test("retryOn429 replays on the same key before on429 rotation", async () => { let sends = 0; + let pacingReservations = 0; let rotations = 0; let retrySends = 0; const retryingAdapter: ProviderAdapter = { @@ -230,6 +231,7 @@ describe("runWithImageBridge", () => { adapter: retryingAdapter, plan, retryOn429Policy: { enabled: true, attempts: 2, intervalMs: 120, maxIntervalMs: 60_000, respectRetryAfter: false }, + waitForRequestSlot: async () => { pacingReservations += 1; }, on429: () => { rotations += 1; return null; @@ -241,6 +243,7 @@ describe("runWithImageBridge", () => { const sse = await response.text(); expect(sse).toContain("recovered"); expect(sends).toBe(2); + expect(pacingReservations).toBe(2); expect(rotations).toBe(0); expect(retrySends).toBe(1); // Same-target replay reuses the ONE built request (builder runs once per target sequence). diff --git a/tests/request-pacing.test.ts b/tests/request-pacing.test.ts index 7debbc372c..e1ab68f2dc 100644 --- a/tests/request-pacing.test.ts +++ b/tests/request-pacing.test.ts @@ -5,7 +5,9 @@ import { requestPacingIntervalMs, resetProviderRequestPacingForTest, setProviderRequestPacingLimitsForTest, + setProviderRequestPacingRuntimeForTest, waitForProviderRequestSlot, + type RequestPacingRuntime, } from "../src/providers/request-pacing"; import { providerFetch } from "../src/server/responses/fetch-helpers"; import { fetchWithHeaderTimeout } from "../src/server/responses/fetch-helpers"; @@ -18,6 +20,43 @@ function provider(requestPacing: OcxProviderConfig["requestPacing"]): OcxProvide return { adapter: "openai-chat", baseUrl: "https://example.test/v1", requestPacing }; } +function fakePacingClock(): { + runtime: RequestPacingRuntime; + now: () => number; + advanceBy: (delayMs: number) => void; +} { + let now = 0; + let nextId = 1; + const timers = new Map void }>(); + return { + runtime: { + now: () => now, + setTimer: (callback, delayMs) => { + const id = nextId++; + timers.set(id, { at: now + delayMs, callback }); + return id; + }, + clearTimer: handle => { timers.delete(handle as number); }, + enqueueMicrotask: callback => callback(), + }, + now: () => now, + advanceBy: (delayMs) => { + const target = now + delayMs; + while (true) { + const due = [...timers.entries()] + .filter(([, timer]) => timer.at <= target) + .sort((left, right) => left[1].at - right[1].at || left[0] - right[0])[0]; + if (!due) break; + const [id, timer] = due; + timers.delete(id); + now = timer.at; + timer.callback(); + } + now = target; + }, + }; +} + describe("requestPacingIntervalMs", () => { test("uses the slower of provider RPM, provider delay, and model override", () => { const configured = provider({ @@ -72,8 +111,8 @@ describe("provider request pacing queue", () => { 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); + await expect(queued).rejects.toHaveProperty("name", "AbortError"); }); test("rejects newest admission when the provider queue is full", async () => { @@ -97,11 +136,14 @@ describe("provider request pacing queue", () => { }); test("expires a queued request at the bounded queued-age deadline", async () => { + const clock = fakePacingClock(); + setProviderRequestPacingRuntimeForTest(clock.runtime); 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); + clock.advanceBy(25); await expect(queued).rejects.toMatchObject({ name: "RequestPacingQueueOverloadError", reason: "queue_expired", @@ -117,57 +159,49 @@ describe("provider request pacing queue", () => { 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({ - 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 () => { + test("manual fetchResponse slots enforce the same-model interval without wall-clock timing", async () => { + const clock = fakePacingClock(); + setProviderRequestPacingRuntimeForTest(clock.runtime); 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); + const second = waitForProviderRequestSlot("demo", configured, "slow"); + clock.advanceBy(179); + expect(providerRequestPacingStatus("demo", configured).queued).toBe(1); + clock.advanceBy(1); + await second; + expect(clock.now()).toBe(180); }); - test("a model waiting on its override does not block another eligible model", async () => { + test("an eligible sibling bypasses a slower model lane with an injected clock", async () => { + const clock = fakePacingClock(); + setProviderRequestPacingRuntimeForTest(clock.runtime); const configured = provider({ enabled: true, - minIntervalMs: 60, - models: { slow: { minIntervalMs: 350 } }, + minIntervalMs: 80, + models: { slow: { minIntervalMs: 400 } }, }); await waitForProviderRequestSlot("demo", configured, "slow"); - const started = Date.now(); const secondSlow = waitForProviderRequestSlot("demo", configured, "slow"); const fast = waitForProviderRequestSlot("demo", configured, "fast"); + clock.advanceBy(80); await fast; - expect(Date.now() - started).toBeGreaterThanOrEqual(45); - expect(Date.now() - started).toBeLessThan(220); + expect(clock.now()).toBe(80); + expect(providerRequestPacingStatus("demo", configured).queued).toBe(1); + clock.advanceBy(320); await secondSlow; + expect(clock.now()).toBe(400); }); 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); }); From ca3bd5fdbb9e409bc2c766a1038afedfe0b5384b Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 15:56:36 +0900 Subject: [PATCH 22/28] fix(responses): harden opt-in empty completion retry --- .../docs/ja/reference/configuration/server.md | 1 + .../docs/ko/reference/configuration/server.md | 1 + .../docs/reference/configuration/server.md | 1 + .../docs/ru/reference/configuration/server.md | 1 + .../docs/tr/reference/configuration/server.md | 2 +- .../zh-cn/reference/configuration/server.md | 1 + .../zh-tw/reference/configuration/server.md | 1 + gui/src/i18n/de.ts | 1 + gui/src/i18n/en.ts | 1 + gui/src/i18n/ja.ts | 1 + gui/src/i18n/ko.ts | 1 + gui/src/i18n/ru.ts | 1 + gui/src/i18n/tr.ts | 1 + gui/src/i18n/zh-TW.ts | 1 + gui/src/i18n/zh.ts | 1 + gui/src/pages/Logs.tsx | 4 +- src/config.ts | 12 + src/server/responses/core.ts | 43 ++- .../responses/empty-completion-guard.ts | 71 +++-- src/types.ts | 2 + src/usage/log.ts | 4 +- structure/02_config-and-codex-home.md | 2 +- structure/04_transports-and-sidecars.md | 9 + tests/config.test.ts | 13 + tests/empty-completion-core.test.ts | 254 ++++++++++++++++++ tests/empty-completion-guard.test.ts | 47 ++-- tests/empty-completion-hardening.test.ts | 149 ++++++++++ tests/usage-log.test.ts | 25 ++ 28 files changed, 600 insertions(+), 51 deletions(-) create mode 100644 tests/empty-completion-core.test.ts create mode 100644 tests/empty-completion-hardening.test.ts diff --git a/docs-site/src/content/docs/ja/reference/configuration/server.md b/docs-site/src/content/docs/ja/reference/configuration/server.md index 3ae0fa70ab..673a2c75c7 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/server.md +++ b/docs-site/src/content/docs/ja/reference/configuration/server.md @@ -12,6 +12,7 @@ description: リスナー、リモート アクセス、アドミッション | `port` | `number` | `10100` |プロキシリッスンポート。 | | `hostname?` | `string` | `"127.0.0.1"` |バインドアドレス。非ループバック バインドには `OPENCODEX_API_AUTH_TOKEN` が必要です。 | | `proxy?` | `string` | — |送信 HTTP(S) プロキシ URL または `${ENV_VAR}`。これらの変数が設定されていない場合にのみ、`HTTP_PROXY` / `HTTPS_PROXY` に適用されます。ループバックは `NO_PROXY` に残ります。 | +| `emptyCompletionRetry?` | `boolean` | `false` | テキストもツール呼び出しもない Responses 完了を、同一リクエストで 1 回再試行するよう明示的に有効化します。再試行は課金対象になる場合があります。`OCX_EMPTY_COMPLETION_RETRY=0` で設定を変更せず無効化できます。combo と routed-compaction turn は対象外です。 | | `stallTimeoutSec?` | `number` | `300` | `response.incomplete` より前にアップストリーム データがない秒数。最小 1。 | `connectTimeoutMs?` | `number` | `200000` |試行ごとの DNS/TCP/TLS/最終ヘッダーの期限。本体が生成される前に終了します。 | | `shutdownTimeoutMs?` | `number` | `5000` |アクティブなターンが中止される前の正常な排出期限。 | diff --git a/docs-site/src/content/docs/ko/reference/configuration/server.md b/docs-site/src/content/docs/ko/reference/configuration/server.md index 8a0626fc85..ab23a6bcdc 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/server.md +++ b/docs-site/src/content/docs/ko/reference/configuration/server.md @@ -12,6 +12,7 @@ description: 리스너, 원격 접근, admission 키, 타임아웃, 저장소, | `port` | `number` | `10100` | 프록시 수신 포트입니다. | | `hostname?` | `string` | `"127.0.0.1"` | 바인드 주소입니다. 루프백이 아닌 바인드에는 `OPENCODEX_API_AUTH_TOKEN`이 필요합니다. | | `proxy?` | `string` | — | 송신용 HTTP(S) 프록시 URL 또는 `${ENV_VAR}`입니다. 해당 변수가 비어 있을 때만 `HTTP_PROXY` / `HTTPS_PROXY`에 적용되며, 루프백은 `NO_PROXY`에 그대로 남습니다. | +| `emptyCompletionRetry?` | `boolean` | `false` | 텍스트나 도구 호출 없이 완료된 Responses 요청을 한 번 동일하게 재시도하도록 선택합니다. 재시도에는 비용이 발생할 수 있습니다. `OCX_EMPTY_COMPLETION_RETRY=0`은 설정을 바꾸지 않고 비활성화하며, combo 및 routed-compaction turn은 제외됩니다. | | `stallTimeoutSec?` | `number` | `300` | 업스트림 데이터가 없을 때 `response.incomplete`가 되기까지의 초 수입니다. 최소 1입니다. | | `connectTimeoutMs?` | `number` | `200000` | 시도별 DNS/TCP/TLS/최종 헤더 기한입니다. 본문 생성 전에 끝납니다. | | `shutdownTimeoutMs?` | `number` | `5000` | 진행 중인 turn을 중단하기 전에 허용하는 정상 종료 드레인 기한입니다. | diff --git a/docs-site/src/content/docs/reference/configuration/server.md b/docs-site/src/content/docs/reference/configuration/server.md index 81e6b3651b..34eb5a9375 100644 --- a/docs-site/src/content/docs/reference/configuration/server.md +++ b/docs-site/src/content/docs/reference/configuration/server.md @@ -13,6 +13,7 @@ runs helper features around provider requests. | `port` | `number` | `10100` | Proxy listen port. | | `hostname?` | `string` | `"127.0.0.1"` | Bind address. Non-loopback binds require `OPENCODEX_API_AUTH_TOKEN`. | | `proxy?` | `string` | — | Outbound HTTP(S) proxy URL or `${ENV_VAR}`. Applied to `HTTP_PROXY` / `HTTPS_PROXY` only when those variables are unset; loopback remains in `NO_PROXY`. | +| `emptyCompletionRetry?` | `boolean` | `false` | Opt in to one identical Responses retry when a completion has no text or tool call. The retry may be billable. `OCX_EMPTY_COMPLETION_RETRY=0` disables it without changing config; combo and routed-compaction turns remain excluded. | | `stallTimeoutSec?` | `number` | `300` | Seconds without upstream data before `response.incomplete`. Minimum 1. | | `connectTimeoutMs?` | `number` | `200000` | Per-attempt DNS/TCP/TLS/final-header deadline; it ends before body generation. | | `shutdownTimeoutMs?` | `number` | `5000` | Graceful drain deadline before active turns are aborted. | diff --git a/docs-site/src/content/docs/ru/reference/configuration/server.md b/docs-site/src/content/docs/ru/reference/configuration/server.md index df0ff57401..b6816f5e84 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/server.md +++ b/docs-site/src/content/docs/ru/reference/configuration/server.md @@ -13,6 +13,7 @@ description: Listener, удалённый доступ, admission key, тайм | `port` | `number` | `10100` | Порт, который слушает прокси. | | `hostname?` | `string` | `"127.0.0.1"` | Адрес bind'а. Не-loopback bind требует `OPENCODEX_API_AUTH_TOKEN`. | | `proxy?` | `string` | — | URL исходящего HTTP(S)-прокси или `${ENV_VAR}`. Применяется к `HTTP_PROXY` / `HTTPS_PROXY` только когда эти переменные не заданы; loopback всегда остаётся в `NO_PROXY`. | +| `emptyCompletionRetry?` | `boolean` | `false` | Явно включает один идентичный повтор Responses, если completion не содержит ни текста, ни tool call. Повтор может тарифицироваться. `OCX_EMPTY_COMPLETION_RETRY=0` отключает его без изменения config; combo и routed-compaction turn исключены. | | `stallTimeoutSec?` | `number` | `300` | Секунды без upstream-данных до `response.incomplete`. Минимум 1. | | `connectTimeoutMs?` | `number` | `200000` | Дедлайн одной попытки DNS/TCP/TLS/final-header; он завершается до генерации тела ответа. | | `shutdownTimeoutMs?` | `number` | `5000` | Дедлайн graceful-drain до принудительного прерывания активных turn'ов. | diff --git a/docs-site/src/content/docs/tr/reference/configuration/server.md b/docs-site/src/content/docs/tr/reference/configuration/server.md index 74433324c0..3de2b40fa2 100644 --- a/docs-site/src/content/docs/tr/reference/configuration/server.md +++ b/docs-site/src/content/docs/tr/reference/configuration/server.md @@ -14,6 +14,7 @@ yardımcı özellikleri nasıl çalıştıracağını kontrol eder. | `port` | `number` | `10100` | Proxy dinleme portu. | | `hostname?` | `string` | `"127.0.0.1"` | Bağlama adresi. Geri döngü olmayan bağlamalar `OPENCODEX_API_AUTH_TOKEN` gerektirir. | | `proxy?` | `string` | — | Giden HTTP(S) proxy URL'si veya `${ENV_VAR}`. Yalnızca bu değişkenler ayarlanmadığında `HTTP_PROXY` / `HTTPS_PROXY`'ye uygulanır; geri döngü `NO_PROXY` içinde kalır. | +| `emptyCompletionRetry?` | `boolean` | `false` | Metin veya araç çağrısı içermeyen bir Responses tamamlamasını aynı istekle bir kez yeniden denemeyi açıkça etkinleştirir. Yeniden deneme ücretlendirilebilir. `OCX_EMPTY_COMPLETION_RETRY=0`, yapılandırmayı değiştirmeden devre dışı bırakır; combo ve routed-compaction turları hariçtir. | | `stallTimeoutSec?` | `number` | `300` | `response.incomplete` öncesinde yukarı akış verisi olmadan geçen saniye. Minimum 1. | | `connectTimeoutMs?` | `number` | `200000` | Deneme başına DNS/TCP/TLS/nihai başlık son tarihi; gövde üretiminden önce biter. | | `shutdownTimeoutMs?` | `number` | `5000` | Aktif turlar iptal edilmeden önce zarif boşaltma süresi sınırı. | @@ -273,4 +274,3 @@ sınırı tüketmez. Uzak `https:` görselleri ve başarısız veya boş açıkl Anthropic OAuth sidecar'ları opencodex'in mevcut Claude Code OAuth parmak izini yeniden kullanır. Hedeflenen hesap ve iş yükünü kapsamlı bir şekilde test edin. - diff --git a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md index 3030232964..2d1d0773bb 100644 --- a/docs-site/src/content/docs/zh-cn/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-cn/reference/configuration/server.md @@ -13,6 +13,7 @@ description: 监听、远程访问、准入密钥、超时、存储、侧车、 | `port` | `number` | `10100` | 代理监听端口。 | | `hostname?` | `string` | `"127.0.0.1"` | 绑定地址。非回环绑定需要 `OPENCODEX_API_AUTH_TOKEN`。 | | `proxy?` | `string` | — | 出站 HTTP(S) 代理 URL,或 `${ENV_VAR}`。仅当 `HTTP_PROXY` / `HTTPS_PROXY` 未设置时才会应用;回环地址始终保留在 `NO_PROXY` 中。 | +| `emptyCompletionRetry?` | `boolean` | `false` | 显式启用:当 Responses 完成时既无文本也无工具调用,使用相同请求重试一次。重试可能产生费用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不修改配置的情况下禁用;combo 与 routed-compaction turn 不参与。 | | `stallTimeoutSec?` | `number` | `300` | 在上游没有数据之前可等待的秒数,超过后返回 `response.incomplete`。最小值为 1。 | | `connectTimeoutMs?` | `number` | `200000` | 每次尝试的 DNS/TCP/TLS/最终响应头截止时间;它在正文生成之前结束。 | | `shutdownTimeoutMs?` | `number` | `5000` | 优雅停机截止时间,超过后会中止仍在进行中的请求。 | diff --git a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md index 4d9f5921d1..8e4f6fd7d9 100644 --- a/docs-site/src/content/docs/zh-tw/reference/configuration/server.md +++ b/docs-site/src/content/docs/zh-tw/reference/configuration/server.md @@ -12,6 +12,7 @@ description: 監聽器、遠端存取、許可金鑰、逾時、儲存、sidecar | `port` | `number` | `10100` | 代理監聽連接埠。 | | `hostname?` | `string` | `"127.0.0.1"` | 綁定位址。非回送綁定需要 `OPENCODEX_API_AUTH_TOKEN`。 | | `proxy?` | `string` | — | 對外 HTTP(S) 代理 URL 或 `${ENV_VAR}`。僅在那些變數未設定時套用至 `HTTP_PROXY` / `HTTPS_PROXY`;回送保留在 `NO_PROXY` 中。 | +| `emptyCompletionRetry?` | `boolean` | `false` | 明確啟用:當 Responses 完成時沒有文字或工具呼叫,以相同請求重試一次。重試可能產生費用。`OCX_EMPTY_COMPLETION_RETRY=0` 可在不變更設定的情況下停用;combo 與 routed-compaction turn 不適用。 | | `stallTimeoutSec?` | `number` | `300` | 在 `response.incomplete` 前無上游資料的秒數。最小 1。 | | `connectTimeoutMs?` | `number` | `200000` | 每次嘗試的 DNS/TCP/TLS/final-header 截止時間;它在 body 生成前結束。 | | `shutdownTimeoutMs?` | `number` | `5000` | 在中止活躍回合前的優雅排空截止時間。 | diff --git a/gui/src/i18n/de.ts b/gui/src/i18n/de.ts index 85ae253fc9..64625e9a00 100644 --- a/gui/src/i18n/de.ts +++ b/gui/src/i18n/de.ts @@ -695,6 +695,7 @@ export const de: Record = { "logs.detail.attempt.recovery.rateLimit429": "Ratenbegrenzt (429)", "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth ratenbegrenzt (429)", "logs.detail.attempt.recovery.image413": "Bildnutzlast zu groß (413)", + "logs.detail.attempt.recovery.emptyCompletion": "Wiederholung nach leerer Antwort", "logs.detail.attempt.recovery.unknown": "Unbekannter Wiederherstellungsgrund", "logs.detail.reason.usage_missing": "Nutzung wurde nicht gemeldet.", "logs.detail.reason.usage_unsupported": "Dieser Anbieter meldet keine Nutzung.", diff --git a/gui/src/i18n/en.ts b/gui/src/i18n/en.ts index beff0e4875..c09fead691 100644 --- a/gui/src/i18n/en.ts +++ b/gui/src/i18n/en.ts @@ -728,6 +728,7 @@ export const en = { "logs.detail.attempt.recovery.rateLimit429": "Rate-limited (429)", "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth rate-limited (429)", "logs.detail.attempt.recovery.image413": "Image payload too large (413)", + "logs.detail.attempt.recovery.emptyCompletion": "Empty completion retry", "logs.detail.attempt.recovery.unknown": "Unknown recovery reason", "logs.detail.reason.usage_missing": "Usage was not reported.", "logs.detail.reason.usage_unsupported": "This provider does not report usage.", diff --git a/gui/src/i18n/ja.ts b/gui/src/i18n/ja.ts index baf1ac39d8..16dff6c8f1 100644 --- a/gui/src/i18n/ja.ts +++ b/gui/src/i18n/ja.ts @@ -671,6 +671,7 @@ export const ja: Record = { "logs.detail.attempt.recovery.rateLimit429": "レート制限 (429)", "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth レート制限 (429)", "logs.detail.attempt.recovery.image413": "画像ペイロードが大きすぎます (413)", + "logs.detail.attempt.recovery.emptyCompletion": "空の完了を再試行", "logs.detail.attempt.recovery.unknown": "不明なリカバリ理由", "logs.detail.reason.usage_missing": "使用量が報告されませんでした。", "logs.detail.reason.usage_unsupported": "このプロバイダーは使用量を報告しません。", diff --git a/gui/src/i18n/ko.ts b/gui/src/i18n/ko.ts index 61f17667c3..750380ab3b 100644 --- a/gui/src/i18n/ko.ts +++ b/gui/src/i18n/ko.ts @@ -714,6 +714,7 @@ export const ko: Record = { "logs.detail.attempt.recovery.rateLimit429": "요청 한도 초과 (429)", "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth 요청 한도 초과 (429)", "logs.detail.attempt.recovery.image413": "이미지 페이로드가 너무 큼 (413)", + "logs.detail.attempt.recovery.emptyCompletion": "빈 응답 재시도", "logs.detail.attempt.recovery.unknown": "알 수 없는 복구 사유", "logs.detail.reason.usage_missing": "usage가 보고되지 않았습니다.", "logs.detail.reason.usage_unsupported": "이 프로바이더는 usage 보고를 지원하지 않습니다.", diff --git a/gui/src/i18n/ru.ts b/gui/src/i18n/ru.ts index c350514259..1c2f05d045 100644 --- a/gui/src/i18n/ru.ts +++ b/gui/src/i18n/ru.ts @@ -712,6 +712,7 @@ export const ru: Record = { "logs.detail.attempt.recovery.rateLimit429": "Ограничение частоты запросов (429)", "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth ограничен (429)", "logs.detail.attempt.recovery.image413": "Слишком большой размер изображения (413)", + "logs.detail.attempt.recovery.emptyCompletion": "Повтор пустого завершения", "logs.detail.attempt.recovery.unknown": "Неизвестная причина восстановления", "logs.detail.reason.usage_missing": "Данные об использовании не были сообщены.", "logs.detail.reason.usage_unsupported": "Этот провайдер не сообщает данные об использовании.", diff --git a/gui/src/i18n/tr.ts b/gui/src/i18n/tr.ts index de25ae8b16..e2e8f48e97 100644 --- a/gui/src/i18n/tr.ts +++ b/gui/src/i18n/tr.ts @@ -719,6 +719,7 @@ export const tr: Record = { "logs.detail.attempt.recovery.rateLimit429": "Oran kısıtlandı (429)", "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth kısıtlandı (429)", "logs.detail.attempt.recovery.image413": "Görsel boyutu çok büyük (413)", + "logs.detail.attempt.recovery.emptyCompletion": "Boş tamamlama yeniden denemesi", "logs.detail.attempt.recovery.unknown": "Bilinmeyen kurtarma nedeni", "logs.detail.reason.usage_missing": "Kullanım bildirilmedi.", "logs.detail.reason.usage_unsupported": "Bu sağlayıcı kullanım bildirmeyebilir.", diff --git a/gui/src/i18n/zh-TW.ts b/gui/src/i18n/zh-TW.ts index ff667bffa3..b2109c181b 100644 --- a/gui/src/i18n/zh-TW.ts +++ b/gui/src/i18n/zh-TW.ts @@ -1777,6 +1777,7 @@ export const zhTW: Record = { "logs.detail.attempt.recovery.rateLimit429": "被限流 (429)", "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth 被限流 (429)", "logs.detail.attempt.recovery.image413": "圖片承載過大 (413)", + "logs.detail.attempt.recovery.emptyCompletion": "空白完成重試", "logs.detail.attempt.recovery.unknown": "未知的復原原因", "logs.detail.estimate.provider_cost_overlay": "已使用供應商設定的價格覆蓋。", "pws.cockpitImportDescription": "從此裝置匯入 Cockpit Tools Antigravity JSON 匯出檔。不會顯示檔案內容。", diff --git a/gui/src/i18n/zh.ts b/gui/src/i18n/zh.ts index ad87a7e599..2eb8afc4f3 100644 --- a/gui/src/i18n/zh.ts +++ b/gui/src/i18n/zh.ts @@ -707,6 +707,7 @@ export const zh: Record = { "logs.detail.attempt.recovery.rateLimit429": "被限流 (429)", "logs.detail.attempt.recovery.anthropicOauth429": "Anthropic OAuth 被限流 (429)", "logs.detail.attempt.recovery.image413": "图片载荷过大 (413)", + "logs.detail.attempt.recovery.emptyCompletion": "空完成重试", "logs.detail.attempt.recovery.unknown": "未知的恢复原因", "logs.detail.reason.usage_missing": "未上报 usage。", "logs.detail.reason.usage_unsupported": "该提供方不支持上报 usage。", diff --git a/gui/src/pages/Logs.tsx b/gui/src/pages/Logs.tsx index 5503177b72..1ec4d0a972 100644 --- a/gui/src/pages/Logs.tsx +++ b/gui/src/pages/Logs.tsx @@ -96,7 +96,8 @@ type AttemptRecoveryKind = | "key-429" | "rate-limit-429" | "anthropic-oauth-429" - | "image-413"; + | "image-413" + | "empty-completion"; interface LogAttempt { ordinal: number; @@ -321,6 +322,7 @@ const RECOVERY_KIND_KEYS = { "rate-limit-429": "logs.detail.attempt.recovery.rateLimit429", "anthropic-oauth-429": "logs.detail.attempt.recovery.anthropicOauth429", "image-413": "logs.detail.attempt.recovery.image413", + "empty-completion": "logs.detail.attempt.recovery.emptyCompletion", } as const satisfies Record; /** Map a metric-unavailable reason to its i18n key. */ diff --git a/src/config.ts b/src/config.ts index b71a1a34c7..4e89b84f3f 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1244,6 +1244,8 @@ const configSchema = z.object({ ]).optional().catch(undefined), providers: z.record(z.string(), providerConfigSchema), defaultProvider: z.string().min(1).default("openai"), + // A retry can be billable, so absence and malformed hand edits both stay off. + emptyCompletionRetry: z.boolean().optional().catch(false), openaiProviderTierVersion: z.union([z.literal(1), z.literal(2)]).optional(), // Invalid hand edits must not discard an otherwise usable config. googleAntigravityStaticCatalogVersion: z.union([z.literal(1), z.literal(2)]).optional().catch(undefined), @@ -2331,6 +2333,14 @@ function codexAccountPickerEnabledError(value: unknown): string | null { return "schema_invalid: codexAccountPickerEnabled: must be a boolean or omitted"; } +function emptyCompletionRetryError(value: unknown): string | null { + const raw = rawConfigRecord(value); + if (!raw || !Object.hasOwn(raw, "emptyCompletionRetry")) return null; + const enabled = raw.emptyCompletionRetry; + if (enabled === undefined || typeof enabled === "boolean") return null; + return "schema_invalid: emptyCompletionRetry: must be a boolean or omitted"; +} + /** Validate an in-memory config candidate without touching disk. Used by headless CLI import/set. */ /** * Reject a loopback-listener port that collides with the proxy port (#1102). @@ -2379,6 +2389,7 @@ export function validateConfigCandidate(value: unknown): { ok: true; config: Ocx ?? googleAntigravityStaticCatalogVersionError(value) ?? codexAccountPrioritiesError(value) ?? codexAccountPickerEnabledError(value) + ?? emptyCompletionRetryError(value) ?? loopbackListenerPortError(value); if (boundaryError) return { ok: false, error: boundaryError }; const result = configSchema.safeParse(value); @@ -3229,6 +3240,7 @@ export function getDefaultConfig(): OcxConfig { // Adding extra providers (e.g. opencode-go) and switching defaultProvider is a user/runtime choice. return { port: 10100, + emptyCompletionRetry: false, managementUsageMaxReadBytes: 64 * 1024 * 1024, appOwnedMemoryBudgetMb: DEFAULT_APP_OWNED_MEMORY_BUDGET_BYTES / (1024 * 1024), // Fresh/re-initialized configs are already written in the current three-tier diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 513c0fddba..24bb2df457 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -3141,10 +3141,11 @@ async function handleResponsesInner( // text and no tool call is a failure the client cannot see — it silently records the turn as // done. The guard holds pre-content adapter events, suppresses the terminal of an empty // turn, retries the IDENTICAL request once, and surfaces a stated error when the retry is - // empty or fails. Kill switch: OCX_EMPTY_COMPLETION_RETRY=0. Compaction turns and combo - // attempts keep their own machinery (the combo preflight already handles empty streams). + // empty or fails. This is a top-level config opt-in; OCX_EMPTY_COMPLETION_RETRY=0 is a + // disable-only emergency override. Compaction turns and combo attempts keep their own + // machinery (the combo preflight already handles empty streams). const emptyCompletionGuardEnabled = - emptyCompletionRetryEnabled() + emptyCompletionRetryEnabled(config) && !options.comboAttempt && !routedCompaction; @@ -3158,9 +3159,12 @@ async function handleResponsesInner( // empty-completion guard re-invokes the IDENTICAL turn (same parsed request, // same forwarded headers, same abort signal) through a fresh queue, so the // attempt body must not capture the first queue. - const runTurnAttempt = async (targetQueue: AdapterEventQueue): Promise => { + const runTurnAttempt = async ( + targetQueue: AdapterEventQueue, + recovery?: AttemptRecoveryKind, + ): Promise => { try { - noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens); + noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery); await adapter.runTurn?.( parsed, { headers: selectedForwardHeaders, abortSignal: runTurnAbort.signal, translatorBudget }, @@ -3188,7 +3192,7 @@ async function handleResponsesInner( const retryQueue = createAdapterEventQueue({ onBacklogExceeded: () => runTurnAbort.abort(), }); - void runTurnAttempt(retryQueue); + void runTurnAttempt(retryQueue, "empty-completion"); return retryQueue.stream(); }; @@ -3719,13 +3723,17 @@ async function handleResponsesInner( * back to key/account failover; a failure becomes an in-stream adapter error so the client * never sees a second hidden HTTP response or an unbounded retry loop. */ - const fetchTerminalGuardContinuation = async function* (nextParsed: OcxParsedRequest): AsyncGenerator { + const fetchTerminalGuardContinuation = async function* ( + nextParsed: OcxParsedRequest, + initialRecoveryKind?: AttemptRecoveryKind, + ): AsyncGenerator { let response: Response | undefined; // One-shot recovery label for the next top-of-loop continuation send after a failover rotation. - let nextContinuationRecoveryKind: AttemptRecoveryKind | undefined; + let nextContinuationRecoveryKind: AttemptRecoveryKind | undefined = initialRecoveryKind; /** * Build and fetch one terminal-guard continuation. `recoveryKind` tags same-target and - * failover sends (`rate-limit-429`, `key-429`, `anthropic-oauth-429`, `image-413`); the + * failover sends (`empty-completion`, `rate-limit-429`, `key-429`, + * `anthropic-oauth-429`, `image-413`); the * adapter rebuild is deterministic for the same parsed request (tests assert byte-identical * replays). */ @@ -3969,6 +3977,19 @@ async function handleResponsesInner( } }; + const fetchGuardedEmptyCompletionRetry = (): AsyncIterable => { + const retryEvents = fetchTerminalGuardContinuation(parsed, "empty-completion"); + return terminalGuardEnabled + ? guardTerminalEventStream({ + parsed, + firstEvents: retryEvents, + adapterName: activeAdapter.name, + maxAutoContinuations: 1, + continuation: fetchTerminalGuardContinuation, + }) + : retryEvents; + }; + if (parsed.stream) { const initialEventStream = activeAdapter.parseStream(upstreamResponse, translatorBudget); const eventStream = terminalGuardEnabled @@ -3987,7 +4008,7 @@ async function handleResponsesInner( const guardedEventStream = emptyCompletionGuardEnabled ? guardEmptyCompletionEventStream({ firstEvents: eventStream, - continuation: () => fetchTerminalGuardContinuation(parsed), + continuation: fetchGuardedEmptyCompletionRetry, }) : eventStream; const { toolNsMap, declaredToolNames, toolParameterSchemas, freeformToolNames, toolSearchToolNames } = toolBridgeMaps; @@ -4054,7 +4075,7 @@ async function handleResponsesInner( events = []; for await (const event of guardEmptyCompletionEventStream({ firstEvents: (async function* () { yield* guardedEvents; })(), - continuation: () => fetchTerminalGuardContinuation(parsed), + continuation: fetchGuardedEmptyCompletionRetry, })) events.push(event); } else { events = guardedEvents; diff --git a/src/server/responses/empty-completion-guard.ts b/src/server/responses/empty-completion-guard.ts index d113a3af10..84bbb5632e 100644 --- a/src/server/responses/empty-completion-guard.ts +++ b/src/server/responses/empty-completion-guard.ts @@ -1,4 +1,4 @@ -import type { AdapterEvent, OcxUsage } from "../../types"; +import type { AdapterEvent, OcxConfig, OcxUsage } from "../../types"; /** * Empty-completion guard for Responses turns (port of codex-router's @@ -15,19 +15,21 @@ import type { AdapterEvent, OcxUsage } from "../../types"; * upstream — the client sees a stated failure instead of a second silent * success. * - * Kill switch on the pattern of the router's CODEX_ROUTER_EMPTY_COMPLETION_RETRY: - * setting OCX_EMPTY_COMPLETION_RETRY=0 turns the guard off entirely and - * restores the previous relay behavior exactly. + * The retry is explicitly enabled by top-level config. The environment switch + * is a disable-only emergency override: OCX_EMPTY_COMPLETION_RETRY=0 restores + * the previous relay behavior without editing the persisted config. */ export const EMPTY_COMPLETION_RETRY_ENV = "OCX_EMPTY_COMPLETION_RETRY"; +/** Retained pre-content events are bounded independently by count and encoded size. */ +export const EMPTY_COMPLETION_MAX_BUFFERED_EVENTS = 1_024; +export const EMPTY_COMPLETION_MAX_BUFFERED_BYTES = 1_048_576; + export function emptyCompletionRetryEnabled( + config: Pick, env: Record = process.env, ): boolean { - // "0" disables; anything else (including unset) enables — same contract as - // the router's CODEX_ROUTER_EMPTY_COMPLETION_RETRY. - const configured = env[EMPTY_COMPLETION_RETRY_ENV]; - return configured !== "0"; + return config.emptyCompletionRetry === true && env[EMPTY_COMPLETION_RETRY_ENV] !== "0"; } /** Surfaced when the single retry was also empty or failed upstream. */ @@ -39,10 +41,26 @@ export const EMPTY_COMPLETION_RETRY_FAILED_CODE = "empty_completion_retry_failed * silent empty success this guard exists to catch, and retrying the identical * request would burn tokens for the same truncated result. */ -const VISIBLE_INCOMPLETE_STOP_REASONS: Record = { - max_tokens: true, - content_filter: true, -}; +const VISIBLE_INCOMPLETE_STOP_REASONS = new Set(["max_tokens", "content_filter"]); +const UTF8_ENCODER = new TextEncoder(); + +function retainedEventBytes(event: AdapterEvent): number { + return UTF8_ENCODER.encode(JSON.stringify(event)).byteLength; +} + +function isReasoningEvent(event: AdapterEvent): boolean { + return event.type === "thinking_delta" + || event.type === "thinking_signature" + || event.type === "redacted_thinking" + || event.type === "kiro_redacted_reasoning" + || event.type === "reasoning_raw_delta"; +} + +function isTerminalEvent( + event: AdapterEvent, +): event is Extract { + return event.type === "done" || event.type === "incomplete" || event.type === "error"; +} /** * Content means something the client can act on: output text or a tool call @@ -104,12 +122,14 @@ export function mergeUsage( const cacheReadInputTokens = sumOptional("cacheReadInputTokens"); const cacheCreationInputTokens = sumOptional("cacheCreationInputTokens"); const reasoningOutputTokens = sumOptional("reasoningOutputTokens"); + const contextTotalTokens = second.contextTotalTokens ?? first.contextTotalTokens; const inputTokens = first.inputTokens + second.inputTokens; const outputTokens = first.outputTokens + second.outputTokens; return { inputTokens, outputTokens, totalTokens: inputTokens + outputTokens, + ...(contextTotalTokens !== undefined ? { contextTotalTokens } : {}), ...(cachedInputTokens !== undefined ? { cachedInputTokens } : {}), ...(cacheReadInputTokens !== undefined ? { cacheReadInputTokens } : {}), ...(cacheCreationInputTokens !== undefined ? { cacheCreationInputTokens } : {}), @@ -147,7 +167,9 @@ export async function* guardEmptyCompletionEventStream( const maxRetries = Math.max(0, Math.floor(options.maxRetries ?? 1)); let source = options.firstEvents; let held: AdapterEvent[] = []; + let heldBytes = 0; let sawContent = false; + let passthrough = false; let retries = 0; let usage: OcxUsage | undefined; @@ -158,6 +180,7 @@ export async function* guardEmptyCompletionEventStream( const releaseHeld = (): AdapterEvent[] => { const released = held; held = []; + heldBytes = 0; return released; }; @@ -168,10 +191,10 @@ export async function* guardEmptyCompletionEventStream( yield event; continue; } - if (sawContent) { + if (sawContent || passthrough) { // Buffered content is already flowing; everything downstream passes - // through. The final done carries usage merged across every attempt. - yield event.type === "done" ? withUsage(event) : event; + // through. Every terminal carries usage merged across every attempt. + yield isTerminalEvent(event) ? withUsage(event) : event; continue; } if (isContentEvent(event)) { @@ -182,7 +205,7 @@ export async function* guardEmptyCompletionEventStream( } if (event.type === "done") { usage = mergeUsage(usage, event.usage); - if (VISIBLE_INCOMPLETE_STOP_REASONS[event.stopReason ?? ""]) { + if (event.stopReason !== undefined && VISIBLE_INCOMPLETE_STOP_REASONS.has(event.stopReason)) { // Rendered as response.incomplete: a stated failure, not the silent // empty success this guard exists to catch. yield* releaseHeld(); @@ -196,7 +219,7 @@ export async function* guardEmptyCompletionEventStream( try { source = await options.continuation(); } catch { - yield emptyCompletionRetryFailedEvent(usage); + yield emptyCompletionRetryFailedEvent(usage, true); return; } terminalSeen = true; @@ -227,7 +250,21 @@ export async function* guardEmptyCompletionEventStream( yield withUsage(event); return; } + const eventBytes = retainedEventBytes(event); + if (held.length + 1 > EMPTY_COMPLETION_MAX_BUFFERED_EVENTS + || heldBytes + eventBytes > EMPTY_COMPLETION_MAX_BUFFERED_BYTES) { + // Preserve data rather than retaining without bound: release the prefix, + // emit this event, and stop attempting an empty-completion retry for the turn. + yield* releaseHeld(); + yield event; + passthrough = true; + continue; + } held.push(event); + heldBytes += eventBytes; + // The bridge watchdog sees only yielded events. Feed it while reasoning is + // held so a long reasoning-only prefix remains live without exposing it early. + if (isReasoningEvent(event)) yield { type: "heartbeat" }; } if (!terminalSeen) { // The source ended without a terminal event (truncated stream). Release diff --git a/src/types.ts b/src/types.ts index f98ed13f81..119b671669 100644 --- a/src/types.ts +++ b/src/types.ts @@ -630,6 +630,8 @@ export interface OcxClientIntegrationsConfig { export interface OcxConfig { port: number; + /** Opt in to one identical-turn retry when a Responses completion has no text or tool call. */ + emptyCompletionRetry?: boolean; /** Maximum usage-log bytes read for one management snapshot. */ managementUsageMaxReadBytes?: number; providers: Record; diff --git a/src/usage/log.ts b/src/usage/log.ts index 7cb6160b11..08cac2d408 100644 --- a/src/usage/log.ts +++ b/src/usage/log.ts @@ -27,7 +27,8 @@ export type AttemptRecoveryKind = | "key-429" | "rate-limit-429" | "anthropic-oauth-429" - | "image-413"; + | "image-413" + | "empty-completion"; export interface PersistedUsageAttempt { ordinal: number; @@ -208,6 +209,7 @@ const ATTEMPT_RECOVERY_KINDS = new Set([ "rate-limit-429", "anthropic-oauth-429", "image-413", + "empty-completion", ]); const USAGE_STATUSES = new Set([ "reported", diff --git a/structure/02_config-and-codex-home.md b/structure/02_config-and-codex-home.md index 101f942033..b1ec14a828 100644 --- a/structure/02_config-and-codex-home.md +++ b/structure/02_config-and-codex-home.md @@ -159,7 +159,7 @@ matters for maintainers is which groups exist and who resolves them: | Routing | `defaultProvider`, `providers`, per-provider `selectedModels` | Explicit `provider/model` wins over `defaultProvider`. | | Catalog | `disabledModels`, `customModels`, `modelCacheTtlMs`, `providerContextCaps`, `contextCapValue`, `codexAccountNamespaces`, `codexAccountPickerEnabled` | Catalog state is derived; config only records intent. The picker flag is an explicit visibility override, while selector mappings remain the durable exact-routing contract. | | Retained state | `appOwnedMemoryBudgetMb` | Process-wide eviction target for app-owned logs, caches, blobs, and continuation payloads. Default 256 MiB, valid 64..4096; pinned state may temporarily exceed the target, but every pin-capable store has a finite local cap and their documented aggregate stays below `APP_OWNED_WORST_CASE_PINNED_BYTES` (512 MiB). Neither value caps RSS or native runtime memory. | -| Transport | stream mode, timeouts, proxy settings, `websockets` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. | +| Transport | stream mode, timeouts, proxy settings, `websockets`, `emptyCompletionRetry` | `streamMode` persists in config.json; Windows services need a persisted input, and macOS uses it for explicit eager-relay opt-in. Empty-completion replay is an explicit top-level opt-in because its second upstream request may be billable. | | Credentials | `apiKeys` | Data-plane only; never admitted to `/api/*`. | | Lifecycle | `codexAutoStart`, shim/start behavior, resume-history sync, storage cleanup | Startup safety reads these; see [`05_gui-and-management-api.md`](05_gui-and-management-api.md). | diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1db9a6fe1d..3e4dd3c92a 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -318,6 +318,15 @@ closes the stream with `response.incomplete` / `upstream_stall_timeout` and canc request if no real adapter events arrive. Adapter-yielded `{ type: "heartbeat" }` events DO reset the watchdog. +Top-level `emptyCompletionRetry: true` opts Responses turns into one identical replay when a +successful upstream completion contains neither output text nor a tool call. The default is off +because the replay may be billable; `OCX_EMPTY_COMPLETION_RETRY=0` is a disable-only emergency +override. Streaming and buffered HTTP adapters plus `runTurn` transports share the same guard, +while combo attempts and routed compaction stay excluded. Pre-content reasoning is retained under +named event-count and byte caps and emits liveness heartbeats while held. A second empty result or +retry failure becomes typed 502 `empty_completion_retry_failed`; usage is merged across sends, and +the Logs attempt records recovery kind `empty-completion`. + The web-search loop requests `stream: true` for every routed-model iteration, but buffers the events needed to decide whether to intercept a synthetic search call. Text explicitly phased as `commentary` is safe to forward live because it cannot terminate the turn; this keeps Kiro's diff --git a/tests/config.test.ts b/tests/config.test.ts index 2e74b37a58..5ec0d918a7 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -93,6 +93,19 @@ function writeAccountNamespaceConfig( } describe("opencodex config defaults", () => { + test("empty-completion retry is an explicit top-level opt-in", () => { + const defaults = getDefaultConfig(); + expect(defaults.emptyCompletionRetry).toBe(false); + expect(validateConfigCandidate({ ...defaults, emptyCompletionRetry: true })).toMatchObject({ + ok: true, + config: { emptyCompletionRetry: true }, + }); + expect(validateConfigCandidate({ ...defaults, emptyCompletionRetry: "true" })).toMatchObject({ + ok: false, + error: expect.stringContaining("emptyCompletionRetry"), + }); + }); + test("usage and MCP config overrides change the effective bound while defaults remain compatible", () => { const defaults = getDefaultConfig(); expect(defaults.managementUsageMaxReadBytes).toBe(64 * 1024 * 1024); diff --git a/tests/empty-completion-core.test.ts b/tests/empty-completion-core.test.ts new file mode 100644 index 0000000000..d7e5151e20 --- /dev/null +++ b/tests/empty-completion-core.test.ts @@ -0,0 +1,254 @@ +import { afterEach, beforeEach, describe, expect, mock, test } from "bun:test"; +import type { ProviderAdapter } from "../src/adapters/base"; +import type { RequestLogContext } from "../src/server/request-log"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const actualResolver = await import("../src/server/adapter-resolve"); +const actualResolveAdapter = actualResolver.resolveAdapter; + +let attemptEvents: AdapterEvent[][] = []; +let runTurnCalls = 0; +let httpCalls = 0; +let parsedAttempts: OcxParsedRequest[] = []; +let builtBodies: string[] = []; +let customRunTurn: ProviderAdapter["runTurn"] | undefined; + +function attemptAt(index: number): AdapterEvent[] { + return attemptEvents[index] ?? [{ type: "error", message: `missing fixture attempt ${index}` }]; +} + +function fixtureAdapter(provider: OcxProviderConfig): ProviderAdapter { + const runTurn = provider.adapter === "test-run-turn"; + return { + name: runTurn ? "test-run-turn" : "openai-chat", + buildRequest(parsed) { + const body = JSON.stringify({ model: parsed.modelId, messages: parsed.context.messages }); + builtBodies.push(body); + return { url: provider.baseUrl, method: "POST", headers: {}, body }; + }, + async fetchResponse() { + const index = httpCalls; + httpCalls += 1; + return new Response("", { headers: { "x-fixture-attempt": String(index) } }); + }, + async *parseStream(response) { + const index = Number(response.headers.get("x-fixture-attempt")); + yield* attemptAt(index); + }, + async parseResponse(response) { + const index = Number(response.headers.get("x-fixture-attempt")); + return attemptAt(index); + }, + ...(runTurn ? { + async runTurn(parsed: OcxParsedRequest, _incoming: unknown, emit: (event: AdapterEvent) => void) { + if (customRunTurn) { + await customRunTurn(parsed, _incoming as never, emit); + return; + } + const index = runTurnCalls; + runTurnCalls += 1; + parsedAttempts.push(parsed); + for (const event of attemptAt(index)) emit(event); + }, + } : {}), + }; +} + +mock.module("../src/server/adapter-resolve", () => ({ + ...actualResolver, + resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { + if (provider.adapter === "test-run-turn" || provider.adapter === "test-http") { + return fixtureAdapter(provider); + } + return actualResolveAdapter(provider, cacheRetention); + }, +})); + +const { handleResponses } = await import("../src/server/responses"); + +function config(adapter: "test-run-turn" | "test-http", extra: Partial = {}): OcxConfig { + return { + port: 0, + defaultProvider: "fixture", + emptyCompletionRetry: true, + providers: { + fixture: { + adapter, + baseUrl: "https://fixture.test/v1", + apiKey: "fixture-key", + authMode: "key", + models: ["model"], + }, + }, + ...extra, + } as OcxConfig; +} + +function request(stream: boolean, input: unknown = "please answer"): Request { + return new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "fixture/model", input, stream }), + }); +} + +beforeEach(() => { + attemptEvents = []; + runTurnCalls = 0; + httpCalls = 0; + parsedAttempts = []; + builtBodies = []; + customRunTurn = undefined; +}); + +afterEach(() => { + delete process.env.OCX_EMPTY_COMPLETION_RETRY; +}); + +describe("empty-completion core integration", () => { + for (const stream of [true, false]) { + test(`runTurn ${stream ? "streaming" : "non-streaming"} retries on a fresh queue`, async () => { + attemptEvents = [ + [{ type: "thinking_delta", thinking: "first" }, { type: "done", usage: { inputTokens: 3, outputTokens: 0 } }], + [{ type: "text_delta", text: "answer" }, { type: "done", usage: { inputTokens: 4, outputTokens: 1 } }], + ]; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(stream), config("test-run-turn"), logCtx); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(runTurnCalls).toBe(2); + expect(parsedAttempts[1]).toBe(parsedAttempts[0]); + expect(body).toContain("answer"); + if (stream) expect(body.match(/event: response\.completed/g)).toHaveLength(1); + expect(logCtx.activeAttempt).toMatchObject({ + sendCount: 2, + recoveryKinds: ["empty-completion"], + usage: { inputTokens: 7, outputTokens: 1, totalTokens: 8 }, + }); + }); + + test(`HTTP ${stream ? "streaming" : "non-streaming"} replays the identical request once`, async () => { + attemptEvents = [ + [{ type: "done", usage: { inputTokens: 2, outputTokens: 0 } }], + [{ type: "text_delta", text: "http answer" }, { type: "done", usage: { inputTokens: 5, outputTokens: 2 } }], + ]; + const logCtx: RequestLogContext = { model: "", provider: "" }; + + const response = await handleResponses(request(stream), config("test-http"), logCtx); + const body = await response.text(); + + expect(response.status).toBe(200); + expect(httpCalls).toBe(2); + expect(builtBodies).toHaveLength(1); + expect(body).toContain("http answer"); + expect(logCtx.activeAttempt).toMatchObject({ + sendCount: 2, + recoveryKinds: ["empty-completion"], + }); + }); + } + + test("the top-level gate defaults off so unrelated empty replay paths keep one send", async () => { + attemptEvents = [ + [{ type: "done" }], + [{ type: "text_delta", text: "must not run" }, { type: "done" }], + ]; + const disabled = config("test-run-turn"); + delete disabled.emptyCompletionRetry; + + const response = await handleResponses(request(false), disabled, { model: "", provider: "" }); + await response.text(); + + expect(runTurnCalls).toBe(1); + }); + + test("buffered reasoning keeps the streaming stall watchdog alive", async () => { + customRunTurn = async (_parsed, _incoming, emit) => { + const index = runTurnCalls; + runTurnCalls += 1; + if (index === 0) { + for (let i = 0; i < 5; i += 1) { + emit({ type: "thinking_delta", thinking: `step-${i}` }); + await new Promise(resolve => setTimeout(resolve, 20)); + } + emit({ type: "done" }); + return; + } + emit({ type: "text_delta", text: "answer after reasoning" }); + emit({ type: "done" }); + }; + + const response = await handleResponses( + request(true), + config("test-run-turn", { stallTimeoutSec: 0.05 }), + { model: "", provider: "" }, + ); + const body = await response.text(); + + expect(runTurnCalls).toBe(2); + expect(body).toContain("answer after reasoning"); + expect(body).not.toContain("upstream_stall_timeout"); + }); + + test("combo and routed-compaction turns are excluded from the retry", async () => { + attemptEvents = [[{ type: "done" }], [{ type: "text_delta", text: "must not run" }, { type: "done" }]]; + const combo = await handleResponses( + request(false), + config("test-run-turn"), + { model: "", provider: "" }, + { comboAttempt: true }, + ); + await combo.text(); + expect(runTurnCalls).toBe(1); + + runTurnCalls = 0; + attemptEvents = [[{ type: "done" }], [{ type: "text_delta", text: "must not run" }, { type: "done" }]]; + const compactionInput = [ + { type: "message", role: "user", content: [{ type: "input_text", text: "earlier" }] }, + { type: "compaction_trigger" }, + ]; + const compaction = await handleResponses( + request(false, compactionInput), + config("test-run-turn"), + { model: "", provider: "" }, + ); + await compaction.text(); + expect(runTurnCalls).toBe(1); + }); + + test("an identical empty retry still receives terminal-guard repair", async () => { + attemptEvents = [ + [{ type: "done" }], + [{ type: "text_delta", text: "我接下来会修改相关文件。" }, { type: "done" }], + [ + { type: "tool_call_start", id: "call_1", name: "exec_command" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + { type: "done" }, + ], + ]; + const guarded = config("test-http"); + guarded.providers.fixture!.terminalContinuationGuard = true; + const req = new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "fixture/model", + input: "请检查这个问题并修复代码", + stream: true, + tools: [{ type: "function", name: "exec_command", parameters: { type: "object" } }], + }), + }); + + const response = await handleResponses(req, guarded, { model: "", provider: "" }); + const body = await response.text(); + + expect(httpCalls).toBe(3); + expect(builtBodies).toHaveLength(2); + expect(builtBodies[1]).not.toBe(builtBodies[0]); + expect(body).toContain("exec_command"); + expect(body.match(/event: response\.completed/g)).toHaveLength(1); + }); +}); diff --git a/tests/empty-completion-guard.test.ts b/tests/empty-completion-guard.test.ts index 26a709c590..3265521b3a 100644 --- a/tests/empty-completion-guard.test.ts +++ b/tests/empty-completion-guard.test.ts @@ -20,6 +20,10 @@ function eventsOf(...items: AdapterEvent[]): AsyncIterable { return (async function* () { yield* items; })(); } +function withoutHeartbeats(events: AdapterEvent[]): AdapterEvent[] { + return events.filter(event => event.type !== "heartbeat"); +} + describe("empty-completion guard content classification", () => { test("output text is content", () => { expect(isContentEvent({ type: "text_delta", text: "hello" })).toBe(true); @@ -51,10 +55,14 @@ describe("empty-completion guard content classification", () => { }); describe("empty-completion guard kill switch", () => { - test("enabled by default, disabled by OCX_EMPTY_COMPLETION_RETRY=0", () => { - expect(emptyCompletionRetryEnabled({})).toBe(true); - expect(emptyCompletionRetryEnabled({ [EMPTY_COMPLETION_RETRY_ENV]: "1" })).toBe(true); - expect(emptyCompletionRetryEnabled({ [EMPTY_COMPLETION_RETRY_ENV]: "0" })).toBe(false); + test("requires top-level config opt-in and lets OCX_EMPTY_COMPLETION_RETRY=0 disable it", () => { + expect(emptyCompletionRetryEnabled({}, {})).toBe(false); + expect(emptyCompletionRetryEnabled({}, { [EMPTY_COMPLETION_RETRY_ENV]: "1" })).toBe(false); + expect(emptyCompletionRetryEnabled({ emptyCompletionRetry: true }, {})).toBe(true); + expect(emptyCompletionRetryEnabled( + { emptyCompletionRetry: true }, + { [EMPTY_COMPLETION_RETRY_ENV]: "0" }, + )).toBe(false); }); }); @@ -74,7 +82,7 @@ describe("empty-completion guard retry", () => { })); expect(continuations).toBe(0); - expect(events).toEqual([ + expect(withoutHeartbeats(events)).toEqual([ { type: "thinking_delta", thinking: "thinking..." }, { type: "text_delta", text: "answer" }, { type: "done", usage: { inputTokens: 10, outputTokens: 2 } }, @@ -83,7 +91,6 @@ describe("empty-completion guard retry", () => { test("a reasoning-only terminal turn is retried once and the identical-turn retry succeeds", async () => { let continuations = 0; - const retryParsedSeen: string[] = []; const events = await collect(guardEmptyCompletionEventStream({ firstEvents: eventsOf( { type: "thinking_delta", thinking: "first attempt" }, @@ -101,10 +108,9 @@ describe("empty-completion guard retry", () => { })); expect(continuations).toBe(1); - expect(retryParsedSeen).toEqual([]); // The first attempt's buffered reasoning is released in order, then the // retry's reasoning, then the content, then the merged-usage terminal. - expect(events).toEqual([ + expect(withoutHeartbeats(events)).toEqual([ { type: "thinking_delta", thinking: "first attempt" }, { type: "reasoning_raw_delta", text: "raw" }, { type: "thinking_delta", thinking: "second attempt" }, @@ -154,8 +160,9 @@ describe("empty-completion guard retry", () => { })); expect(continuations).toBe(1); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ + const meaningful = withoutHeartbeats(events); + expect(meaningful).toHaveLength(1); + expect(meaningful[0]).toMatchObject({ type: "error", status: 502, errorType: "upstream_error", @@ -163,7 +170,7 @@ describe("empty-completion guard retry", () => { usage: { inputTokens: 22, outputTokens: 0, totalTokens: 22 }, }); // The silent completed event never reaches the client. - expect(events.some(event => event.type === "done")).toBe(false); + expect(meaningful.some(event => event.type === "done")).toBe(false); }); test("a failed retry surfaces empty_completion_retry_failed", async () => { @@ -177,8 +184,9 @@ describe("empty-completion guard retry", () => { ), })); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ + const meaningful = withoutHeartbeats(events); + expect(meaningful).toHaveLength(1); + expect(meaningful[0]).toMatchObject({ type: "error", status: 502, errorType: "upstream_error", @@ -216,7 +224,7 @@ describe("empty-completion guard retry", () => { })); expect(continuations).toBe(0); - expect(events).toEqual([ + expect(withoutHeartbeats(events)).toEqual([ { type: "thinking_delta", thinking: "..." }, { type: "done", stopReason: "max_tokens", usage: { inputTokens: 5, outputTokens: 1 } }, ]); @@ -256,7 +264,7 @@ describe("empty-completion guard retry", () => { }, })); expect(continuations).toBe(0); - expect(incomplete).toEqual([ + expect(withoutHeartbeats(incomplete)).toEqual([ { type: "thinking_delta", thinking: "..." }, { type: "incomplete", reason: "content_filter", retryable: false }, ]); @@ -289,8 +297,9 @@ describe("empty-completion guard retry", () => { })); expect(continuations).toBe(0); - expect(events).toHaveLength(1); - expect(events[0]).toMatchObject({ type: "error", code: EMPTY_COMPLETION_RETRY_FAILED_CODE }); + const meaningful = withoutHeartbeats(events); + expect(meaningful).toHaveLength(1); + expect(meaningful[0]).toMatchObject({ type: "error", code: EMPTY_COMPLETION_RETRY_FAILED_CODE }); }); test("heartbeats pass through immediately even before content", async () => { @@ -309,7 +318,7 @@ describe("empty-completion guard retry", () => { expect(continuations).toBe(1); expect(events.map(event => event.type)).toEqual([ - "heartbeat", "thinking_delta", "text_delta", "done", + "heartbeat", "heartbeat", "thinking_delta", "text_delta", "done", ]); }); @@ -324,6 +333,6 @@ describe("empty-completion guard retry", () => { })); expect(continuations).toBe(0); - expect(events).toEqual([{ type: "thinking_delta", thinking: "..." }]); + expect(withoutHeartbeats(events)).toEqual([{ type: "thinking_delta", thinking: "..." }]); }); }); diff --git a/tests/empty-completion-hardening.test.ts b/tests/empty-completion-hardening.test.ts new file mode 100644 index 0000000000..02233fbd51 --- /dev/null +++ b/tests/empty-completion-hardening.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, test } from "bun:test"; +import { + EMPTY_COMPLETION_MAX_BUFFERED_BYTES, + EMPTY_COMPLETION_MAX_BUFFERED_EVENTS, + guardEmptyCompletionEventStream, +} from "../src/server/responses/empty-completion-guard"; +import type { AdapterEvent } from "../src/types"; + +async function collect(source: AsyncIterable): Promise { + const events: AdapterEvent[] = []; + for await (const event of source) events.push(event); + return events; +} + +function eventsOf(...items: AdapterEvent[]): AsyncIterable { + return (async function* () { yield* items; })(); +} + +describe("empty-completion guard hardening", () => { + test("usage keeps the retry's absolute context checkpoint without summing it", async () => { + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "done", usage: { inputTokens: 10, outputTokens: 0, contextTotalTokens: 1_000 } }, + ), + continuation: () => eventsOf( + { type: "text_delta", text: "answer" }, + { type: "done", usage: { inputTokens: 12, outputTokens: 2, contextTotalTokens: 1_014 } }, + ), + })); + + expect(events.at(-1)).toMatchObject({ + type: "done", + usage: { inputTokens: 22, outputTokens: 2, totalTokens: 24, contextTotalTokens: 1_014 }, + }); + }); + + test("inherited Object prototype stop-reason names do not bypass retry", async () => { + for (const stopReason of ["constructor", "toString"]) { + let continuations = 0; + await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf({ type: "done", stopReason }), + continuation: () => { + continuations += 1; + return eventsOf({ type: "text_delta", text: "ok" }, { type: "done" }); + }, + })); + expect(continuations).toBe(1); + } + }); + + test("buffered reasoning emits liveness heartbeats until content is released", async () => { + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: "one" }, + { type: "reasoning_raw_delta", text: "two" }, + { type: "text_delta", text: "answer" }, + { type: "done" }, + ), + continuation: () => eventsOf(), + })); + + expect(events.map(event => event.type)).toEqual([ + "heartbeat", + "heartbeat", + "thinking_delta", + "reasoning_raw_delta", + "text_delta", + "done", + ]); + }); + + test("event-count overflow releases the prefix and disables retry for the turn", async () => { + let continuations = 0; + const reasoning = Array.from({ length: EMPTY_COMPLETION_MAX_BUFFERED_EVENTS + 1 }, (_, index) => ({ + type: "thinking_delta" as const, + thinking: `r${index}`, + })); + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf(...reasoning, { type: "done" }), + continuation: () => { + continuations += 1; + return eventsOf(); + }, + })); + + expect(continuations).toBe(0); + expect(events.filter(event => event.type === "thinking_delta")).toHaveLength(reasoning.length); + expect(events.at(-1)?.type).toBe("done"); + }); + + test("byte overflow remains bounded across the retry attempt", async () => { + let continuations = 0; + const firstBytes = "a".repeat(Math.floor(EMPTY_COMPLETION_MAX_BUFFERED_BYTES / 2)); + const retryBytes = "b".repeat(Math.floor(EMPTY_COMPLETION_MAX_BUFFERED_BYTES / 2) + 1_024); + const events = await collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf( + { type: "thinking_delta", thinking: firstBytes }, + { type: "done", usage: { inputTokens: 3, outputTokens: 0 } }, + ), + continuation: () => { + continuations += 1; + return eventsOf( + { type: "reasoning_raw_delta", text: retryBytes }, + { type: "done", usage: { inputTokens: 4, outputTokens: 0 } }, + ); + }, + })); + + expect(continuations).toBe(1); + expect(events.filter(event => event.type === "thinking_delta")).toHaveLength(1); + expect(events.filter(event => event.type === "reasoning_raw_delta")).toHaveLength(1); + expect(events.at(-1)).toMatchObject({ + type: "done", + usage: { inputTokens: 7, outputTokens: 0, totalTokens: 7 }, + }); + }); + + test("post-retry errors and incompletes after content keep usage from both attempts", async () => { + const retryTerminal = async (terminal: AdapterEvent) => collect(guardEmptyCompletionEventStream({ + firstEvents: eventsOf({ type: "done", usage: { inputTokens: 5, outputTokens: 0 } }), + continuation: () => eventsOf( + { type: "text_delta", text: "partial" }, + terminal, + ), + })); + + const error = await retryTerminal({ + type: "error", + status: 499, + message: "client closed", + usage: { inputTokens: 7, outputTokens: 2 }, + }); + expect(error.at(-1)).toMatchObject({ + type: "error", + status: 499, + usage: { inputTokens: 12, outputTokens: 2, totalTokens: 14 }, + }); + + const incomplete = await retryTerminal({ + type: "incomplete", + reason: "upstream_stall_timeout", + usage: { inputTokens: 9, outputTokens: 3 }, + }); + expect(incomplete.at(-1)).toMatchObject({ + type: "incomplete", + usage: { inputTokens: 14, outputTokens: 3, totalTokens: 17 }, + }); + }); +}); diff --git a/tests/usage-log.test.ts b/tests/usage-log.test.ts index 5a4b905043..2649feaa1b 100644 --- a/tests/usage-log.test.ts +++ b/tests/usage-log.test.ts @@ -113,6 +113,31 @@ describe("usage log", () => { expect(readUsageEntries()[0]?.attempts?.[0]?.recoveryKinds).toEqual(["rate-limit-429"]); }); + test("persists the empty-completion recovery kind on attempts", () => { + const entry: PersistedUsageEntry = { + requestId: "ocx-empty-completion-kind", + timestamp: 1, + provider: "fixture", + model: "fixture/model", + status: 200, + durationMs: 4, + usageStatus: "reported", + attempts: [{ + ordinal: 1, + provider: "fixture", + model: "fixture/model", + adapter: "openai-chat", + status: 200, + durationMs: 4, + sendCount: 2, + recoveryKinds: ["empty-completion", "empty-completion"], + usageStatus: "reported", + }], + }; + appendUsageEntry(entry); + expect(readUsageEntries()[0]?.attempts?.[0]?.recoveryKinds).toEqual(["empty-completion"]); + }); + /** Build one minimal persisted-usage JSONL line for the given request id. */ const persistedLine = (requestId: string) => JSON.stringify({ requestId, From a6fc6b8bfaf3755198d33ad18f8c6e7032423ff8 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 16:00:22 +0900 Subject: [PATCH 23/28] feat(chat): rebuild native chat transport (#1467) --- src/server/chat-completions.ts | 21 +- src/server/chat-native-sse.ts | 331 +++++++++++++++++++++ src/server/chat-native.ts | 380 ++++++++++++++++++++++++ structure/04_transports-and-sidecars.md | 16 + tests/chat-completions-endpoint.test.ts | 329 +++++++++++++++++++- 5 files changed, 1065 insertions(+), 12 deletions(-) create mode 100644 src/server/chat-native-sse.ts create mode 100644 src/server/chat-native.ts diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index 1ed1d4e6d6..a9a0ebfb31 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -2,8 +2,8 @@ * OpenAI Chat Completions inbound (/v1/chat/completions) for GitHub Copilot App * and other OpenAI-compatible clients. * - * Translate-and-replay: Chat Completions body -> /v1/responses via handleResponses, - * then bridge the Responses output back to Chat Completions SSE/JSON. + * Ordinary openai-chat routes send directly on the Chat Completions wire. Routes + * that need Responses-only behavior keep the Chat -> Responses -> Chat bridge. */ import { FORWARD_HEADERS } from "../adapters/openai-responses"; import { ChatCompletionsRequestError, chatCompletionsToResponsesBody } from "../chat/inbound"; @@ -40,6 +40,7 @@ import { isTranslatorBudgetExceededError, type TranslatorBudget, } from "../lib/translator-budget"; +import { handleNativeChatCompletions, isNativeChatRouteEligible } from "./chat-native"; type Rec = Record; @@ -109,6 +110,7 @@ async function handleChatCompletionsWithBudget( let nativeRoute = false; let directRoute = false; + let chatNativeRoute: ReturnType | null = null; try { const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); // Settle the wire once so every branch below reads the adapter this model will @@ -146,6 +148,7 @@ async function handleChatCompletionsWithBudget( if (next === undefined) delete internalBody.reasoning; else internalBody.reasoning = next; } + if (isNativeChatRouteEligible(route, chatBody as Rec)) chatNativeRoute = route; } catch (err) { if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; @@ -156,6 +159,20 @@ async function handleChatCompletionsWithBudget( } void nativeRoute; + if (chatNativeRoute) { + return handleNativeChatCompletions({ + req, + config, + logCtx, + ...(logIds ? { logIds } : {}), + route: chatNativeRoute, + responsesBody: { ...internalBody, stream }, + requestedModel, + requestedStream: stream, + translatorBudget, + }); + } + const headers = new Headers({ "content-type": "application/json" }); for (const name of FORWARD_HEADERS) { if (name === "authorization" && !directRoute) continue; diff --git a/src/server/chat-native-sse.ts b/src/server/chat-native-sse.ts new file mode 100644 index 0000000000..4d197d5a58 --- /dev/null +++ b/src/server/chat-native-sse.ts @@ -0,0 +1,331 @@ +import { chatCompletionsErrorBody } from "../chat/outbound"; +import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; +import { redactSecretString } from "../lib/redact"; +import { + isTranslatorBudgetExceededError, + TRANSLATOR_MAX_SSE_EVENT_BYTES, + type TranslatorBudget, +} from "../lib/translator-budget"; +import type { OcxUsage } from "../types"; +import { nextSseBlock, replaceSseDataPayload, sseDataPayload } from "./sse-payload-rewrite"; + +type Rec = Record; + +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function usageFromChat(value: unknown): OcxUsage | undefined { + if (!isRec(value)) return undefined; + const prompt = typeof value.prompt_tokens === "number" ? value.prompt_tokens : undefined; + const completion = typeof value.completion_tokens === "number" ? value.completion_tokens : undefined; + if (prompt === undefined && completion === undefined) return undefined; + const promptDetails = isRec(value.prompt_tokens_details) ? value.prompt_tokens_details : undefined; + const completionDetails = isRec(value.completion_tokens_details) ? value.completion_tokens_details : undefined; + return { + inputTokens: prompt ?? 0, + outputTokens: completion ?? 0, + ...(typeof promptDetails?.cached_tokens === "number" ? { cachedInputTokens: promptDetails.cached_tokens } : {}), + ...(typeof completionDetails?.reasoning_tokens === "number" + ? { reasoningOutputTokens: completionDetails.reasoning_tokens } + : {}), + }; +} + +export function structuredError(value: unknown): { + message: string; + type?: string; + code?: string | null; + status?: number; +} | null { + if (!isRec(value) || value.error === undefined || value.error === null) return null; + const error = value.error; + if (typeof error === "string") return { message: redactSecretString(error) }; + if (!isRec(error)) return { message: "upstream error" }; + const rawMessage = typeof error.message === "string" ? error.message : "upstream error"; + return { + message: redactSecretString(rawMessage), + ...(typeof error.type === "string" ? { type: error.type } : {}), + ...(error.code === null || typeof error.code === "string" ? { code: error.code } : {}), + ...(typeof error.status === "number" && Number.isInteger(error.status) ? { status: error.status } : {}), + }; +} + +function normalizedChunk(value: Rec, requestedModel: string): Rec { + return { + ...value, + id: typeof value.id === "string" ? value.id : `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`, + object: "chat.completion.chunk", + created: typeof value.created === "number" ? value.created : Math.floor(Date.now() / 1000), + model: requestedModel, + }; +} + +export function jsonCompletionSse(value: Rec, requestedModel: string): string { + const id = typeof value.id === "string" ? value.id : `chatcmpl-${crypto.randomUUID().replace(/-/g, "").slice(0, 24)}`; + const created = typeof value.created === "number" ? value.created : Math.floor(Date.now() / 1000); + const model = requestedModel; + const choices = Array.isArray(value.choices) ? value.choices : []; + const choice = isRec(choices[0]) ? choices[0] : {}; + const message = isRec(choice.message) ? choice.message : {}; + const frames: Rec[] = [{ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: { role: "assistant", content: "" }, finish_reason: null }], + }]; + const delta: Rec = {}; + if (typeof message.content === "string" && message.content.length > 0) delta.content = message.content; + if (typeof message.reasoning_content === "string" && message.reasoning_content.length > 0) { + delta.reasoning_content = message.reasoning_content; + } + if (Array.isArray(message.tool_calls) && message.tool_calls.length > 0) { + delta.tool_calls = message.tool_calls.map((tool, index) => isRec(tool) ? { index, ...tool } : tool); + } + if (Object.keys(delta).length > 0) { + frames.push({ id, object: "chat.completion.chunk", created, model, choices: [{ index: 0, delta, finish_reason: null }] }); + } + frames.push({ + id, + object: "chat.completion.chunk", + created, + model, + choices: [{ index: 0, delta: {}, finish_reason: typeof choice.finish_reason === "string" ? choice.finish_reason : "stop" }], + ...(value.usage !== undefined ? { usage: value.usage } : {}), + }); + return `${frames.map(frame => `data: ${JSON.stringify(frame)}\n\n`).join("")}data: [DONE]\n\n`; +} + +interface NativeChatSseOptions { + requestedModel: string; + translatorBudget: TranslatorBudget; + signal: AbortSignal; + onFirstOutput?: () => void; + onUsage: (usage: OcxUsage) => void; + onTerminal?: (status: number, message?: string) => void; + onCancel?: () => void; +} + +export function nativeChatSse( + body: ReadableStream, + options: NativeChatSseOptions, +): ReadableStream { + const reader = body.getReader(); + const decoder = new TextDecoder(); + const encoder = new TextEncoder(); + const scope = { kind: "live_transient" as const }; + let buffer = ""; + let bufferBytes = 0; + let queuedBytes = 0; + let sawFinish = false; + let sawDone = false; + let firstOutput = false; + let settled = false; + let cancelled = false; + let cancelledBySignal = false; + + const releaseQueued = () => { + if (queuedBytes === 0) return; + options.translatorBudget.releaseRetained(queuedBytes, scope); + queuedBytes = 0; + }; + const replaceBuffer = (next: string) => { + const nextBytes = encoder.encode(next).byteLength; + const reservation = options.translatorBudget.reserveTransient(nextBytes, scope); + reservation.commitRetained(); + options.translatorBudget.releaseRetained(bufferBytes, scope); + buffer = next; + bufferBytes = nextBytes; + }; + const appendBuffer = (fragment: string) => { + if (!fragment) return; + const fragmentBytes = encoder.encode(fragment).byteLength; + const nextBytes = bufferBytes + fragmentBytes; + if (nextBytes > TRANSLATOR_MAX_SSE_EVENT_BYTES) { + throw new Error("upstream SSE event exceeded the safe limit", { cause: { code: "translation_buffer_limit" } }); + } + const reservation = options.translatorBudget.reserveTransient(nextBytes, scope); + try { + buffer += fragment; + reservation.commitRetained(); + options.translatorBudget.releaseRetained(bufferBytes, scope); + bufferBytes = nextBytes; + } catch (error) { + reservation.release(); + throw error; + } + }; + const enqueue = (controller: ReadableStreamDefaultController, text: string) => { + const bytes = encoder.encode(text); + const reservation = options.translatorBudget.reserveTransient(bytes.byteLength, scope); + controller.enqueue(bytes); + reservation.commitRetained(); + queuedBytes += bytes.byteLength; + }; + const releaseBuffer = () => { + options.translatorBudget.releaseRetained(bufferBytes, scope); + buffer = ""; + bufferBytes = 0; + }; + const settle = (status: number, message?: string) => { + if (settled) return; + settled = true; + options.signal.removeEventListener("abort", onAbort); + options.onTerminal?.(status, message); + }; + const fail = ( + controller: ReadableStreamDefaultController, + message: string, + code: string, + status = 502, + type = "upstream_error", + ) => { + const safeMessage = redactSecretString(message); + try { + const frame = `data: ${JSON.stringify(chatCompletionsErrorBody(status, safeMessage, type, code))}\n\n`; + if (code === "translation_buffer_limit") controller.enqueue(encoder.encode(frame)); + else enqueue(controller, frame); + } catch { /* controller may already be closed */ } + releaseBuffer(); + settle(status, safeMessage); + try { void reader.cancel(new Error(safeMessage)).catch(() => {}); } catch { /* already closed */ } + try { controller.close(); } catch { /* already closed */ } + }; + const processBlock = ( + controller: ReadableStreamDefaultController, + block: string, + delimiter: string, + ): void => { + const payload = sseDataPayload(block); + if (payload === null) { + enqueue(controller, block + delimiter); + return; + } + const trimmed = payload.trim(); + if (trimmed === "[DONE]") { + sawDone = true; + enqueue(controller, replaceSseDataPayload(block, "[DONE]") + delimiter); + settle(200); + try { void reader.cancel().catch(() => {}); } catch { /* already closed */ } + controller.close(); + return; + } + let parsed: unknown; + try { + parsed = JSON.parse(payload); + } catch { + fail(controller, "upstream returned malformed SSE JSON", "upstream_sse_invalid"); + return; + } + if (!isRec(parsed)) { + fail(controller, "upstream returned an invalid SSE event", "upstream_sse_invalid"); + return; + } + const error = structuredError(parsed); + if (error) { + const status = error.status ?? 502; + const classified = classifyError(status, error.type ?? "upstream_error", error.message); + if (isCyberPolicyCode(error.code)) { + classified.code = CYBER_POLICY_ERROR_CODE; + classified.type = "invalid_request_error"; + } else if (error.code !== undefined && error.code !== null) { + classified.code = error.code; + } + const safe = chatCompletionsErrorBody(status, classified.message, classified.type, classified.code); + enqueue(controller, replaceSseDataPayload(block, JSON.stringify(safe)) + delimiter); + settle(isCyberPolicyCode(classified.code) ? 400 : status, classified.message); + try { void reader.cancel(new Error(classified.message)).catch(() => {}); } catch { /* already closed */ } + controller.close(); + return; + } + const usage = usageFromChat(parsed.usage); + if (usage) options.onUsage(usage); + const choices = Array.isArray(parsed.choices) ? parsed.choices : []; + if (choices.some(choice => isRec(choice) && typeof choice.finish_reason === "string" && choice.finish_reason.length > 0)) { + sawFinish = true; + } + if (!firstOutput && choices.some(choice => { + if (!isRec(choice) || !isRec(choice.delta)) return false; + const delta = choice.delta; + return (typeof delta.content === "string" && delta.content.length > 0) + || (typeof delta.reasoning_content === "string" && delta.reasoning_content.length > 0) + || (Array.isArray(delta.tool_calls) && delta.tool_calls.length > 0); + })) { + firstOutput = true; + options.onFirstOutput?.(); + } + enqueue(controller, replaceSseDataPayload(block, JSON.stringify(normalizedChunk(parsed, options.requestedModel))) + delimiter); + }; + + function onAbort() { + cancelledBySignal = true; + if (!settled) { + settled = true; + options.onCancel?.(); + } + try { void reader.cancel(options.signal.reason).catch(() => {}); } catch { /* already closed */ } + } + if (options.signal.aborted) onAbort(); + else options.signal.addEventListener("abort", onAbort, { once: true }); + + return new ReadableStream({ + async pull(controller) { + releaseQueued(); + try { + for (;;) { + const next = nextSseBlock(buffer); + if (next) { + replaceBuffer(next.rest); + processBlock(controller, next.block, next.delimiter); + return; + } + const { done, value } = await reader.read(); + if (cancelled) return; + if (cancelledBySignal) { + releaseBuffer(); + controller.close(); + return; + } + if (!done) { + appendBuffer(decoder.decode(value, { stream: true })); + continue; + } + appendBuffer(decoder.decode()); + if (buffer.trim().length > 0) { + fail(controller, "upstream SSE ended with an unterminated event", "upstream_sse_unterminated"); + return; + } + releaseBuffer(); + if (sawDone || sawFinish) { + if (!sawDone) enqueue(controller, "data: [DONE]\n\n"); + settle(200); + controller.close(); + } else { + fail(controller, "upstream SSE ended before a terminal event", "upstream_sse_truncated"); + } + return; + } + } catch (error) { + const overflow = isTranslatorBudgetExceededError(error) + || (error instanceof Error && (error.cause as { code?: unknown } | undefined)?.code === "translation_buffer_limit"); + fail( + controller, + overflow ? "upstream SSE event exceeded the safe limit" : error instanceof Error ? error.message : String(error), + overflow ? "translation_buffer_limit" : "upstream_sse_error", + ); + } + }, + cancel(reason) { + cancelled = true; + releaseQueued(); + releaseBuffer(); + options.signal.removeEventListener("abort", onAbort); + if (!settled) { + settled = true; + options.onCancel?.(); + } + try { return reader.cancel(reason).then(() => undefined, () => undefined); } catch { return Promise.resolve(); } + }, + }); +} diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts new file mode 100644 index 0000000000..80164885c5 --- /dev/null +++ b/src/server/chat-native.ts @@ -0,0 +1,380 @@ +import { createOpenAIChatAdapter } from "../adapters/openai-chat"; +import type { AdapterRequest, ProviderAdapter } from "../adapters/base"; +import { + chatCompletionsErrorBody, + chatCompletionsErrorResponse, + collectChatCompletion, + isChatCompletionsStreamError, +} from "../chat/outbound"; +import { classifyError, CYBER_POLICY_ERROR_CODE, isCyberPolicyCode } from "../lib/errors"; +import { readBoundedResponseBody } from "../lib/bounded-body"; +import { redactSecretString } from "../lib/redact"; +import { resolveClientRetryAfter } from "../lib/retry-after"; +import { + applyUpstreamRecoveryInit, + fetchWithResetRetry, + prepareSameTarget429Wait, + type UpstreamSendRecovery, +} from "../lib/upstream-retry"; +import { + isTranslatorBudgetExceededError, + type TranslatorBudget, +} from "../lib/translator-budget"; +import { + hasKeyPoolFailover, + rateLimitRetryDelayMs, + rateLimitRetryPolicyFor, + rotateProviderTransportOn429, +} from "../providers/key-failover"; +import { parseRequest } from "../responses/parser"; +import type { RouteResult } from "../router"; +import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; +import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers"; +import { linkAbortSignal } from "./responses"; +import { + addFinalRequestLog, + beginRequestAttempt, + noteAttemptSend, + recordFirstOutput, + sealRequestAttemptIdentity, + type RequestLogContext, +} from "./request-log"; +import { jsonCompletionSse, nativeChatSse, structuredError, usageFromChat } from "./chat-native-sse"; + +type Rec = Record; + +const MAX_NATIVE_CHAT_JSON_BYTES = 32 * 1024 * 1024; +const MAX_NATIVE_CHAT_ERROR_BYTES = 64 * 1024; + +function isRec(value: unknown): value is Rec { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +export function isNativeChatRouteEligible(route: RouteResult, rawBody: Rec): boolean { + const provider = route.provider; + if (provider.adapter !== "openai-chat") return false; + if (provider.authMode !== undefined && provider.authMode !== "key" && provider.authMode !== "local") return false; + // Combo and policy execution own multi-candidate retries in the Responses pipeline. + if (route.combo || route.routeKind === "combo" || route.routeKind === "policy") return false; + if (rawBody.store === true || rawBody.background === true) return false; + if (typeof rawBody.previous_response_id === "string" && rawBody.previous_response_id.length > 0) return false; + if (rawBody.compaction_trigger !== undefined) return false; + if (Array.isArray(rawBody.tools)) { + for (const tool of rawBody.tools) { + if (!isRec(tool)) continue; + if (tool.type === "web_search" || tool.type === "web_search_preview" || tool.type === "image_generation") { + return false; + } + } + } + return true; +} + +function chatCompletionJson(value: unknown): Rec | null { + if (!isRec(value) || !Array.isArray(value.choices) || value.choices.length === 0) return null; + return value; +} + +function prepareParsedRequest(body: Rec, route: RouteResult, stream: boolean): OcxParsedRequest { + const raw = { ...body, model: route.modelId, stream }; + const parsed = parseRequest(raw); + parsed.modelId = route.modelId; + parsed.stream = stream; + parsed._rawBody = raw; + return parsed; +} + +interface HandleNativeChatOptions { + req: Request; + config: OcxConfig; + logCtx: RequestLogContext; + logIds?: { requestId: string; start: number }; + route: RouteResult; + responsesBody: Rec; + requestedModel: string; + requestedStream: boolean; + translatorBudget: TranslatorBudget; +} + +export async function handleNativeChatCompletions(options: HandleNativeChatOptions): Promise { + const { req, config, logCtx, logIds, route, requestedModel, requestedStream, translatorBudget } = options; + logCtx.inboundProtocol = "chat"; + const attempt = beginRequestAttempt( + (logCtx.attempts?.length ?? 0) + 1, + route.providerName, + route.modelId, + "openai-chat", + ); + logCtx.activeAttempt = attempt; + logCtx.activeAttemptStartedAt = Date.now(); + (logCtx.attempts ??= []).push(attempt); + sealRequestAttemptIdentity(attempt, route.providerName, "openai-chat", logCtx.accountLogLabel); + + let logged = false; + const finishLog = (status: number, message?: string, closeReason: "non_stream" | "terminal" | "client_cancel" = "non_stream") => { + if (logged) return; + logged = true; + if (message) logCtx.upstreamError = redactSecretString(message).slice(0, 500); + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason }); + }; + const fail = (status: number, message: string, type?: string, code?: string | null): Response => { + const safeMessage = redactSecretString(message); + finishLog(status, safeMessage); + return chatCompletionsErrorResponse(status, safeMessage, type, code); + }; + + let parsed: OcxParsedRequest; + try { + parsed = prepareParsedRequest(options.responsesBody, route, requestedStream); + } catch (error) { + return fail(400, error instanceof Error ? error.message : String(error), "invalid_request_error"); + } + logCtx.requestedEffort = parsed.options.reasoning; + logCtx.requestedServiceTier = parsed.options.serviceTier; + + const upstream = new AbortController(); + const cleanupAbort = linkAbortSignal(upstream, req.signal); + const connectMs = config.connectTimeoutMs ?? 200_000; + let activeProvider: OcxProviderConfig = route.provider; + let activeAdapter: ProviderAdapter = createOpenAIChatAdapter(activeProvider); + let activeRequest: AdapterRequest; + let retainedRequestBytes = 0; + const releaseRetainedRequest = () => { + if (retainedRequestBytes === 0) return; + translatorBudget.releaseRetained(retainedRequestBytes, { kind: "request_copies" }); + retainedRequestBytes = 0; + }; + const retainRequest = (request: AdapterRequest) => { + const bytes = new TextEncoder().encode(request.body).byteLength; + translatorBudget.chargeRetained(bytes, { kind: "request_copies" }); + retainedRequestBytes = bytes; + }; + try { + activeRequest = await activeAdapter.buildRequest(parsed, { headers: new Headers(), translatorBudget, abortSignal: upstream.signal }); + retainRequest(activeRequest); + } catch (error) { + releaseRetainedRequest(); + cleanupAbort(); + upstream.abort(); + if (isTranslatorBudgetExceededError(error)) { + return fail(413, "request translation buffer exceeded the safe limit", "request_too_large", "translation_buffer_limit"); + } + return fail(400, error instanceof Error ? error.message : String(error), "invalid_request_error"); + } + + const send = async (request: AdapterRequest, recovery?: "rate-limit-429" | "key-429"): Promise => { + try { + return await fetchWithResetRetry( + (transportRecovery?: UpstreamSendRecovery) => { + noteAttemptSend(attempt, logCtx.usageLogInputTokens, transportRecovery ?? recovery); + return fetchWithHeaderTimeout( + request.url, + applyUpstreamRecoveryInit({ + method: request.method, + headers: request.headers, + body: request.body, + }, transportRecovery), + upstream.signal, + connectMs, + requestedStream, + providerFetch(activeProvider), + ); + }, + { abortSignal: upstream.signal, label: safeHostLabel(request.url) }, + ); + } finally { + request.releaseBodyObservation?.(); + } + }; + + let response: Response; + try { + response = await send(activeRequest); + const retryPolicy = rateLimitRetryPolicyFor(activeProvider); + let retries = 0; + while (response.status === 429 && retryPolicy && retries < retryPolicy.attempts) { + retries += 1; + for await (const _ of prepareSameTarget429Wait({ + body: response.body, + signal: upstream.signal, + delayMs: rateLimitRetryDelayMs(retryPolicy, response.headers.get("retry-after"), Date.now()), + })) { /* pre-stream wait */ } + if (upstream.signal.aborted) throw upstream.signal.reason; + response = await send(activeRequest, "rate-limit-429"); + } + while (response.status === 429 && hasKeyPoolFailover(activeProvider)) { + const rotated = rotateProviderTransportOn429(config, route.providerName, activeProvider, { + retryAfter: response.headers.get("retry-after"), + now: Date.now(), + attemptedKey: activeProvider.apiKey, + promptCacheKey: parsed.options.promptCacheKey, + }); + if (!rotated) break; + try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } + activeProvider = rotated; + activeAdapter = createOpenAIChatAdapter(activeProvider); + releaseRetainedRequest(); + activeRequest = await activeAdapter.buildRequest(parsed, { headers: new Headers(), translatorBudget, abortSignal: upstream.signal }); + retainRequest(activeRequest); + response = await send(activeRequest, "key-429"); + } + } catch (error) { + releaseRetainedRequest(); + cleanupAbort(); + upstream.abort(); + if (req.signal.aborted) return fail(499, "Client cancelled request", "client_cancelled"); + return fail(502, error instanceof Error ? error.message : String(error), "server_error"); + } + releaseRetainedRequest(); + + if (!response.ok) { + let bodyText = ""; + try { + const body = await readBoundedResponseBody(response, { + signal: upstream.signal, + maxBytes: MAX_NATIVE_CHAT_ERROR_BYTES, + }); + if (body.displaySafe) bodyText = body.text; + } catch { /* status-only fallback */ } + cleanupAbort(); + if (req.signal.aborted) { + upstream.abort(); + return fail(499, "Client cancelled request", "client_cancelled"); + } + const detail = activeAdapter.formatErrorBody?.(response.status, response.headers, bodyText) ?? ""; + let upstreamType: string | undefined; + let upstreamCode: string | null | undefined; + try { + const parsedError = JSON.parse(bodyText) as Rec; + const nested = isRec(parsedError.error) ? parsedError.error : undefined; + if (typeof nested?.type === "string") upstreamType = nested.type; + if (nested?.code === null || typeof nested?.code === "string") upstreamCode = nested.code; + } catch { /* keep generic classification */ } + const message = detail ? `Provider error ${response.status}: ${detail}` : `Provider error ${response.status}`; + const classified = classifyError( + response.status, + upstreamType ?? (response.status === 401 ? "authentication_error" + : response.status === 429 ? "rate_limit_error" + : response.status >= 500 ? "server_error" : "invalid_request_error"), + message, + ); + if (isCyberPolicyCode(upstreamCode)) { + classified.code = CYBER_POLICY_ERROR_CODE; + classified.type = "invalid_request_error"; + } else if (upstreamCode === "model_not_found") { + classified.code = "model_not_found"; + classified.type = "invalid_request_error"; + } else if (upstreamCode !== undefined && upstreamCode !== null && classified.code == null) { + classified.code = upstreamCode; + } + const status = isCyberPolicyCode(classified.code) ? 400 : response.status; + const retryAfter = resolveClientRetryAfter({ + status: response.status, + message: classified.message, + upstreamRetryAfter: response.headers.get("retry-after"), + }); + finishLog(status, classified.message); + return new Response(JSON.stringify(chatCompletionsErrorBody(status, classified.message, classified.type, classified.code)), { + status, + headers: { + "Content-Type": "application/json", + ...(retryAfter ? { "Retry-After": retryAfter } : {}), + }, + }); + } + + const contentType = response.headers.get("content-type")?.toLowerCase() ?? ""; + if (contentType.includes("text/event-stream") && response.body) { + const stream = nativeChatSse(response.body, { + requestedModel, + translatorBudget, + signal: upstream.signal, + onFirstOutput: logIds ? () => recordFirstOutput(logCtx, logIds.start) : undefined, + onUsage: usage => { + logCtx.usage = usage; + attempt.usage = usage; + }, + ...(requestedStream ? { + onTerminal: (status: number, message?: string) => { + cleanupAbort(); + finishLog(status, message, "terminal"); + }, + onCancel: () => { + cleanupAbort(); + upstream.abort(); + finishLog(499, undefined, "client_cancel"); + }, + } : {}), + }); + if (requestedStream) { + return new Response(stream, { + status: 200, + headers: { + "Content-Type": "text/event-stream; charset=utf-8", + "Cache-Control": "no-cache", + Connection: "keep-alive", + }, + }); + } + try { + const completion = await collectChatCompletion(stream, requestedModel, translatorBudget); + cleanupAbort(); + finishLog(200); + return Response.json(completion); + } catch (error) { + cleanupAbort(); + upstream.abort(); + if (isChatCompletionsStreamError(error)) { + return fail(error.status, error.message, error.type, error.code); + } + return fail(502, error instanceof Error ? error.message : String(error), "upstream_error"); + } + } + + let body; + try { + body = await readBoundedResponseBody(response, { + signal: upstream.signal, + maxBytes: MAX_NATIVE_CHAT_JSON_BYTES, + totalTimeoutMs: Math.max(connectMs, 5_000), + inactivityTimeoutMs: Math.max(connectMs, 5_000), + }); + } catch (error) { + cleanupAbort(); + upstream.abort(); + if (req.signal.aborted) return fail(499, "Client cancelled request", "client_cancelled"); + return fail(502, error instanceof Error ? error.message : String(error), "upstream_error"); + } + cleanupAbort(); + if (body.oversized) { + upstream.abort(); + return fail(502, "upstream response exceeded the safe limit", "upstream_error", "translation_buffer_limit"); + } + let parsedJson: unknown; + try { + parsedJson = JSON.parse(body.text); + } catch { + return fail(502, "upstream returned malformed Chat Completions JSON", "upstream_error"); + } + const error = structuredError(parsedJson); + if (error) return fail(error.status ?? 502, error.message, error.type, error.code); + const completion = chatCompletionJson(parsedJson); + if (!completion) return fail(502, "upstream response contained no choices", "upstream_error"); + const usage = usageFromChat(completion.usage); + if (usage) { + logCtx.usage = usage; + attempt.usage = usage; + } + if (logIds) recordFirstOutput(logCtx, logIds.start); + finishLog(200); + if (requestedStream) { + return new Response(jsonCompletionSse(completion, requestedModel), { + status: 200, + headers: { "Content-Type": "text/event-stream; charset=utf-8", "Cache-Control": "no-cache" }, + }); + } + return new Response(JSON.stringify(completion), { + status: 200, + headers: { "Content-Type": "application/json" }, + }); +} diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 1db9a6fe1d..86abf28b62 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -612,6 +612,22 @@ Spend arrives in `meteringEvent` as **credits, not tokens**. No captured respons `tokenUsage` on any event, which is why Kiro usage stays estimated; `meteringEvent` is currently ignored because a credit is not a token count. +## Chat Completions inbound native path + +`POST /v1/chat/completions` sends eligible `openai-chat` routes directly to the provider's Chat +Completions endpoint. Request construction remains owned by `src/adapters/openai-chat.ts`, including +model normalization, provider headers, capability-specific fields, and the canonical +`openaiChatCompletionsUrl()` path. Combo/policy routes and requests that need Responses-only hosted +tools, continuation, background, or storage semantics retain the existing Chat -> Responses -> Chat +bridge. + +The direct SSE relay accepts CRLF and arbitrary transport chunk boundaries while retaining at most +one bounded event. EOF with an unterminated event and an event above the translator limit are typed +upstream failures, never successful partial completions. Provider-controlled structured error +messages are redacted before either JSON or SSE reaches the client. The native path uses the same +request-attempt logging, reset retry, same-key 429 replay, key rotation, usage extraction, and +request-signal cancellation contracts as routed Responses transport. + ## Parallel tool calls (default-on for chat providers) The openai-chat adapter buffers ALL streamed `tool_calls` deltas (keyed by `index`, falling back to diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index 00276cb466..469e9b107e 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -799,8 +799,8 @@ test("chat-native non-stream fold preserves tool calls and finish reason", async }); -test("chat-native non-stream budget overflow returns 413 without hanging", async () => { - // Two sequential requests: first overflows the bounded JSON reader (413), second proves server still responsive. +test("chat-native non-stream upstream overflow returns 502 without hanging", async () => { + // Provider-controlled overflow is a 502; the second request proves the reader was released. let calls = 0; const upstream = Bun.serve({ port: 0, @@ -813,7 +813,7 @@ test("chat-native non-stream budget overflow returns 413 without hanging", async return Response.json({ id: "chatcmpl_ok", object: "chat.completion", choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], usage: { prompt_tokens: 1, completion_tokens: 1 } }); }, }); - saveConfig(mockConfig(`${upstream.url.toString().replace(/\/\$/, "")}/v1`)); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); const server = startServer(0); try { const response = await fetch(new URL("/v1/chat/completions", server.url), { @@ -821,7 +821,7 @@ test("chat-native non-stream budget overflow returns 413 without hanging", async headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "mock/test-model", stream: false, messages: [{ role: "user", content: "hi" }] }), }); - expect(response.status).toBe(413); + expect(response.status).toBe(502); const json = await response.json() as { error?: { code?: string } }; expect(json.error?.code).toBe("translation_buffer_limit"); const response2 = await fetch(new URL("/v1/chat/completions", server.url), { @@ -838,15 +838,14 @@ test("chat-native non-stream budget overflow returns 413 without hanging", async } }); -test("chat-native non-stream invalid JSON is forwarded as 200 passthrough", async () => { - // The native JSON path forwards verbatim when parsing fails (bridges legacy behavior for untrusted upstreams). +test("chat-native non-stream invalid JSON returns a provider error", async () => { const upstream = Bun.serve({ port: 0, fetch() { return new Response("not-json-at-all", { headers: { "content-type": "application/json" } }); }, }); - saveConfig(mockConfig(`${upstream.url.toString().replace(/\/\$/, "")}/v1`)); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); const server = startServer(0); try { const response = await fetch(new URL("/v1/chat/completions", server.url), { @@ -854,8 +853,318 @@ test("chat-native non-stream invalid JSON is forwarded as 200 passthrough", asyn headers: { "content-type": "application/json" }, body: JSON.stringify({ model: "mock/test-model", stream: false, messages: [{ role: "user", content: "hi" }] }), }); + expect(response.status).toBe(502); + const json = await response.json() as { error?: { type?: string } }; + expect(json.error?.type).toBe("upstream_error"); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-native uses the shared request builder and normalized Chat Completions URL", async () => { + const captured: Array<{ pathname: string; body: Record }> = []; + const upstream = Bun.serve({ + port: 0, + async fetch(req) { + captured.push({ pathname: new URL(req.url).pathname, body: await req.json() as Record }); + return Response.json({ + id: "chatcmpl_builder", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + }); + }, + }); + const endpointBase = `${upstream.url.toString().replace(/\/$/, "")}/v1/chat/completions/`; + saveConfig(mockConfig(endpointBase, { noTemperatureModels: ["test-model"] })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: false, + temperature: 0.9, + messages: [{ role: "user", content: "hi" }], + }), + }); + expect(response.status).toBe(200); + expect(captured).toHaveLength(1); + expect(captured[0]?.pathname).toBe("/v1/chat/completions"); + expect(captured[0]?.body.model).toBe("test-model"); + expect(captured[0]?.body.temperature).toBeUndefined(); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-native streaming accepts CRLF events split across transport chunks", async () => { + const encoder = new TextEncoder(); + const upstream = Bun.serve({ + port: 0, + fetch() { + return new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"choices":[{"index":0,"delta":{"content":"split"}}]}\r')); + controller.enqueue(encoder.encode('\n\r')); + controller.enqueue(encoder.encode('\ndata: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\r\n')); + controller.enqueue(encoder.encode('\r\ndata: [DONE]\r\n\r\n')); + controller.close(); + }, + }), { headers: { "content-type": "text/event-stream" } }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: true, messages: [{ role: "user", content: "hi" }] }), + }); + const text = await response.text(); expect(response.status).toBe(200); - expect(await response.text()).toBe("not-json-at-all"); + expect(text).toContain('"content":"split"'); + expect(text).toContain('"object":"chat.completion.chunk"'); + expect(text).toContain("data: [DONE]"); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-native streaming rejects an unterminated SSE event", async () => { + const upstream = Bun.serve({ + port: 0, + fetch() { + return new Response('data: {"choices":[{"index":0,"delta":{"content":"partial"}}]}', { + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: true, messages: [{ role: "user", content: "hi" }] }), + }); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain('"code":"upstream_sse_unterminated"'); + expect(text).not.toContain("data: [DONE]"); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-native streaming bounds an oversized unterminated SSE event", async () => { + let calls = 0; + const upstream = Bun.serve({ + port: 0, + fetch() { + calls += 1; + if (calls === 1) { + return new Response(`data: ${"x".repeat(33 * 1024 * 1024)}`, { + headers: { "content-type": "text/event-stream" }, + }); + } + return new Response('data: {"choices":[{"index":0,"delta":{},"finish_reason":"stop"}]}\n\ndata: [DONE]\n\n', { + headers: { "content-type": "text/event-stream" }, + }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const first = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: true, messages: [{ role: "user", content: "hi" }] }), + }); + const firstText = await first.text(); + expect(firstText).toContain('"code":"translation_buffer_limit"'); + expect(firstText).not.toContain("data: [DONE]"); + + const second = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: true, messages: [{ role: "user", content: "hi" }] }), + }); + expect(await second.text()).toContain("data: [DONE]"); + expect(calls).toBe(2); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-native redacts structured provider errors before returning them", async () => { + const echoedSecret = "Authorization: Bearer opaquecredential123456"; + const upstream = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + error: { + message: `upstream echoed ${echoedSecret}`, + type: "authentication_error", + code: "invalid_api_key", + }, + }, { status: 401 }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: false, messages: [{ role: "user", content: "hi" }] }), + }); + const text = await response.text(); + expect(response.status).toBe(401); + expect(text).toContain("Bearer [REDACTED]"); + expect(text).not.toContain("opaquecredential123456"); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-native preserves same-key retry, key rotation, usage, and request logging", async () => { + const { clearRequestLogsForTests, getRequestLogEntries } = await import("../src/server/request-log"); + const { clearKeyCooldowns } = await import("../src/providers/key-failover"); + clearRequestLogsForTests(); + clearKeyCooldowns("mock"); + const authorizations: Array = []; + const upstream = Bun.serve({ + port: 0, + fetch(req) { + authorizations.push(req.headers.get("authorization")); + if (authorizations.length < 3) { + return Response.json({ error: { message: "rate limited", type: "rate_limit_error" } }, { + status: 429, + headers: { "retry-after": "0" }, + }); + } + return Response.json({ + id: "chatcmpl_retry", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "ok" }, finish_reason: "stop" }], + usage: { prompt_tokens: 4, completion_tokens: 2 }, + }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`, { + authMode: "key", + apiKey: "key-one", + apiKeyPool: [{ id: "one", key: "key-one" }, { id: "two", key: "key-two" }], + retryOn429: { attempts: 1, intervalMs: 100, maxIntervalMs: 100, respectRetryAfter: false }, + })); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: false, messages: [{ role: "user", content: "hi" }] }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(authorizations).toEqual(["Bearer key-one", "Bearer key-one", "Bearer key-two"]); + const entry = getRequestLogEntries().at(-1); + expect(entry?.status).toBe(200); + expect(entry?.usage).toMatchObject({ inputTokens: 4, outputTokens: 2 }); + expect(entry?.attempts?.[0]?.recoveryKinds).toEqual(["rate-limit-429", "key-429"]); + } finally { + await server.stop(true); + upstream.stop(true); + clearKeyCooldowns("mock"); + } +}); + +test("chat-native client cancellation cancels the upstream stream and logs 499", async () => { + const { clearRequestLogsForTests, getRequestLogEntries } = await import("../src/server/request-log"); + const { handleChatCompletions } = await import("../src/server/chat-completions"); + clearRequestLogsForTests(); + let markCancelled!: () => void; + const cancelled = new Promise(resolve => { markCancelled = resolve; }); + const encoder = new TextEncoder(); + globalThis.fetch = (async () => new Response(new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('data: {"choices":[{"index":0,"delta":{"content":"held"}}]}\n\n')); + }, + cancel() { + markCancelled(); + }, + }), { headers: { "content-type": "text/event-stream" } })) as typeof fetch; + try { + const clientAbort = new AbortController(); + const request = new Request("http://localhost/v1/chat/completions", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ model: "mock/test-model", stream: true, messages: [{ role: "user", content: "hi" }] }), + signal: clientAbort.signal, + }); + const logCtx = {} as Parameters[2]; + const response = await handleChatCompletions( + request, + mockConfig("https://provider.example/v1"), + logCtx, + { requestId: "chat-cancel", start: Date.now() }, + ); + const reader = response.body!.getReader(); + expect((await reader.read()).done).toBe(false); + clientAbort.abort("client done"); + expect((await reader.read()).done).toBe(true); + await cancelled; + expect(getRequestLogEntries().at(-1)?.status).toBe(499); + } finally { + globalThis.fetch = originalFetch; + } +}); + +test("chat-native streaming synthesizes tool-call SSE from Chat JSON", async () => { + const upstream = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + id: "chatcmpl_tc", + object: "chat.completion", + choices: [{ + index: 0, + message: { + role: "assistant", + content: null, + tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{\"q\":1}" } }], + }, + finish_reason: "tool_calls", + }], + usage: { prompt_tokens: 1, completion_tokens: 1 }, + }); + }, + }); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`)); + const server = startServer(0); + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + stream: true, + messages: [{ role: "user", content: "hi" }], + tools: [{ type: "function", function: { name: "lookup", parameters: { type: "object" } } }], + }), + }); + const text = await response.text(); + expect(response.status).toBe(200); + expect(text).toContain("lookup"); + expect(text).toContain("tool_calls"); + expect(text).toContain("data: [DONE]"); } finally { await server.stop(true); upstream.stop(true); @@ -927,7 +1236,7 @@ test("POST /v1/chat/completions direct mode forwards caller Authorization", asyn */ const inspectNativeCodexOwnership = ownedServiceHomeInspection("chat replay main-enrichment test"); -test("Chat replay owns optional main enrichment while routed work survives drain and recovery", async () => { +test("chat-native skips optional main enrichment while routed work survives drain and recovery", async () => { resetLifecycleDrainStateForTests(); writeFileSync(join(isolatedCodexHome!.path, "auth.json"), JSON.stringify({ tokens: { access_token: "chat-main-access", account_id: "chat-main-account" }, @@ -979,7 +1288,7 @@ test("Chat replay owns optional main enrichment while routed work survives drain await started; const response = await pending; expect(response.status).toBe(200); - expect(getNativeMainProfileRequestCount()).toBe(1); + expect(getNativeMainProfileRequestCount()).toBe(0); drain = acquireNativeMainProfileDrain("chat-overlap"); expect(drain).not.toBeNull(); const routedDuringDrain = await request(); From e97ca5cf0b41f90836b90758647b3643213670e6 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 16:20:36 +0900 Subject: [PATCH 24/28] fix(codex): authorize exact-model Chat service tiers --- .../content/docs/guides/codex-app-models.md | 6 +++--- .../docs/reference/configuration/providers.md | 6 +++--- src/adapters/openai-chat.ts | 11 +++++----- src/providers/service-tier.ts | 19 ++++++++++++----- src/types.ts | 8 ++++--- structure/04_transports-and-sidecars.md | 7 ++++--- tests/openai-chat-hardening.test.ts | 21 +++++++++++++++++++ tests/service-tier-capability.test.ts | 6 +++--- 8 files changed, 59 insertions(+), 25 deletions(-) diff --git a/docs-site/src/content/docs/guides/codex-app-models.md b/docs-site/src/content/docs/guides/codex-app-models.md index 2fd550714b..9443563717 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -205,9 +205,9 @@ can opt in globally with `supportsServiceTier: true`, or narrowly with `modelSupportsServiceTier: { "verified-model": true }`; an exact `false` narrows a provider default of `true`, while provider-level `false` remains fail-closed. The same final adapter/model decision controls both catalog metadata and runtime injection, so the fast option is never -advertised where it cannot be honored. An `openai-chat` destination must also set -`chatServiceTier: true`, matching the adapter's existing wire-level opt-in; Responses routes do -not need that extra flag. +advertised where it cannot be honored. An `openai-chat` destination can authorize every otherwise +eligible model with `chatServiceTier: true`, or authorize only exact models with +`modelSupportsServiceTier`; Responses routes do not need that extra Chat wire authorization. ## Subagent selection diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 790188d857..6c0598be1a 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -67,9 +67,9 @@ 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. | | `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 fallback. `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. An `openai-chat` route also requires `chatServiceTier: true` before this capability can be emitted on the wire. | -| `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. An exact `false` can narrow a provider default of `true`; an explicit provider-level `false` remains fail-closed and cannot be reopened by the map. Undeclared models fall back to `supportsServiceTier`. A capable `openai-responses` route publishes Fast metadata and receives global Fast injection; `openai-chat` also requires `chatServiceTier: true`. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | -| `chatServiceTier?` | `boolean` | Wire-level opt-in for serializing `service_tier` on `/chat/completions`. Required in addition to the resolved provider/model capability because many strict OpenAI-compatible Chat gateways reject this extension. | +| `supportsServiceTier?` | `boolean` | Tri-state `service_tier` capability fallback. `true`: fast mode may inject and caller values are preserved. `false`: the field is stripped and never injected, and exact model declarations cannot reopen it. Absent: the provider is unclassified — caller-supplied values are preserved untouched and fast mode never injects unless an exact model is enabled. The registry classifies canonical OpenAI (`true`), DeepSeek, and Volcengine Ark (`false`); set it explicitly only for custom gateways that genuinely support tiers. Chat routes additionally need provider-wide or exact-model Chat authorization. | +| `modelSupportsServiceTier?` | `Record` | Exact upstream model capability overrides. Exact `true` authorizes that Chat model even without `chatServiceTier`; exact `false` narrows provider defaults and Chat authorization. An explicit provider-level `supportsServiceTier: false` remains fail-closed and cannot be reopened. Undeclared models fall back to provider-wide behavior. Management `PATCH /api/providers` merges entries and accepts `null` to clear one. | +| `chatServiceTier?` | `boolean` | Provider-wide wire opt-in for serializing `service_tier` on `/chat/completions`. Exact models may instead opt in through `modelSupportsServiceTier`; undeclared models remain blocked when this flag is absent or false. | | `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. | | `disabled?` | `boolean` | Keep the provider on disk but exclude it from routing and model/catalog listings. | | `apiKey?` | `string` | API key, or an `${ENV_VAR}` / `$ENV_VAR` reference resolved at request time. | diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 2b8bcecbf3..b2b78e49c0 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -12,6 +12,7 @@ import { identifyRoutedModel } from "./identity"; import { peekReasoningForCall } from "../responses/reasoning-replay-cache"; import { buildNonOpenAIToolCatalogNudgeForTools, shouldInjectNonOpenAIToolCatalogNudge } from "./tool-catalog-nudge"; import { openRouterProviderPayload, resolveOpenRouterRouting } from "../providers/openrouter-routing"; +import { canSerializeServiceTierForChatModel } from "../providers/service-tier"; import { openaiChatCompletionsUrl } from "./openai-chat-url"; import { isTranslatorBudgetExceededError, @@ -1070,11 +1071,11 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd // an explicit value here makes the Responses parser's serviceTier projection ineffective. // // Opt-in, like `prompt_cache_key` directly below: `service_tier` is an OpenAI-specific - // extension and 66 registry providers share this adapter, several of which reject - // unknown body fields. Forwarding unconditionally would turn a caller-supplied - // `service_tier` into an upstream 400 on those routes. `supportsServiceTier` is the - // Responses-wire flag (applyServiceTierGate) and deliberately does not gate this path. - if (provider.chatServiceTier && parsed.options.serviceTier !== undefined) { + // extension and 66 registry providers share this adapter. A provider-wide Chat opt-in + // authorizes undeclared models; an exact model declaration can authorize or deny one + // model. Provider-level false remains fail-closed. + if (canSerializeServiceTierForChatModel(provider, parsed.modelId) + && parsed.options.serviceTier !== undefined) { body.service_tier = parsed.options.serviceTier; } if (modelInList(provider.reasoningSplitModels, parsed.modelId)) body.reasoning_split = true; diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index d725e38b9e..9d773c3796 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -46,6 +46,16 @@ export function supportsServiceTierForModel( ?? provider.supportsServiceTier; } +/** Whether the Chat serializer may emit a tier for this exact model. */ +export function canSerializeServiceTierForChatModel( + provider: Pick, + modelId: string, +): boolean { + const exact = exactModelValue(provider.modelSupportsServiceTier, modelId); + if (provider.supportsServiceTier === false || exact === false) return false; + return provider.chatServiceTier === true || exact === true; +} + /** Resolve an explicit model wire override for catalog-time capability projection. */ export function serviceTierAdapterForModel( providerName: string, @@ -91,10 +101,9 @@ export function serviceTierSupportForModel( ? provider.adapter : serviceTierAdapterForModel(providerName, provider, modelId, inbound); if (!SERVICE_TIER_ADAPTERS.has(adapter)) return false; - // The current OpenAI Chat adapter deliberately serializes this extension only for - // providers that opted in. Treat that serializer decision as authoritative so catalog - // metadata, routing evidence, fast-mode injection, and caller-tier stripping cannot claim - // support that the final request builder will silently omit. - if (adapter === "openai-chat" && provider.chatServiceTier !== true) return false; + // Treat the Chat serializer decision as authoritative so catalog metadata, routing + // evidence, fast-mode injection, and caller-tier stripping cannot claim support that the + // final request builder will omit. A provider-wide false and an exact false stay closed. + if (adapter === "openai-chat" && !canSerializeServiceTierForChatModel(provider, modelId)) return false; return supportsServiceTierForModel(provider, modelId); } diff --git a/src/types.ts b/src/types.ts index 3d90136be4..3800b48aa3 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1310,7 +1310,8 @@ export interface OcxProviderConfig { requiresAdjacentResponsesToolResults?: boolean; /** * Provider fallback for the OpenAI `service_tier` parameter. On Responses routes this - * is the complete wire opt-in; Chat routes additionally require `chatServiceTier`. + * is the complete wire opt-in; Chat routes additionally require `chatServiceTier` or an + * exact-model true declaration. * Tri-state: `true` lets fast mode inject/remove the field (an unset * fast mode preserves a caller-supplied value); `false` strips the field and * never injects, because an upstream documented as not supporting the parameter @@ -1521,8 +1522,9 @@ export interface OcxProviderConfig { * OpenAI-specific extension with the same hazard as `promptCacheKey` — strict backends * reject unknown fields, and 66 registry providers share the `openai-chat` adapter, so a * caller-supplied `service_tier` would otherwise turn working requests into upstream 400s. - * `supportsServiceTier` is the Responses-wire flag and does not apply here. - * Default off; only enable for providers that document this parameter on the chat wire. + * Exact models may opt in through `modelSupportsServiceTier` instead; provider-level + * `supportsServiceTier: false` remains a global denial. Default off; only enable for + * providers that document this parameter on the chat wire. */ chatServiceTier?: boolean; /** diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 240351863d..7a93f847f1 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -46,9 +46,10 @@ known. `supportsServiceTier` remains the provider fallback, while the exact The catalog and request path share this decision: a routed row publishes `service_tiers` only when the resolved adapter is capable, and the final-route normalizer applies the same gate to `service_tier`. `openai-responses` uses the resolved provider/model declaration directly; -`openai-chat` additionally requires its existing `chatServiceTier` serializer opt-in. Capability is -namespaced by the selected provider and model; model-name similarity and adapter type alone never -opt a gateway in. +`openai-chat` accepts either its provider-wide `chatServiceTier` serializer opt-in or an exact-model +`true` declaration. Exact `false` narrows provider defaults, and provider-level +`supportsServiceTier: false` cannot be reopened. Capability is namespaced by the selected provider +and model; model-name similarity and adapter type alone never opt a gateway in. `POST /v1/responses/compact` handles remote compaction v1 before the generic `/v1/responses` branch and before the `/v1/*` guard. Unknown `/v1/*` paths return JSON 404 errors instead of falling through diff --git a/tests/openai-chat-hardening.test.ts b/tests/openai-chat-hardening.test.ts index 3df7e1fc82..0492e9575b 100644 --- a/tests/openai-chat-hardening.test.ts +++ b/tests/openai-chat-hardening.test.ts @@ -417,6 +417,27 @@ describe("openai-chat credential hardening", () => { expect(body.service_tier).toBe("priority"); }); + test("an exact model capability authorizes only that Chat model", () => { + const exactOnly = provider({ modelSupportsServiceTier: { "test-model": true } }); + const authorized = parsed(); + authorized.options.serviceTier = "priority"; + expect(JSON.parse(createOpenAIChatAdapter(exactOnly).buildRequest(authorized).body).service_tier) + .toBe("priority"); + + const undeclared = parsed(); + undeclared.modelId = "other-model"; + undeclared.options.serviceTier = "priority"; + expect(JSON.parse(createOpenAIChatAdapter(exactOnly).buildRequest(undeclared).body)) + .not.toHaveProperty("service_tier"); + + const providerDenied = provider({ + supportsServiceTier: false, + modelSupportsServiceTier: { "test-model": true }, + }); + expect(JSON.parse(createOpenAIChatAdapter(providerDenied).buildRequest(authorized).body)) + .not.toHaveProperty("service_tier"); + }); + // `service_tier` is an OpenAI-specific extension and this adapter serves 66 registry // providers, several of which reject unknown body fields. Forwarding it by default would // turn a caller-supplied tier into an upstream 400 on those routes, so absence of the diff --git a/tests/service-tier-capability.test.ts b/tests/service-tier-capability.test.ts index b1e77b6e4c..ceaa46c2ad 100644 --- a/tests/service-tier-capability.test.ts +++ b/tests/service-tier-capability.test.ts @@ -296,18 +296,18 @@ describe("the gate fires on the live handleResponses path", () => { expect(optedIn.service_tier).toBe("priority"); }); - test("custom Chat provider injects Fast only for an explicitly capable model", async () => { + test("an exact-model-only Chat capability forwards Fast while undeclared models stay blocked", async () => { const custom = (): OcxProviderConfig => ({ adapter: "openai-chat", baseUrl: "https://gateway.example.com/v1", apiKey: "sk-test", - supportsServiceTier: true, - chatServiceTier: true, modelSupportsServiceTier: { "verified-model": true, "blocked-model": false }, }); const supported = await drive("custom-chat", custom(), "verified-model", {}, true); expect(supported.service_tier).toBe("priority"); const blocked = await drive("custom-chat", custom(), "blocked-model", { service_tier: "priority" }, true); expect(blocked).not.toHaveProperty("service_tier"); + const undeclared = await drive("custom-chat", custom(), "undeclared-model", { service_tier: "priority" }); + expect(undeclared).not.toHaveProperty("service_tier"); }); }); From 9024c1a10d69cc0d30d192e01a5c8f559493e25d Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 16:22:38 +0900 Subject: [PATCH 25/28] fix: preserve paced fetch runtime compatibility --- src/lib/state-store-registrations.ts | 2 ++ src/providers/request-pacing.ts | 34 +++++++++++++++++++++ src/server/responses/compact.ts | 5 ++- src/server/responses/core.ts | 33 +++++++++++++++----- src/server/responses/fetch-helpers.ts | 12 +++++--- tests/request-pacing.test.ts | 44 +++++++++++++++++++++++++-- tests/state-store-sweeper.test.ts | 1 + 7 files changed, 116 insertions(+), 15 deletions(-) diff --git a/src/lib/state-store-registrations.ts b/src/lib/state-store-registrations.ts index 151402d2f0..55bb4f292a 100644 --- a/src/lib/state-store-registrations.ts +++ b/src/lib/state-store-registrations.ts @@ -34,6 +34,7 @@ import { sweepExpiredAnthropicRoutingHealth } from "../oauth/anthropic-routing"; import { listLiveOAuthAccountKeys, reconcileOAuthReauthState } from "../oauth/store"; import { reconcileGuardianBackoff } from "../oauth/token-guardian"; import { sweepExpiredApiKeyCooldowns } from "../providers/key-failover"; +import { reconcileProviderRequestPacing } from "../providers/request-pacing"; import { sweepExpiredResponseStates } from "../responses/state"; import { sweepExpiredAntigravityReplay } from "../adapters/google-antigravity-replay"; import { reconcileProviderAccountQuotaRows } from "../providers/quota"; @@ -75,6 +76,7 @@ export function buildGenerationContext(): GenerationContext { export const STATE_STORE_REGISTRATIONS = [ { name: "subagent-model-health", sweepExpired: sweepExpiredSubagentModelHealth }, { name: "api-key-cooldowns", sweepExpired: sweepExpiredApiKeyCooldowns }, + { name: "provider-request-pacing", reconcileGeneration: reconcileProviderRequestPacing }, { name: "combo-target-cooldowns", sweepExpired: sweepExpiredComboTargetCooldowns, diff --git a/src/providers/request-pacing.ts b/src/providers/request-pacing.ts index a1c907d6f6..e38c62f3c0 100644 --- a/src/providers/request-pacing.ts +++ b/src/providers/request-pacing.ts @@ -1,4 +1,5 @@ import type { OcxProviderConfig, RequestPacingRule } from "../types"; +import type { GenerationContext } from "../lib/state-store-sweeper"; export const REQUEST_PACING_MAX_QUEUE_DEPTH = 256; export const REQUEST_PACING_MAX_QUEUE_AGE_MS = 60_000; @@ -21,6 +22,13 @@ export class RequestPacingQueueOverloadError extends Error { } } +export class RequestPacingProviderRemovedError extends Error { + constructor(public readonly providerName: string) { + super(`request pacing provider '${providerName}' was removed`); + this.name = "RequestPacingProviderRemovedError"; + } +} + interface Waiter { modelId?: string; providerIntervalMs: number; @@ -65,6 +73,7 @@ const defaultRuntime: RequestPacingRuntime = { enqueueMicrotask: queueMicrotask, }; let runtime = defaultRuntime; +let lastReconciledGeneration = 0; function abortReason(signal: AbortSignal): unknown { return signal.reason ?? new DOMException("The operation was aborted", "AbortError"); @@ -125,6 +134,18 @@ function rejectExpiredWaiters(providerName: string, state: ProviderPacer, now: n } } +function removeProviderPacer(providerName: string, state: ProviderPacer): void { + if (state.timer) runtime.clearTimer(state.timer); + state.timer = undefined; + pacers.delete(providerName); + const waiters = state.queue.splice(0); + const error = new RequestPacingProviderRemovedError(providerName); + for (const waiter of waiters) { + if (waiter.abort) waiter.signal?.removeEventListener("abort", waiter.abort); + waiter.reject(error); + } +} + function runQueue(providerName: string, state: ProviderPacer): void { if (state.queue.length === 0) { if (state.timer) runtime.clearTimer(state.timer); @@ -267,10 +288,23 @@ export function setProviderRequestPacingRuntimeForTest(nextRuntime: RequestPacin runtime = nextRuntime; } +export function reconcileProviderRequestPacing(context: GenerationContext): number { + if (context.generation <= lastReconciledGeneration) return 0; + let removed = 0; + for (const [providerName, state] of pacers) { + if (context.providerNames.has(providerName)) continue; + removeProviderPacer(providerName, state); + removed += 1; + } + lastReconciledGeneration = context.generation; + return removed; +} + export function resetProviderRequestPacingForTest(): void { for (const state of pacers.values()) if (state.timer) runtime.clearTimer(state.timer); pacers.clear(); maxQueueDepth = REQUEST_PACING_MAX_QUEUE_DEPTH; maxQueueAgeMs = REQUEST_PACING_MAX_QUEUE_AGE_MS; runtime = defaultRuntime; + lastReconciledGeneration = 0; } diff --git a/src/server/responses/compact.ts b/src/server/responses/compact.ts index 0cfee64e7e..589ba09196 100644 --- a/src/server/responses/compact.ts +++ b/src/server/responses/compact.ts @@ -490,7 +490,10 @@ export async function handleResponsesCompact( req.signal, connectMs, false, - providerFetch(sendProvider, route.providerName, route.modelId), + providerFetch(sendProvider, undefined, { + providerName: route.providerName, + modelId: route.modelId, + }), // Every credential-bearing forward send gets manual redirects, not only // pool sends: direct mode carries the caller's credential too (#914). sendProvider.authMode === "forward", diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 4b32c8378a..b76fd061df 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -595,7 +595,10 @@ async function retryCodexPoolOnAlternateAccount( upstream.signal, connectMs, stream, - providerFetch(route.provider, route.providerName, route.modelId, options.codexWsRuntimeIdentity), + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + }), // Credential-bearing forward send: never follow a redirect into a // dead-host rejection after the credential was seen (#914). route.provider.authMode === "forward", @@ -2338,7 +2341,10 @@ async function handleResponsesInner( headers: request.headers, body: request.body, }, recovery), upstream.signal, connectMs, parsed.stream, - providerFetch(route.provider, route.providerName, route.modelId, options.codexWsRuntimeIdentity), + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + }), route.provider.authMode === "forward") // Every real attempt response — including an intermediate 5xx the // retry wrapper replaces — proves the host was reached (#914 review). @@ -2400,7 +2406,10 @@ async function handleResponsesInner( headers: request.headers, body: request.body, }, recovery), upstream.signal, connectMs, parsed.stream, - providerFetch(route.provider, route.providerName, route.modelId, options.codexWsRuntimeIdentity), + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + }), route.provider.authMode === "forward") .then(res => { settleObservedHostResponse(); @@ -2991,9 +3000,8 @@ async function handleResponsesInner( } const imageProviderFetch = providerFetch( route.provider, - route.providerName, - route.modelId, options.codexWsRuntimeIdentity, + { providerName: route.providerName, modelId: route.modelId }, ); const imgResponse = await runWithImageBridge({ parsed, adapter, @@ -3339,7 +3347,10 @@ async function handleResponsesInner( headers: builtInitialRequest.headers, body: builtInitialRequest.body, }, recovery), upstream.signal, connectMs, parsed.stream, - providerFetch(route.provider, route.providerName, route.modelId, options.codexWsRuntimeIdentity)); + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + })); }, { abortSignal: upstream.signal, label: safeHostLabel(builtInitialRequest.url) }, ); @@ -3421,7 +3432,10 @@ async function handleResponsesInner( 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, options.codexWsRuntimeIdentity)); + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: route.modelId, + })); } finally { retryRequest.releaseBodyObservation?.(); } @@ -3750,7 +3764,10 @@ async function handleResponsesInner( upstream.signal, connectMs, nextParsed.stream, - providerFetch(route.provider, route.providerName, nextParsed.modelId, options.codexWsRuntimeIdentity), + providerFetch(route.provider, options.codexWsRuntimeIdentity, { + providerName: route.providerName, + modelId: 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 a8bd8ba8f0..1a35bef26b 100644 --- a/src/server/responses/fetch-helpers.ts +++ b/src/server/responses/fetch-helpers.ts @@ -144,11 +144,15 @@ export interface PaceAwareFetch { export type ProviderFetch = typeof globalThis.fetch & PaceAwareFetch; +export interface ProviderFetchOptions { + providerName?: string; + modelId?: string; +} + export function providerFetch( provider: OcxProviderConfig, - providerName?: string, - modelId?: string, runtime: BunRuntimeGateInput = currentBunRuntimeIdentity(), + options: ProviderFetchOptions = {}, ): ProviderFetch { const base = (provider as OcxProviderConfig & { fetch?: typeof globalThis.fetch }).fetch ?? globalThis.fetch; // ChatGPT Codex backend: streaming turns ride the responses_websockets @@ -160,8 +164,8 @@ export function providerFetch( } return base(input, init); }; - const waitForPacing = (signal?: AbortSignal) => providerName - ? waitForProviderRequestSlot(providerName, provider, modelId, signal) + const waitForPacing = (signal?: AbortSignal) => options.providerName + ? waitForProviderRequestSlot(options.providerName, provider, options.modelId, signal) : Promise.resolve(); const wrapped = async (input: Parameters[0], init?: RequestInit) => { await waitForPacing(init?.signal ?? undefined); diff --git a/tests/request-pacing.test.ts b/tests/request-pacing.test.ts index e1ab68f2dc..4a2dedc60a 100644 --- a/tests/request-pacing.test.ts +++ b/tests/request-pacing.test.ts @@ -1,6 +1,7 @@ import { afterEach, describe, expect, test } from "bun:test"; import { providerRequestPacingStatus, + reconcileProviderRequestPacing, RequestPacingQueueOverloadError, requestPacingIntervalMs, resetProviderRequestPacingForTest, @@ -23,6 +24,7 @@ function provider(requestPacing: OcxProviderConfig["requestPacing"]): OcxProvide function fakePacingClock(): { runtime: RequestPacingRuntime; now: () => number; + pendingTimerCount: () => number; advanceBy: (delayMs: number) => void; } { let now = 0; @@ -40,6 +42,7 @@ function fakePacingClock(): { enqueueMicrotask: callback => callback(), }, now: () => now, + pendingTimerCount: () => timers.size, advanceBy: (delayMs) => { const target = now + delayMs; while (true) { @@ -91,7 +94,7 @@ describe("provider request pacing queue", () => { ...provider({ enabled: true, requestsPerMinute: 600 }), fetch: fetchImpl, } as OcxProviderConfig & { fetch: typeof globalThis.fetch }; - const send = providerFetch(configured, "demo", "model-a"); + const send = providerFetch(configured, undefined, { providerName: "demo", modelId: "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); @@ -152,6 +155,43 @@ describe("provider request pacing queue", () => { expect(providerRequestPacingStatus("demo", configured).queued).toBe(0); }); + test("generation reconciliation removes deleted providers and rejects their queued waiters", async () => { + const clock = fakePacingClock(); + setProviderRequestPacingRuntimeForTest(clock.runtime); + const configured = provider({ enabled: true, minIntervalMs: 100 }); + + await waitForProviderRequestSlot("live", configured, "model"); + await waitForProviderRequestSlot("removed", configured, "model"); + const liveQueued = waitForProviderRequestSlot("live", configured, "model"); + const removedQueued = waitForProviderRequestSlot("removed", configured, "model"); + const removedOutcome = removedQueued.then( + () => null, + error => error, + ); + expect(clock.pendingTimerCount()).toBe(2); + + expect(reconcileProviderRequestPacing({ + generation: 1, + providerNames: new Set(["live"]), + comboIds: new Set(), + comboTargets: new Set(), + codexAccountIds: new Set(), + oauthAccountKeys: new Set(), + configRoots: new Set(), + })).toBe(1); + + expect(await removedOutcome).toMatchObject({ + name: "RequestPacingProviderRemovedError", + providerName: "removed", + }); + expect(providerRequestPacingStatus("removed", configured).queued).toBe(0); + expect(providerRequestPacingStatus("live", configured).queued).toBe(1); + expect(clock.pendingTimerCount()).toBe(1); + clock.advanceBy(100); + await liveQueued; + expect(clock.pendingTimerCount()).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); @@ -214,7 +254,7 @@ describe("provider request pacing queue", () => { ...provider({ enabled: true, minIntervalMs: 120 }), fetch: fetchImpl, } as OcxProviderConfig & { fetch: typeof globalThis.fetch }; - const executor = providerFetch(configured, "demo", "model-a"); + const executor = providerFetch(configured, undefined, { providerName: "demo", modelId: "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); diff --git a/tests/state-store-sweeper.test.ts b/tests/state-store-sweeper.test.ts index 1d149fc6dc..8b9ac2f10d 100644 --- a/tests/state-store-sweeper.test.ts +++ b/tests/state-store-sweeper.test.ts @@ -84,6 +84,7 @@ describe("state-store sweeper", () => { expect(STATE_STORE_REGISTRATIONS.map(registration => registration.name)).toEqual([ "subagent-model-health", "api-key-cooldowns", + "provider-request-pacing", "combo-target-cooldowns", "anthropic-routing-health", "xai-refresh-verdicts", From 51003b68f077df0cd24194d94d6801edc94c1e6a Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 16:24:09 +0900 Subject: [PATCH 26/28] fix(chat): preserve native Chat request fidelity (#1467) --- src/adapters/openai-chat.ts | 126 ++++++++++++++++++++++-- src/chat/inbound.ts | 20 ++-- src/server/chat-completions.ts | 93 ++++++++++------- src/server/chat-native.ts | 36 +++---- structure/04_transports-and-sidecars.md | 17 +++- tests/chat-completions-endpoint.test.ts | 65 ++++++++++++ 6 files changed, 276 insertions(+), 81 deletions(-) diff --git a/src/adapters/openai-chat.ts b/src/adapters/openai-chat.ts index 2b8bcecbf3..befee9da06 100644 --- a/src/adapters/openai-chat.ts +++ b/src/adapters/openai-chat.ts @@ -34,6 +34,122 @@ export function stripBracketedModelSuffix(modelId: string): string { return suffixStart === -1 ? modelId : modelId.slice(0, suffixStart); } +const CHAT_PASSTHROUGH_FIELDS = [ + "audio", + "frequency_penalty", + "logit_bias", + "logprobs", + "max_completion_tokens", + "max_tokens", + "metadata", + "modalities", + "n", + "prediction", + "presence_penalty", + "reasoning_effort", + "response_format", + "seed", + "stop", + "store", + "temperature", + "tool_choice", + "tools", + "top_logprobs", + "top_p", + "user", + "web_search_options", +] as const; + +function openAIChatTransport(provider: OcxProviderConfig): { + url: string; + headers: Record; + hasCredential: boolean; +} { + const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0; + if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) { + throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`); + } + const headers: Record = { "Content-Type": "application/json" }; + if (hasCredential) headers.Authorization = `Bearer ${provider.apiKey}`; + if (provider.headers) Object.assign(headers, provider.headers); + return { url: openaiChatCompletionsUrl(provider.baseUrl), headers, hasCredential }; +} + +/** + * Build a provider request from an inbound Chat Completions body without translating it + * through the Responses contract. This is deliberately a whitelist: Chat-only caller + * fields retain their exact wire representation, while provider capability gates remain + * centralized beside the ordinary openai-chat adapter. + */ +export function buildOpenAIChatPassthroughRequest( + provider: OcxProviderConfig, + rawBody: Record, + modelId: string, + stream: boolean, +): AdapterRequest { + const { url, headers, hasCredential } = openAIChatTransport(provider); + + const body: Record = { + model: provider.modelSuffixBracketStrip ? stripBracketedModelSuffix(modelId) : modelId, + messages: rawBody.messages, + stream, + }; + for (const field of CHAT_PASSTHROUGH_FIELDS) { + if (rawBody[field] !== undefined) body[field] = rawBody[field]; + } + + if (modelInList(provider.noTemperatureModels, modelId)) delete body.temperature; + if (modelInList(provider.noTopPModels, modelId)) delete body.top_p; + if (modelInList(provider.noPenaltyModels, modelId)) { + delete body.presence_penalty; + delete body.frequency_penalty; + } + if (modelInList(provider.noStructuredOutputModels, modelId)) delete body.response_format; + + if (provider.chatServiceTier && rawBody.service_tier !== undefined) { + body.service_tier = rawBody.service_tier; + } + if (provider.promptCacheKey && rawBody.prompt_cache_key !== undefined) { + body.prompt_cache_key = rawBody.prompt_cache_key; + } + if (Array.isArray(rawBody.tools) && rawBody.tools.length > 0) { + if (provider.parallelToolCalls === true) { + body.parallel_tool_calls = rawBody.parallel_tool_calls !== false; + } else if (provider.parallelToolCalls === false + && (provider.baseUrl === "https://integrate.api.nvidia.com/v1" || provider.pinParallelToolCallsFalse === true)) { + body.parallel_tool_calls = false; + } + } + if (stream) { + const callerOptions = rawBody.stream_options !== null + && typeof rawBody.stream_options === "object" + && !Array.isArray(rawBody.stream_options) + ? rawBody.stream_options as Record + : {}; + body.stream_options = { ...callerOptions, include_usage: true }; + } else if (rawBody.stream_options !== undefined) { + body.stream_options = rawBody.stream_options; + } + + const bodyJson = JSON.stringify(body); + + if (isDebugEnabled()) { + let host = "upstream"; + try { host = new URL(url).host; } catch { /* keep fallback */ } + debugProviderDiagnostic("openai-chat", "passthrough-request", { + host, + model: body.model, + stream, + messageCount: Array.isArray(body.messages) ? body.messages.length : 0, + toolCount: Array.isArray(body.tools) ? body.tools.length : 0, + hasCredential, + bodyBytes: new TextEncoder().encode(bodyJson).length, + }); + } + + return { url, method: "POST", headers, body: bodyJson }; +} + // 260715 (issue #126): surface upstream error detail through the web-search sidecar loop. // loop.ts only appends a suffix to "Provider error N" when the adapter exposes // formatErrorBody; without it, strict OpenAI-compatible backends (NVIDIA NIM pydantic @@ -1051,10 +1167,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd formatErrorBody: formatOpenAIChatErrorBody, buildRequest(parsed: OcxParsedRequest) { - const hasCredential = typeof provider.apiKey === "string" && provider.apiKey.trim().length > 0; - if ((provider.authMode === "key" || provider.authMode === "oauth") && !provider.keyOptional && !hasCredential) { - throw new Error(`${provider.adapter} requires a non-empty credential (authMode: ${provider.authMode})`); - } + const { url, headers, hasCredential } = openAIChatTransport(provider); const messages = messagesToChatFormat(parsed, provider); const tools = toolsToChatFormatForProvider(parsed, provider); @@ -1207,11 +1320,6 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd } if (parsed.stream) body.stream_options = { include_usage: true }; - const url = openaiChatCompletionsUrl(provider.baseUrl); - const headers: Record = { "Content-Type": "application/json" }; - if (hasCredential) headers["Authorization"] = `Bearer ${provider.apiKey}`; - if (provider.headers) Object.assign(headers, provider.headers); - const bodyJson = JSON.stringify(body); if (isDebugEnabled()) { let host = "upstream"; diff --git a/src/chat/inbound.ts b/src/chat/inbound.ts index ff3dba5e4b..c72efb4715 100644 --- a/src/chat/inbound.ts +++ b/src/chat/inbound.ts @@ -8,11 +8,23 @@ export class ChatCompletionsRequestError extends Error {} type Rec = Record; +type ChatCompletionsRoutingBody = Rec & { model: string; messages: unknown[] }; function isRec(v: unknown): v is Rec { return !!v && typeof v === "object" && !Array.isArray(v); } +/** Validate only the fields needed before Chat routing, without projecting the body. */ +export function assertChatCompletionsRoutingBody(raw: unknown): asserts raw is ChatCompletionsRoutingBody { + if (!isRec(raw)) throw new ChatCompletionsRequestError("request body must be a JSON object"); + if (typeof raw.model !== "string" || raw.model.length === 0) { + throw new ChatCompletionsRequestError("model is required"); + } + if (!Array.isArray(raw.messages) || raw.messages.length === 0) { + throw new ChatCompletionsRequestError("messages must be a non-empty array"); + } +} + const OUTPUT_CONFIG_EFFORTS = new Set(["minimal", "low", "medium", "high", "xhigh", "max", "ultra"]); const OUTPUT_CONFIG_SUMMARIES = new Set(["auto", "concise", "detailed", "none"]); @@ -227,13 +239,7 @@ function resolveReasoningSummary(raw: Rec): string | undefined { * Throws ChatCompletionsRequestError (-> 400) on malformed input. */ export function chatCompletionsToResponsesBody(raw: unknown): Rec { - if (!isRec(raw)) throw new ChatCompletionsRequestError("request body must be a JSON object"); - if (typeof raw.model !== "string" || raw.model.length === 0) { - throw new ChatCompletionsRequestError("model is required"); - } - if (!Array.isArray(raw.messages) || raw.messages.length === 0) { - throw new ChatCompletionsRequestError("messages must be a non-empty array"); - } + assertChatCompletionsRoutingBody(raw); const systemParts: string[] = []; const input: Rec[] = []; diff --git a/src/server/chat-completions.ts b/src/server/chat-completions.ts index a9a0ebfb31..7d39afa565 100644 --- a/src/server/chat-completions.ts +++ b/src/server/chat-completions.ts @@ -6,7 +6,11 @@ * that need Responses-only behavior keep the Chat -> Responses -> Chat bridge. */ import { FORWARD_HEADERS } from "../adapters/openai-responses"; -import { ChatCompletionsRequestError, chatCompletionsToResponsesBody } from "../chat/inbound"; +import { + assertChatCompletionsRoutingBody, + ChatCompletionsRequestError, + chatCompletionsToResponsesBody, +} from "../chat/inbound"; import { chatCompletionsErrorResponse, collectChatCompletion, @@ -81,11 +85,11 @@ async function handleChatCompletionsWithBudget( translatorBudget: TranslatorBudget, logIds?: { requestId: string; start: number; turnAdmissionLease?: AdmissionLease }, ): Promise { - let chatBody: unknown; - let internalBody: Rec; + let chatBody: Rec; try { - chatBody = await readChatBody(req, translatorBudget); - internalBody = chatCompletionsToResponsesBody(chatBody); + const rawBody = await readChatBody(req, translatorBudget); + assertChatCompletionsRoutingBody(rawBody); + chatBody = rawBody; } catch (err) { const overflow = isTranslatorBudgetExceededError(err); const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500; @@ -98,21 +102,17 @@ async function handleChatCompletionsWithBudget( ); } - const requestedModel = (chatBody as Rec).model as string; - const stream = internalBody.stream === true; + const requestedModel = chatBody.model as string; + const stream = chatBody.stream === true; // Best-effort Grok attribution: the managed fence stamps this header on every model // it registers (extra_headers, sent verbatim by upstream Grok). Dashboard usage // bucketing only — never an auth or billing signal. if (req.headers.get("x-opencodex-grok") === "1") logCtx.surface = "grok"; - // Routed adapters only support streamed turns; always stream internally and fold - // for non-streaming clients. - internalBody.stream = true; - - let nativeRoute = false; let directRoute = false; + let settledRoute: ReturnType | null = null; let chatNativeRoute: ReturnType | null = null; try { - const route = routeModel(config, internalBody.model as string, evidenceFromBody(internalBody)); + const route = routeModel(config, requestedModel, evidenceFromBody(chatBody)); // Settle the wire once so every branch below reads the adapter this model will // actually use, not the provider-wide default (#404). route.provider = resolveWireProtocolOverride(route.providerName, route.modelId, route.provider, "chat"); @@ -121,34 +121,17 @@ async function handleChatCompletionsWithBudget( logCtx.requestedModel = requestedModel; logCtx.provider = route.providerName; logCtx.routeDecision = route.routeDecision; + settledRoute = route; if (route.provider.adapter === "openai-responses") { - nativeRoute = true; directRoute = route.codexAccountMode === "direct"; - // ChatGPT backend rejects store:true and unsupported sampling knobs. - internalBody.store = false; - delete internalBody.max_output_tokens; - delete internalBody.temperature; - delete internalBody.top_p; - delete internalBody.stop; - delete internalBody.user; - } else if (internalBody.store === undefined) { - internalBody.store = false; } if (route.provider.adapter === "cursor" || route.provider.adapter === "kiro") { - const raw = chatBody as Rec; const parts: string[] = []; - if (raw.messages !== undefined) parts.push(JSON.stringify(raw.messages)); - if (raw.tools !== undefined) parts.push(JSON.stringify(raw.tools)); + if (chatBody.messages !== undefined) parts.push(JSON.stringify(chatBody.messages)); + if (chatBody.tools !== undefined) parts.push(JSON.stringify(chatBody.tools)); logCtx.usageLogInputTokens = Math.max(1, estimateTokens(parts.join("\n"), requestedModel)); } - if (internalBody.reasoning !== undefined) { - const { stripEmptyLadderEffort, supportedLadderFor } = await import("./effort-policy"); - const ladder = supportedLadderFor({ provider: route.provider, modelId: route.modelId }); - const next = stripEmptyLadderEffort(internalBody.reasoning, ladder); - if (next === undefined) delete internalBody.reasoning; - else internalBody.reasoning = next; - } - if (isNativeChatRouteEligible(route, chatBody as Rec)) chatNativeRoute = route; + if (isNativeChatRouteEligible(route, chatBody)) chatNativeRoute = route; } catch (err) { if (err instanceof NoEligiblePolicyCandidateError) { logCtx.routeDecision = err.trace; @@ -157,7 +140,6 @@ async function handleChatCompletionsWithBudget( } /* unknown model: let handleResponses shape the 404 */ } - void nativeRoute; if (chatNativeRoute) { return handleNativeChatCompletions({ @@ -166,13 +148,52 @@ async function handleChatCompletionsWithBudget( logCtx, ...(logIds ? { logIds } : {}), route: chatNativeRoute, - responsesBody: { ...internalBody, stream }, + chatBody, requestedModel, requestedStream: stream, translatorBudget, }); } + let internalBody: Rec; + try { + // Validate the full Chat boundary after routing. Native Chat keeps `chatBody` as + // its wire source; this Responses projection is used only by the fallback path. + internalBody = chatCompletionsToResponsesBody(chatBody); + } catch (err) { + const overflow = isTranslatorBudgetExceededError(err); + const status = overflow ? 413 : err instanceof ChatCompletionsRequestError ? 400 : 500; + if (logIds) addFinalRequestLog(logIds.requestId, logIds.start, logCtx, status, { closeReason: "non_stream" }); + return chatCompletionsErrorResponse( + status, + overflow ? "request translation buffer exceeded the safe limit" : err instanceof Error ? err.message : String(err), + overflow ? "request_too_large" : undefined, + overflow ? "translation_buffer_limit" : undefined, + ); + } + + // Routed adapters only support streamed turns; always stream internally and fold + // for non-streaming clients. Native Chat uses the caller's original stream bit. + internalBody.stream = true; + if (settledRoute?.provider.adapter === "openai-responses") { + // ChatGPT backend rejects store:true and unsupported sampling knobs. + internalBody.store = false; + delete internalBody.max_output_tokens; + delete internalBody.temperature; + delete internalBody.top_p; + delete internalBody.stop; + delete internalBody.user; + } else if (internalBody.store === undefined) { + internalBody.store = false; + } + if (settledRoute && internalBody.reasoning !== undefined) { + const { stripEmptyLadderEffort, supportedLadderFor } = await import("./effort-policy"); + const ladder = supportedLadderFor({ provider: settledRoute.provider, modelId: settledRoute.modelId }); + const next = stripEmptyLadderEffort(internalBody.reasoning, ladder); + if (next === undefined) delete internalBody.reasoning; + else internalBody.reasoning = next; + } + const headers = new Headers({ "content-type": "application/json" }); for (const name of FORWARD_HEADERS) { if (name === "authorization" && !directRoute) continue; diff --git a/src/server/chat-native.ts b/src/server/chat-native.ts index 80164885c5..597971a2e7 100644 --- a/src/server/chat-native.ts +++ b/src/server/chat-native.ts @@ -1,4 +1,4 @@ -import { createOpenAIChatAdapter } from "../adapters/openai-chat"; +import { buildOpenAIChatPassthroughRequest, createOpenAIChatAdapter } from "../adapters/openai-chat"; import type { AdapterRequest, ProviderAdapter } from "../adapters/base"; import { chatCompletionsErrorBody, @@ -26,9 +26,8 @@ import { rateLimitRetryPolicyFor, rotateProviderTransportOn429, } from "../providers/key-failover"; -import { parseRequest } from "../responses/parser"; import type { RouteResult } from "../router"; -import type { OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../types"; +import type { OcxConfig, OcxProviderConfig } from "../types"; import { fetchWithHeaderTimeout, providerFetch, safeHostLabel } from "./responses/fetch-helpers"; import { linkAbortSignal } from "./responses"; import { @@ -75,22 +74,13 @@ function chatCompletionJson(value: unknown): Rec | null { return value; } -function prepareParsedRequest(body: Rec, route: RouteResult, stream: boolean): OcxParsedRequest { - const raw = { ...body, model: route.modelId, stream }; - const parsed = parseRequest(raw); - parsed.modelId = route.modelId; - parsed.stream = stream; - parsed._rawBody = raw; - return parsed; -} - interface HandleNativeChatOptions { req: Request; config: OcxConfig; logCtx: RequestLogContext; logIds?: { requestId: string; start: number }; route: RouteResult; - responsesBody: Rec; + chatBody: Rec; requestedModel: string; requestedStream: boolean; translatorBudget: TranslatorBudget; @@ -123,14 +113,12 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio return chatCompletionsErrorResponse(status, safeMessage, type, code); }; - let parsed: OcxParsedRequest; - try { - parsed = prepareParsedRequest(options.responsesBody, route, requestedStream); - } catch (error) { - return fail(400, error instanceof Error ? error.message : String(error), "invalid_request_error"); - } - logCtx.requestedEffort = parsed.options.reasoning; - logCtx.requestedServiceTier = parsed.options.serviceTier; + logCtx.requestedEffort = typeof options.chatBody.reasoning_effort === "string" + ? options.chatBody.reasoning_effort + : undefined; + logCtx.requestedServiceTier = typeof options.chatBody.service_tier === "string" + ? options.chatBody.service_tier + : undefined; const upstream = new AbortController(); const cleanupAbort = linkAbortSignal(upstream, req.signal); @@ -150,7 +138,7 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio retainedRequestBytes = bytes; }; try { - activeRequest = await activeAdapter.buildRequest(parsed, { headers: new Headers(), translatorBudget, abortSignal: upstream.signal }); + activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream); retainRequest(activeRequest); } catch (error) { releaseRetainedRequest(); @@ -207,14 +195,14 @@ export async function handleNativeChatCompletions(options: HandleNativeChatOptio retryAfter: response.headers.get("retry-after"), now: Date.now(), attemptedKey: activeProvider.apiKey, - promptCacheKey: parsed.options.promptCacheKey, + promptCacheKey: typeof options.chatBody.prompt_cache_key === "string" ? options.chatBody.prompt_cache_key : undefined, }); if (!rotated) break; try { void response.body?.cancel().catch(() => {}); } catch { /* already closed */ } activeProvider = rotated; activeAdapter = createOpenAIChatAdapter(activeProvider); releaseRetainedRequest(); - activeRequest = await activeAdapter.buildRequest(parsed, { headers: new Headers(), translatorBudget, abortSignal: upstream.signal }); + activeRequest = buildOpenAIChatPassthroughRequest(activeProvider, options.chatBody, route.modelId, requestedStream); retainRequest(activeRequest); response = await send(activeRequest, "key-429"); } diff --git a/structure/04_transports-and-sidecars.md b/structure/04_transports-and-sidecars.md index 86abf28b62..9a424fb96e 100644 --- a/structure/04_transports-and-sidecars.md +++ b/structure/04_transports-and-sidecars.md @@ -615,11 +615,18 @@ ignored because a credit is not a token count. ## Chat Completions inbound native path `POST /v1/chat/completions` sends eligible `openai-chat` routes directly to the provider's Chat -Completions endpoint. Request construction remains owned by `src/adapters/openai-chat.ts`, including -model normalization, provider headers, capability-specific fields, and the canonical -`openaiChatCompletionsUrl()` path. Combo/policy routes and requests that need Responses-only hosted -tools, continuation, background, or storage semantics retain the existing Chat -> Responses -> Chat -bridge. +Completions endpoint. Route selection reads the raw Chat body and the native request keeps that body +as its wire source; a Responses projection is constructed only after the native route is declined +and is never converted back into Chat. Request construction remains owned by `src/adapters/openai-chat.ts`, including model +normalization, credential and provider headers, capability-specific fields, and the canonical +`openaiChatCompletionsUrl()` path. The passthrough builder uses an explicit Chat-field whitelist so +messages (including `name` and separate `system`/`developer` entries), Chat token controls, +sampling/logprob fields, caller identity/metadata, and caller stream options retain their wire +shape. For streams, caller `stream_options` are merged with mandatory `include_usage: true`. +`service_tier` remains gated by `chatServiceTier: true`; `parallel_tool_calls` is emitted only for +providers opted into parallel tools (or pinned false by the existing provider opt-out contract). +Combo/policy routes and requests that need Responses-only hosted tools, continuation, background, +or storage semantics retain the existing Chat -> Responses -> Chat bridge. The direct SSE relay accepts CRLF and arbitrary transport chunk boundaries while retaining at most one bounded event. EOF with an unterminated event and an event above the translator limit are typed diff --git a/tests/chat-completions-endpoint.test.ts b/tests/chat-completions-endpoint.test.ts index 469e9b107e..54a86e1247 100644 --- a/tests/chat-completions-endpoint.test.ts +++ b/tests/chat-completions-endpoint.test.ts @@ -886,6 +886,7 @@ test("chat-native uses the shared request builder and normalized Chat Completion model: "mock/test-model", stream: false, temperature: 0.9, + service_tier: "priority", messages: [{ role: "user", content: "hi" }], }), }); @@ -894,6 +895,70 @@ test("chat-native uses the shared request builder and normalized Chat Completion expect(captured[0]?.pathname).toBe("/v1/chat/completions"); expect(captured[0]?.body.model).toBe("test-model"); expect(captured[0]?.body.temperature).toBeUndefined(); + expect(captured[0]?.body.service_tier).toBeUndefined(); + } finally { + await server.stop(true); + upstream.stop(true); + } +}); + +test("chat-native preserves caller Chat fields on the upstream wire", async () => { + const { server: upstream, captured } = mockChatUpstreamCapturing(); + saveConfig(mockConfig(`${upstream.url.toString().replace(/\/$/, "")}/v1`, { + chatServiceTier: true, + parallelToolCalls: true, + })); + const server = startServer(0); + const messages = [ + { role: "system", name: "system-sentinel", content: "system sentinel" }, + { role: "developer", name: "developer-sentinel", content: "developer sentinel" }, + { role: "user", name: "user-sentinel", content: "user sentinel" }, + ]; + try { + const response = await fetch(new URL("/v1/chat/completions", server.url), { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + model: "mock/test-model", + messages, + stream: true, + stream_options: { include_usage: false, sentinel_option: "preserved" }, + max_completion_tokens: 321, + service_tier: "priority", + seed: 42, + n: 2, + logprobs: true, + top_logprobs: 3, + logit_bias: { "123": -5 }, + user: "user-wire-sentinel", + metadata: { trace: "metadata-sentinel" }, + parallel_tool_calls: false, + tools: [{ type: "function", function: { name: "lookup", parameters: { type: "object" } } }], + }), + }); + expect(response.status).toBe(200); + await response.text(); + expect(captured).toHaveLength(1); + expect(captured[0]).toMatchObject({ + model: "test-model", + messages, + stream: true, + stream_options: { include_usage: true, sentinel_option: "preserved" }, + max_completion_tokens: 321, + service_tier: "priority", + seed: 42, + n: 2, + logprobs: true, + top_logprobs: 3, + logit_bias: { "123": -5 }, + user: "user-wire-sentinel", + metadata: { trace: "metadata-sentinel" }, + parallel_tool_calls: false, + }); + expect(captured[0]!.messages).toEqual(messages); + expect(captured[0]).not.toHaveProperty("max_tokens"); + expect(captured[0]).not.toHaveProperty("instructions"); + expect(captured[0]).not.toHaveProperty("input"); } finally { await server.stop(true); upstream.stop(true); From af622b205550b16e48b4dae06a2c4f3637ac6e35 Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 16:51:12 +0900 Subject: [PATCH 27/28] fix(responses): contain runTurn pacing rejection --- src/server/responses/core.ts | 39 ++++++++++++++++++----- tests/empty-completion-core.test.ts | 48 +++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 8 deletions(-) diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index bb19f680b5..c6ce07c05a 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -118,7 +118,10 @@ import { createTranslatorBudget, isTranslatorBudgetExceededError, type Translato import { listOpenAiForwardSidecarCandidates, resolveFirstUsableOpenAiSidecar, type ResolvedOpenAiForwardSidecar } from "../../providers/openai-sidecar"; import { isCanonicalOpenAiForwardProvider } from "../../providers/openai-tiers"; import { SERVICE_TIER_ADAPTERS, serviceTierSupportForModel } from "../../providers/service-tier"; -import { waitForProviderRequestSlot } from "../../providers/request-pacing"; +import { + RequestPacingQueueOverloadError, + waitForProviderRequestSlot, +} from "../../providers/request-pacing"; import { slugsEquivalent } from "../../providers/slug-codec"; import { applyOpenAiVirtualModel, resolveOpenAiCompactModel } from "../../providers/openai-virtual-models"; import { isUsageDebugEnabled } from "../../usage/debug"; @@ -3194,10 +3197,19 @@ async function handleResponsesInner( if (adapter.runTurn) { const runTurnAbort = new AbortController(); - linkAbortSignal(runTurnAbort, options.abortSignal); + const cleanupRunTurnAbort = linkAbortSignal(runTurnAbort, options.abortSignal); const queue = createAdapterEventQueue({ onBacklogExceeded: () => runTurnAbort.abort(), }); + // Initial admission must settle before the streaming Response commits HTTP 200. + // Let the outer Responses facade preserve the local retryable-429 contract. + try { + await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal); + } catch (error) { + cleanupRunTurnAbort(); + queue.close(); + throw error; + } // One attempt of the runTurn transport, against an explicit queue. The // empty-completion guard re-invokes the IDENTICAL turn (same parsed request, // same forwarded headers, same abort signal) through a fresh queue, so the @@ -3206,9 +3218,12 @@ async function handleResponsesInner( const runTurnAttempt = async ( targetQueue: AdapterEventQueue, recovery?: AttemptRecoveryKind, + pacingSlotAcquired = false, ): Promise => { - await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal); try { + if (!pacingSlotAcquired) { + await waitForProviderRequestSlot(route.providerName, route.provider, route.modelId, runTurnAbort.signal); + } noteAttemptSend(logCtx.activeAttempt, logCtx.usageLogInputTokens, recovery); await adapter.runTurn?.( parsed, @@ -3216,10 +3231,18 @@ async function handleResponsesInner( targetQueue.push, ); } catch (err) { - targetQueue.push({ - type: "error", - message: err instanceof Error ? err.message : String(err), - }); + targetQueue.push(err instanceof RequestPacingQueueOverloadError + ? { + type: "error", + status: 429, + errorType: "rate_limit_error", + retryable: true, + message: err.message, + } + : { + type: "error", + message: err instanceof Error ? err.message : String(err), + }); } finally { // Cursor assigns a stable conversation id inside runTurn on the first headerless // turn; backfill so Logs can filter/total that opening request (#330 / #522). @@ -3229,7 +3252,7 @@ async function handleResponsesInner( targetQueue.close(); } }; - const runTurn = async (): Promise => runTurnAttempt(queue); + const runTurn = async (): Promise => runTurnAttempt(queue, undefined, true); // The empty-completion retry re-runs the turn against a fresh queue: the // first queue is closed once its attempt settles, and pushing into it after // close is a silent no-op. diff --git a/tests/empty-completion-core.test.ts b/tests/empty-completion-core.test.ts index 36256423b9..6d538bf54c 100644 --- a/tests/empty-completion-core.test.ts +++ b/tests/empty-completion-core.test.ts @@ -3,6 +3,7 @@ import type { ProviderAdapter } from "../src/adapters/base"; import { providerRequestPacingStatus, resetProviderRequestPacingForTest, + setProviderRequestPacingLimitsForTest, setProviderRequestPacingRuntimeForTest, } from "../src/providers/request-pacing"; import type { RequestLogContext } from "../src/server/request-log"; @@ -121,6 +122,53 @@ afterEach(() => { }); describe("empty-completion core integration", () => { + test("streaming runTurn returns the local 429 contract when initial pacing admission is rejected", async () => { + setProviderRequestPacingLimitsForTest({ maxQueueDepth: 0 }); + const overloaded = config("test-run-turn"); + overloaded.providers.fixture!.requestPacing = { enabled: true, minIntervalMs: 100 }; + + const response = await handleResponses(request(true), overloaded, { model: "", provider: "" }); + const body = await response.json() as { error?: { type?: string } }; + + expect(response.status).toBe(429); + expect(response.headers.get("Retry-After")).toBe("1"); + expect(body.error?.type).toBe("rate_limit_error"); + expect(runTurnCalls).toBe(0); + }); + + test("streaming runTurn closes with an in-band error when retry pacing admission is rejected", async () => { + const paced = config("test-run-turn"); + paced.providers.fixture!.requestPacing = { enabled: true, minIntervalMs: 100 }; + customRunTurn = async (parsed, _incoming, emit) => { + runTurnCalls += 1; + parsedAttempts.push(parsed); + setProviderRequestPacingLimitsForTest({ maxQueueDepth: 0 }); + emit({ type: "done" }); + }; + + const response = await handleResponses(request(true), paced, { model: "", provider: "" }); + expect(response.status).toBe(200); + const reader = response.body!.getReader(); + const decoder = new TextDecoder(); + let body = ""; + const completed = (async () => { + while (true) { + const chunk = await reader.read(); + if (chunk.done) return "complete" as const; + body += decoder.decode(chunk.value, { stream: true }); + } + })(); + const outcome = await Promise.race([ + completed, + Bun.sleep(100).then(() => "timeout" as const), + ]); + if (outcome === "timeout") await reader.cancel("test timeout"); + + expect(outcome).toBe("complete"); + expect(body).toContain("empty_completion_retry_failed"); + expect(runTurnCalls).toBe(1); + }); + for (const stream of [true, false]) { test(`runTurn ${stream ? "streaming" : "non-streaming"} retries on a fresh queue`, async () => { attemptEvents = [ From a65bfc0dfb7a823cabea01b17acbddf28b786cca Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 17:28:29 +0900 Subject: [PATCH 28/28] fix(catalog): capture service-tier wire authority --- src/codex/catalog/provider-fetch.ts | 16 +++++++++++-- src/providers/service-tier.ts | 36 ++++++++++++++++++++++++++++- 2 files changed, 49 insertions(+), 3 deletions(-) diff --git a/src/codex/catalog/provider-fetch.ts b/src/codex/catalog/provider-fetch.ts index d31f67533c..1f119ef303 100644 --- a/src/codex/catalog/provider-fetch.ts +++ b/src/codex/catalog/provider-fetch.ts @@ -32,7 +32,11 @@ import { modelInList } from "../../types"; import { CODEX_REASONING_LEVELS, codexEffortRank, configuredReasoningEfforts, modelRecordValue, sanitizeCodexReasoningEfforts } from "../../reasoning-effort"; import { getModelMetadata, getModelMetadataCaseInsensitive, listModelMetadata, resolveMetadataProvider } from "../../generated/model-metadata"; import { enrichProviderFromRegistry, shouldCaseFoldMetadataModelId } from "../../providers/derive"; -import { serviceTierSupportForModel } from "../../providers/service-tier"; +import { + captureServiceTierAdapterAuthority, + serviceTierSupportForModel, + type CapturedServiceTierAdapterAuthority, +} from "../../providers/service-tier"; import { effectiveGoogleMode, getProviderRegistryEntry, providerMatchesRegistryTransport } from "../../providers/registry"; import { parseAntigravityAvailableModels } from "../../providers/antigravity-models"; import { applyProviderContextCap, providerContextCap } from "../../providers/context-cap"; @@ -151,6 +155,7 @@ interface CapturedProviderGather { readonly discovery: ResolvedProviderModelDiscovery; readonly policy: CatalogProviderDiscoveryPolicySnapshot; readonly request: CapturedModelsRequest; + readonly serviceTierAdapterAuthority: CapturedServiceTierAdapterAuthority; readonly observedAuth?: ModelsAuthResolution; /** * Configured model ids this provider must keep even when live discovery omits @@ -402,6 +407,12 @@ function captureProviderGather( ): CapturedProviderGather { const enriched = detachedClone(withCanonicalOpenAiForwardAuthDefault(name, configured)); enrichProviderFromRegistry(name, enriched); + const registryTransportMatch = providerMatchesRegistryTransport(name, enriched); + const serviceTierAdapterAuthority = captureServiceTierAdapterAuthority( + name, + enriched, + registryTransportMatch, + ); const provider = recursivelyFreeze(enriched); const observedAuth = authResolver.kind === "observed" && provider.authMode !== "forward" @@ -415,7 +426,6 @@ function captureProviderGather( maxResponseBytes: resolved.maxResponseBytes, maxModels: resolved.maxModels, }); - const registryTransportMatch = providerMatchesRegistryTransport(name, provider); const trustedOpenAiApi = captureTrustedOpenAiApiPolicy(name, registryTransportMatch); const policy = detachedFrozen({ provider: name, @@ -439,6 +449,7 @@ function captureProviderGather( discovery, policy, request, + serviceTierAdapterAuthority, ...(observedAuth ? { observedAuth: Object.freeze({ ...observedAuth }) } : {}), ...(retainConfiguredModelIds && retainConfiguredModelIds.size > 0 ? { retainConfiguredModelIds } @@ -507,6 +518,7 @@ function captureGatherFlight( // It is the one member of a provider row that is legitimately a function, // so it is dropped here rather than allowed to break every encode. provider: omitProviderTransportExecutor(provider.provider), + serviceTierAdapterAuthority: provider.serviceTierAdapterAuthority, // Combo retention is capture-time state, not a provider-row field. Two // gathers that share providers but differ in combo targets must not join. retainConfiguredModelIds: [...(provider.retainConfiguredModelIds ?? [])].sort(), diff --git a/src/providers/service-tier.ts b/src/providers/service-tier.ts index 9d773c3796..aa4fab044a 100644 --- a/src/providers/service-tier.ts +++ b/src/providers/service-tier.ts @@ -1,10 +1,14 @@ import type { OcxProviderConfig } from "../types"; import { MODEL_ADAPTER_OVERRIDE_ALLOWED } from "../types"; -import { providerModelWireDefault, type InboundWire } from "./registry"; +import { getProviderRegistryEntry, providerModelWireDefault, type InboundWire } from "./registry"; /** OpenAI-compatible adapters that can carry the standard `service_tier` field. */ export const SERVICE_TIER_ADAPTERS = new Set(["openai-chat", "openai-responses"]); +export type CapturedServiceTierAdapterAuthority = Readonly>; + +const capturedAdapterAuthority = new WeakMap(); + type ServiceTierCapabilityProvider = Pick< OcxProviderConfig, "adapter" | "supportsServiceTier" | "modelSupportsServiceTier" | "modelAdapters" | "baseUrl" | "authMode" | "chatServiceTier" @@ -56,6 +60,32 @@ export function canSerializeServiceTierForChatModel( return provider.chatServiceTier === true || exact === true; } +/** Capture registry-owned model wire defaults before an asynchronous catalog flight begins. */ +export function captureServiceTierAdapterAuthority( + providerName: string, + provider: Pick, + registryTransportMatch: boolean, + inbound: InboundWire = "responses", +): CapturedServiceTierAdapterAuthority { + const authority: Record = {}; + const defaults = registryTransportMatch + ? getProviderRegistryEntry(providerName)?.modelWireDefaults + : undefined; + for (const modelId of Object.keys(defaults ?? {})) { + const adapter = providerModelWireDefault( + providerName, + provider, + modelId, + MODEL_ADAPTER_OVERRIDE_ALLOWED, + inbound, + ); + if (adapter !== undefined) authority[modelId.trim().toLowerCase()] = adapter; + } + const frozen = Object.freeze(authority); + capturedAdapterAuthority.set(provider, frozen); + return frozen; +} + /** Resolve an explicit model wire override for catalog-time capability projection. */ export function serviceTierAdapterForModel( providerName: string, @@ -67,6 +97,10 @@ export function serviceTierAdapterForModel( // entries are exact-case keys, while registry defaults intentionally normalize ids there. const configured = provider.modelAdapters?.[modelId]; if (configured !== undefined && MODEL_ADAPTER_OVERRIDE_ALLOWED.has(configured)) return configured; + const captured = capturedAdapterAuthority.get(provider); + if (captured !== undefined) { + return captured[modelId.trim().toLowerCase()] ?? provider.adapter; + } return providerModelWireDefault( providerName, provider,