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 d8a59d9281..ad661c72d6 100644 --- a/docs-site/src/content/docs/guides/codex-app-models.md +++ b/docs-site/src/content/docs/guides/codex-app-models.md @@ -120,8 +120,11 @@ fast_mode = true ``` But the model catalog and runtime request tier id use `priority`. opencodex preserves that split. -Native OpenAI passthrough models keep fast support; routed non-OpenAI models strip service-tier -metadata so the fast option is not advertised where it cannot be honored. +Native OpenAI passthrough models keep fast support. Responses providers only receive proxy-generated +OpenAI-specific `service_tier` when their registry capability explicitly opts in; an omitted +capability fails closed — a caller-supplied value is stripped and fastMode never injects one. +DeepSeek V4 Flash's and Volcengine Agent Plan's native Responses routes are explicitly marked +unsupported, so the field stays out of their upstream requests. ## Subagent selection diff --git a/docs-site/src/content/docs/ja/guides/codex-app-models.md b/docs-site/src/content/docs/ja/guides/codex-app-models.md index 457df93ff7..64667281da 100644 --- a/docs-site/src/content/docs/ja/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ja/guides/codex-app-models.md @@ -87,6 +87,7 @@ fast_mode = true ``` ただし、モデル カタログとランタイム リクエスト層 ID は `priority` を使用します。 opencodex はその分割を保持します。ネイティブ OpenAI パススルー モデルは高速サポートを維持します。ルーティングされた非 OpenAI モデルはサービス層メタデータを削除するため、高速オプションが受け入れられない場合はアドバタイズされません。 +ネイティブ OpenAI パススルー モデルは fast をサポートします。Responses provider は registry capability が明示的に許可する場合だけ proxy が生成した OpenAI 固有の `service_tier` を受け取ります。capability を省略すると fail-closed となり、呼び出し側の値は削除され、fastMode は注入しません。DeepSeek V4 Flash と Volcengine Agent Plan のネイティブ Responses ルートは明示的に未対応とされるため、このフィールドは上流に送られません。 ## サブエージェントの選択 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 c9875cd135..a1646d8b7c 100644 --- a/docs-site/src/content/docs/ja/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ja/reference/configuration/providers.md @@ -41,6 +41,7 @@ description: プロバイダー エントリ、認証、エンドポイント、 | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai` (または別名 `azure`) のいずれか。 | | `baseUrl` | `string` |アップストリーム API のベース URL。ほとんどの組み込み固定エンドポイントは不一致を無視します。衝突安全キー プリセットは、古い同じ名前のカスタム宛先を保持します。 | | `responsesPath?` | `string` |キー認証 `openai-responses` リクエストの相対リソース パス。 `/` で始まり、スキーム、クエリ、またはフラグメントが含まれていない必要があります。 | +| `supportsServiceTier?` | `boolean` | Responses の tier 能力。`true` は proxy の `fastMode` による OpenAI `service_tier` の設定/削除、`false` は未対応 upstream への送信前削除、未設定は fail-closed(呼び出し側の値を削除し、`fastMode` は注入しない)を意味します。registry preset が補完するため、custom provider は上流仕様で確認できる場合だけ `true` にしてください。 | | `disabled?` | `boolean` |プロバイダーをディスク上に保持しますが、ルーティングおよびモデル/カタログのリストからは除外します。 | | `apiKey?` | `string` | API キー、またはリクエスト時に解決される `${ENV_VAR}` / `$ENV_VAR` 参照。 | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic キーのヘッダー スタイル。デフォルトはネイティブ `x-api-key` です。キー認証 `anthropic` プロバイダーにのみ有効です。 | diff --git a/docs-site/src/content/docs/ko/guides/codex-app-models.md b/docs-site/src/content/docs/ko/guides/codex-app-models.md index fd2945470f..fb854f971e 100644 --- a/docs-site/src/content/docs/ko/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ko/guides/codex-app-models.md @@ -118,8 +118,11 @@ fast_mode = true ``` 하지만 모델 카탈로그와 런타임 요청 tier id는 `priority`를 씁니다. opencodex는 이 분리를 그대로 -유지합니다. 네이티브 OpenAI passthrough 모델은 fast 지원을 유지하고, 라우팅된 비 OpenAI 모델에서는 -service-tier 메타데이터를 지워 fast 옵션이 처리 불가능한 곳에서는 노출되지 않게 합니다. +유지합니다. 네이티브 OpenAI passthrough 모델은 fast 지원을 유지합니다. Responses provider는 +registry capability가 명시적으로 허용할 때만 proxy가 생성한 OpenAI 전용 `service_tier`를 받습니다. +capability를 생략하면 fail-closed로 호출자 값도 삭제되고 fastMode가 주입되지 않습니다. DeepSeek +V4 Flash와 Volcengine Agent Plan의 네이티브 Responses 라우트는 명시적으로 미지원으로 표시되어 이 +필드를 업스트림으로 보내지 않습니다. ## 서브에이전트 선택 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 b3adefda8a..6acb536791 100644 --- a/docs-site/src/content/docs/ko/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ko/reference/configuration/providers.md @@ -41,6 +41,7 @@ description: 공급자 항목, 인증, 엔드포인트, 모델 카탈로그, 할 | `adapter` | `string` | `openai-chat`, `openai-responses`, `anthropic`, `google`, `kiro`, `cursor`, `azure-openai` 중 하나이며, `azure`는 별칭입니다. | | `baseUrl` | `string` | 상위 API 기본 URL입니다. 대부분의 내장 고정 엔드포인트는 불일치를 무시합니다. 충돌 안전 키 프리셋은 같은 이름의 이전 사용자 지정 목적지를 보존합니다. | | `responsesPath?` | `string` | 키 인증 `openai-responses` 요청의 상대 리소스 경로입니다. 반드시 `/`로 시작해야 하며 스킴, query, fragment를 포함하면 안 됩니다. | +| `supportsServiceTier?` | `boolean` | Responses tier capability입니다. `true`는 proxy `fastMode`가 OpenAI `service_tier`를 설정/삭제하게 하고, `false`는 지원하지 않는 업스트림으로 보내기 전에 삭제하며, 생략하면 fail-closed로 호출자 값도 삭제하고 `fastMode`가 주입하지 않습니다. registry preset이 보완하므로 custom provider는 업스트림 문서에서 확인된 경우에만 `true`로 설정하세요. | | `disabled?` | `boolean` | 공급자를 디스크에는 남기되, 라우팅과 모델/카탈로그 목록에서는 제외합니다. | | `apiKey?` | `string` | API 키 또는 요청 시점에 해석되는 `${ENV_VAR}` / `$ENV_VAR` 참조입니다. | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic 키 헤더 형식입니다. 기본값은 네이티브 `x-api-key`이며, 키 인증 `anthropic` 공급자에만 유효합니다. | diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 5109a9d8ea..f31a051d4b 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -52,6 +52,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. | | `responsesPath?` | `string` | Relative resource path for key-auth `openai-responses` requests. It must start with `/` and contain no scheme, query, or fragment. | +| `supportsServiceTier?` | `boolean` | Responses service-tier capability: `true` lets proxy `fastMode` set/remove OpenAI's fast/priority `service_tier`; `false` strips the field for an upstream that rejects it; omitted fails closed — a caller-supplied value is stripped and `fastMode` never injects one. Registry presets backfill this capability; custom providers should set `true` only when the upstream documents `service_tier`. | | `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. | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key header style. Defaults to native `x-api-key`; valid only for key-auth `anthropic` providers. | diff --git a/docs-site/src/content/docs/ru/guides/codex-app-models.md b/docs-site/src/content/docs/ru/guides/codex-app-models.md index ad358fb8ff..e933e00547 100644 --- a/docs-site/src/content/docs/ru/guides/codex-app-models.md +++ b/docs-site/src/content/docs/ru/guides/codex-app-models.md @@ -124,8 +124,11 @@ fast_mode = true ``` Но каталог моделей и id tier'а во время выполнения используют `priority`. opencodex сохраняет это -разделение. Нативные passthrough-модели OpenAI сохраняют поддержку fast; routed не-OpenAI модели -теряют service-tier metadata, чтобы опция fast не рекламировалась там, где её нельзя выполнить. +разделение. Нативные passthrough-модели OpenAI сохраняют поддержку fast. Responses-провайдеры +получают созданное proxy поле `service_tier` только при явном разрешении registry capability; при +отсутствии capability применяется fail-closed — значение вызывающей стороны удаляется, а fastMode +ничего не инъецирует. Нативные Responses-маршруты DeepSeek V4 Flash и Volcengine Agent Plan явно +помечены как неподдерживающие поле, поэтому оно не отправляется на их upstream. ## Выбор подагентов 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 6805a6e552..4765241975 100644 --- a/docs-site/src/content/docs/ru/reference/configuration/providers.md +++ b/docs-site/src/content/docs/ru/reference/configuration/providers.md @@ -57,6 +57,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 с тем же именем. | | `responsesPath?` | `string` | Relative resource path для key-auth запросов `openai-responses`. Должен начинаться с `/` и не может содержать scheme, query или fragment. | +| `supportsServiceTier?` | `boolean` | Capability уровня Responses: `true` разрешает proxy `fastMode` устанавливать/удалять OpenAI `service_tier`; `false` удаляет поле перед отправкой upstream, который его не поддерживает; отсутствие значения означает fail-closed — значение вызывающей стороны удаляется, а `fastMode` ничего не инъецирует. Registry preset дополняет capability; для custom provider задавайте `true` только при подтверждении в документации upstream. | | `disabled?` | `boolean` | Сохранить провайдера на диске, но исключить его из routing'а и из model/catalog-listing'ов. | | `apiKey?` | `string` | API-key либо ссылка `${ENV_VAR}` / `$ENV_VAR`, разрешаемая при каждом запросе. | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Header-style для ключа Anthropic. По умолчанию нативный `x-api-key`; допустим только для key-auth-провайдеров `anthropic`. | diff --git a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md index 71b57efe3b..45940c3ebb 100644 --- a/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md +++ b/docs-site/src/content/docs/zh-cn/guides/codex-app-models.md @@ -84,6 +84,7 @@ fast_mode = true ``` 但模型目录和运行时请求里的 tier id 使用的是 `priority`。opencodex 保留了这个拆分。原生 OpenAI 透传模型保留 fast 支持;路由到非 OpenAI 模型时会移除 service-tier 元数据,因此无法兑现的 fast 选项不会被展示出来。 +原生 OpenAI 透传模型保留 fast 支持。只有 registry capability 明确允许时,Responses provider 才会收到 proxy 注入的 OpenAI 专用 `service_tier`;省略 capability 时 fail-closed——调用方已有值会被移除,且 fastMode 不会注入。DeepSeek V4 Flash 与 Volcengine Agent Plan 的原生 Responses 路由被明确标记为不支持该字段,因此不会把它发送给上游。 ## 子代理选择 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 71f5b9218c..ae807bf437 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 @@ -41,6 +41,7 @@ description: 提供者条目、身份验证、端点、模型目录、配额、 | `adapter` | `string` | `openai-chat`、`openai-responses`、`anthropic`、`google`、`kiro`、`cursor`、`azure-openai`(或别名 `azure`)之一。 | | `baseUrl` | `string` | 上游 API 基础 URL。大多数内置固定端点会忽略不匹配的值;具备冲突安全键的预设会保留一个更早、同名的自定义目标。 | | `responsesPath?` | `string` | 用于 key-auth `openai-responses` 请求的相对资源路径。必须以 `/` 开头,且不能包含 scheme、query 或 fragment。 | +| `supportsServiceTier?` | `boolean` | Responses 的 service-tier 能力:`true` 允许 proxy 的 `fastMode` 设置/移除 OpenAI fast/priority 的 `service_tier`;`false` 会为拒绝该字段的上游移除它;省略时 fail-closed —— 移除调用方已有值,且不让 `fastMode` 注入。registry preset 会回填此能力;自定义 provider 只有在上游文档明确支持时才应设为 `true`。 | | `disabled?` | `boolean` | 将提供者保留在磁盘上,但从路由和模型/目录列表中排除。 | | `apiKey?` | `string` | API key,或在请求时解析的 `${ENV_VAR}` / `$ENV_VAR` 引用。 | | `apiKeyTransport?` | `"x-api-key" \| "bearer"` | Anthropic key 头部样式。默认使用原生 `x-api-key`;仅对 key-auth `anthropic` 提供者有效。 | diff --git a/src/adapters/openai-responses.ts b/src/adapters/openai-responses.ts index 132ad1f81b..de8063fd7b 100644 --- a/src/adapters/openai-responses.ts +++ b/src/adapters/openai-responses.ts @@ -540,9 +540,9 @@ function stripPreviousResponseId(body: unknown, strip: boolean): unknown { * `prompt` is a reference to a server-stored prompt template — the most stateful * field in the accepted schema. * - * `service_tier` is deliberately NOT dropped: the server writes it for fast mode - * (`responses/core.ts`), and silently deleting a configured knob inside an adapter is - * worse than forwarding a parameter the upstream ignores. + * `service_tier` is deliberately NOT dropped here: the server owns the provider + * capability decision in `responses/core.ts` after the final route is known, so the + * adapter must stay provider-agnostic and forward what normalization left in place. * * MUST run before the composed sanitize chain below: `stripItemIdsWhenUnstored` keys * off `store === false`, and a stateless upstream cannot resolve a stored item id. diff --git a/src/config.ts b/src/config.ts index 723aca09e7..74e8a81c18 100644 --- a/src/config.ts +++ b/src/config.ts @@ -482,6 +482,7 @@ const providerConfigSchema = z.object({ apiKeyTransport: z.enum(["x-api-key", "bearer"]).optional(), responsesPath: z.string().min(1).optional(), statelessResponses: z.boolean().optional(), + supportsServiceTier: z.boolean().optional(), allowPrivateNetwork: z.boolean().optional(), codexAccountMode: z.enum(["pool", "direct"]).optional(), responsesItemIdRepair: z.object({ diff --git a/src/providers/derive.ts b/src/providers/derive.ts index 03e66f5cad..d2eaa0edf2 100644 --- a/src/providers/derive.ts +++ b/src/providers/derive.ts @@ -5,6 +5,7 @@ export interface DerivedKeyLoginProvider { label: string; baseUrl: string; responsesPath?: string; + supportsServiceTier?: boolean; adapter: string; apiKeyTransport?: OcxProviderConfig["apiKeyTransport"]; dashboardUrl: string; @@ -158,6 +159,7 @@ export function deriveKeyLoginMap(): Record { label: entry.label, baseUrl: entry.baseUrl, ...(entry.responsesPath ? { responsesPath: entry.responsesPath } : {}), + ...(entry.supportsServiceTier !== undefined ? { supportsServiceTier: entry.supportsServiceTier } : {}), adapter: entry.adapter, ...(entry.apiKeyTransport !== undefined ? { apiKeyTransport: entry.apiKeyTransport } : {}), dashboardUrl: entry.dashboardUrl, @@ -259,6 +261,11 @@ export function enrichProviderFromRegistry(name: string, prov: OcxProviderConfig // learned this route still gets backfilled. if (prov.responsesPath === undefined && seed.responsesPath !== undefined) prov.responsesPath = seed.responsesPath; if (prov.statelessResponses === undefined && seed.statelessResponses !== undefined) prov.statelessResponses = seed.statelessResponses; + // Fail-closed backfill: when the registry knows the capability it always applies it, + // and when it does not, the server's unknown-capability default strips caller tiers. + if (prov.supportsServiceTier === undefined && entry.supportsServiceTier !== undefined) { + prov.supportsServiceTier = entry.supportsServiceTier; + } if (!prov.autoToolChoiceOnlyModels && seed.autoToolChoiceOnlyModels) prov.autoToolChoiceOnlyModels = [...seed.autoToolChoiceOnlyModels]; if (!prov.preserveReasoningContentModels && seed.preserveReasoningContentModels) prov.preserveReasoningContentModels = [...seed.preserveReasoningContentModels]; if (!prov.reasoningSplitModels && seed.reasoningSplitModels) prov.reasoningSplitModels = [...seed.reasoningSplitModels]; diff --git a/src/providers/registry.ts b/src/providers/registry.ts index e336c2f394..f04ed04bbc 100644 --- a/src/providers/registry.ts +++ b/src/providers/registry.ts @@ -161,6 +161,8 @@ export interface ProviderRegistryEntry { * replay miss are repaired rather than forwarded. */ statelessResponses?: boolean; + /** Responses service-tier capability: true=proxy override, false=strip, unset=fail closed (strip + no inject). */ + supportsServiceTier?: boolean; modelDiscovery?: ProviderModelDiscoverySpec; contextWindow?: number; modelContextWindows?: Record; @@ -574,6 +576,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ adapter: "openai-responses", baseUrl: "https://chatgpt.com/backend-api/codex", authKind: "forward", + supportsServiceTier: true, codexAccountMode: "pool", featured: true, note: "Codex login account pool (default) or Direct main-account mode via codexAccountMode", @@ -745,6 +748,7 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ adapter: "openai-responses", baseUrl: "https://api.openai.com/v1", authKind: "key", + supportsServiceTier: true, featured: true, dashboardUrl: "https://platform.openai.com/api-keys", defaultModel: "gpt-5.5", @@ -968,6 +972,8 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ // "The API is stateless: responses and conversations are not stored on the // server." https://api-docs.deepseek.com/api/create-response/ statelessResponses: true, + // DeepSeek's Responses schema does not document OpenAI's service_tier field. + supportsServiceTier: false, /* [Decision Log] - 목적: DeepSeek V4 thinking mode multi-turn/tool-call requests must replay prior assistant reasoning_content. - 대안 분석: Globally preserve reasoning_content for all OpenAI-compatible models; preserve it for legacy deepseek-reasoner too; mark only V4 thinking models in registry metadata. @@ -1245,6 +1251,9 @@ export const PROVIDER_REGISTRY: readonly ProviderRegistryEntry[] = [ responsesPath: "/responses", adapter: "openai-responses", authKind: "key", + // The Agent Plan Responses route does not document OpenAI's service_tier field, so + // an unknown caller tier or proxy fastMode priority must never be forwarded. + supportsServiceTier: false, preserveCustomDestination: true, dashboardUrl: "https://console.volcengine.com/ark/region:ark+cn-beijing/overview", defaultModel: "deepseek-v4-pro", diff --git a/src/router.ts b/src/router.ts index 0c562240c4..148c3520bc 100644 --- a/src/router.ts +++ b/src/router.ts @@ -259,6 +259,11 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig) ...(provider.responsesPath === undefined && registryEntry.responsesPath !== undefined ? { responsesPath: registryEntry.responsesPath } : {}), + // Backfill the registry capability; when it is absent the server's unknown-capability + // default strips caller service_tier and never injects fastMode (fail closed). + ...(provider.supportsServiceTier === undefined && registryEntry.supportsServiceTier !== undefined + ? { supportsServiceTier: registryEntry.supportsServiceTier } + : {}), authMode: canonicalAuthMode, apiKey: resolvedApiKey, // Backfill the Google wire mode + Vertex project/location from the registry when the user diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index fabbc6ad8c..83f98b3ab4 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -799,14 +799,31 @@ async function applyFinalRouteRequestNormalization(args: { // Virtual model rewriting: Pro aliases → base model + reasoning.mode="pro". applyOpenAiVirtualModel(parsed, route, logCtx); - // Fast mode override for OpenAI-routed models. - if (config.fastMode !== undefined && route.provider.adapter === "openai-responses") { - const tier = config.fastMode ? "priority" : undefined; - if (parsed._rawBody && typeof parsed._rawBody === "object") { - if (tier) (parsed._rawBody as Record).service_tier = tier; - else delete (parsed._rawBody as Record).service_tier; + // OpenAI `service_tier` is a provider capability, not a property of the Responses wire. + // Some Responses-compatible providers (DeepSeek, Volcengine Agent Plan, for example) do + // not document the field and reject unknown top-level parameters, so an unknown or + // omitted capability must fail closed: strip a caller-supplied tier and never inject + // one. Only an explicit `supportsServiceTier: true` preserves or rewrites the field. + const isResponsesWire = route.provider.adapter === "openai-responses"; + if (isResponsesWire) { + const raw = parsed._rawBody && typeof parsed._rawBody === "object" + ? parsed._rawBody as Record + : undefined; + if (route.provider.supportsServiceTier === true && config.fastMode !== undefined) { + const tier = config.fastMode ? "priority" : undefined; + if (raw) { + if (tier) raw.service_tier = tier; + else delete raw.service_tier; + } + parsed.options.serviceTier = tier; + } else if (route.provider.supportsServiceTier !== true) { + // Unknown capability fails closed: DeepSeek's Responses schema does not document + // `service_tier`, so leaving a caller-supplied value in the passthrough body would + // still produce a 400 even when fastMode is unset. The same default protects other + // unclassified Responses gateways until they opt in explicitly. + if (raw) delete raw.service_tier; + parsed.options.serviceTier = undefined; } - parsed.options.serviceTier = tier; } { diff --git a/src/types.ts b/src/types.ts index bae193fb6e..dd9aca09ed 100644 --- a/src/types.ts +++ b/src/types.ts @@ -936,6 +936,14 @@ export interface OcxProviderConfig { * forwarded to an upstream that cannot resolve their pair. */ statelessResponses?: boolean; + /** + * Responses service-tier capability. `true` lets proxy fastMode set/remove the + * OpenAI `service_tier`; `false` strips it for an upstream that rejects the field; + * omitted/unknown fails closed: a caller-supplied value is stripped and fastMode never + * injects one. Registry presets backfill the capability; custom providers can opt in + * explicitly only when the upstream documents `service_tier`. + */ + supportsServiceTier?: boolean; /** * Explicit opt-in for non-registry private-network destinations such as localhost, RFC1918, * link-local, or unique-local upstreams. Metadata endpoints remain blocked. diff --git a/tests/deepseek-inbound-wire.test.ts b/tests/deepseek-inbound-wire.test.ts index b8403f0253..aec5518e58 100644 --- a/tests/deepseek-inbound-wire.test.ts +++ b/tests/deepseek-inbound-wire.test.ts @@ -12,7 +12,7 @@ * assert the captured upstream URL, which is externally observable. */ import { afterEach, describe, expect, test } from "bun:test"; -import { providerConfigSeed } from "../src/providers/derive"; +import { enrichProviderFromRegistry, providerConfigSeed } from "../src/providers/derive"; import { getProviderRegistryEntry } from "../src/providers/registry"; import { createResponsesPassthroughAdapter as createResponsesPassthroughAdapterProduction } from "../src/adapters/openai-responses"; import { resolveWireProtocolOverride } from "../src/server/adapter-resolve"; @@ -148,9 +148,9 @@ describe("stateless Responses upstreams get no stateful parameters", () => { expect(body.store).toBe(false); }); - test("service_tier survives, because the server sets it for fast mode", () => { - // Deleting a configured knob inside an adapter would be action-at-a-distance; - // forwarding a parameter the upstream ignores is the reversible choice. + test("service_tier remains adapter-passthrough data until route normalization", () => { + // The adapter must not make provider-capability decisions. The server owns that + // normalization after the final route is known. const body = buildBody({ ...deepseekProvider(), adapter: "openai-responses" }, { service_tier: "priority" }); expect(body.service_tier).toBe("priority"); }); @@ -166,9 +166,17 @@ describe("stateless Responses upstreams get no stateful parameters", () => { expect(body.store).toBeUndefined(); }); - test("the seed and backfill carry the capability, and only for declaring entries", () => { + test("registry enrichment backfills the capability without expanding persisted seeds", () => { expect(providerConfigSeed(getProviderRegistryEntry("deepseek")!).statelessResponses).toBe(true); + // Seeds stay lean: the registry-only capability is backfilled by enrichment/routing. + expect(providerConfigSeed(getProviderRegistryEntry("deepseek")!).supportsServiceTier).toBeUndefined(); + expect(providerConfigSeed(getProviderRegistryEntry("volcengine-agent-plan")!).supportsServiceTier).toBeUndefined(); expect(providerConfigSeed(getProviderRegistryEntry("cerebras")!).statelessResponses).toBeUndefined(); + expect(providerConfigSeed(getProviderRegistryEntry("cerebras")!).supportsServiceTier).toBeUndefined(); + const enriched = deepseekProvider(); + delete enriched.supportsServiceTier; + enrichProviderFromRegistry("deepseek", enriched); + expect(enriched.supportsServiceTier).toBe(false); }); test("a replay miss does not forward an orphaned tool result", () => { @@ -197,3 +205,179 @@ describe("stateless Responses upstreams get no stateful parameters", () => { expect(input.some(item => item.type === "message")).toBe(true); }); }); + +describe("Responses service-tier injection is capability-gated and fails closed", () => { + const originalFetch = globalThis.fetch; + afterEach(() => { globalThis.fetch = originalFetch; }); + + interface UpstreamCall { + url: string; + body: Record; + } + + function captureCalls(): UpstreamCall[] { + const calls: UpstreamCall[] = []; + globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + calls.push({ + url: String(input), + body: typeof init?.body === "string" ? JSON.parse(init.body) as Record : {}, + }); + return new Response("data: [DONE]\n\n", { + status: 200, + headers: { "content-type": "text/event-stream" }, + }); + }) as typeof fetch; + return calls; + } + + async function drive(config: OcxConfig, body: Record): Promise<{ + response: Response; + calls: UpstreamCall[]; + }> { + const calls = captureCalls(); + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + config, + { model: "", provider: "" }, + { inboundWire: "responses" }, + ); + return { response, calls }; + } + + async function forwardBody(config: OcxConfig, body: Record): Promise> { + const { response, calls } = await drive(config, body); + // Tests must prove one successful upstream call, not vacuous pass-throughs. + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + return calls[0]!.body; + } + + function deepseekWithoutCapability(): OcxProviderConfig { + const provider = deepseekProvider(); + // Simulate a pre-capability config saved before this registry field existed. The + // router must backfill the registry `false`, so this exercises the stale-config path. + delete provider.supportsServiceTier; + return provider; + } + + test("an omitted capability strips a caller tier and never injects fastMode (DeepSeek stale config)", async () => { + const config = { + fastMode: true, + defaultProvider: "deepseek", + providers: { deepseek: deepseekWithoutCapability() }, + } as unknown as OcxConfig; + const body = await forwardBody(config, { model: MODEL, input: "ping", stream: true, service_tier: "priority" }); + expect(body.service_tier).toBeUndefined(); + }); + + test("an omitted capability strips a caller tier even when fastMode is unset", async () => { + const config = { + defaultProvider: "deepseek", + providers: { deepseek: deepseekWithoutCapability() }, + } as unknown as OcxConfig; + const body = await forwardBody(config, { model: MODEL, input: "ping", stream: true, service_tier: "priority" }); + expect(body.service_tier).toBeUndefined(); + }); + + test("an unclassified custom Responses provider also fails closed", async () => { + const config = { + fastMode: true, + defaultProvider: "custom", + providers: { + custom: { + adapter: "openai-responses", + baseUrl: "https://api.openai.com/v1", + authMode: "key", + apiKey: "sk-test", + }, + }, + } as unknown as OcxConfig; + const body = await forwardBody(config, { model: "custom-model", input: "ping", stream: true, service_tier: "priority" }); + expect(body.service_tier).toBeUndefined(); + }); + + test("keeps fast mode for the OpenAI API Responses provider", async () => { + const provider = { + ...providerConfigSeed(getProviderRegistryEntry("openai-apikey")!), + apiKey: "sk-test", + }; + // The router must backfill registry capabilities for an older persisted provider row. + delete provider.supportsServiceTier; + const config = { + fastMode: true, + defaultProvider: "openai-apikey", + providers: { "openai-apikey": provider }, + } as unknown as OcxConfig; + expect((await forwardBody(config, { model: "openai-apikey/gpt-5.5", input: "ping", stream: true })).service_tier) + .toBe("priority"); + }); + + test("removes a caller tier when OpenAI fast mode is explicitly disabled", async () => { + const provider = { + ...providerConfigSeed(getProviderRegistryEntry("openai-apikey")!), + apiKey: "sk-test", + }; + delete provider.supportsServiceTier; + const config = { + fastMode: false, + defaultProvider: "openai-apikey", + providers: { "openai-apikey": provider }, + } as unknown as OcxConfig; + expect((await forwardBody(config, { model: "openai-apikey/gpt-5.5", input: "ping", stream: true, service_tier: "priority" })).service_tier) + .toBeUndefined(); + }); + + test("preserves a caller tier for an explicit supportsServiceTier provider when fastMode is unset", async () => { + const provider = { + ...providerConfigSeed(getProviderRegistryEntry("openai-apikey")!), + apiKey: "sk-test", + supportsServiceTier: true, + }; + const config = { + defaultProvider: "openai-apikey", + providers: { "openai-apikey": provider }, + } as unknown as OcxConfig; + const body = await forwardBody(config, { model: "openai-apikey/gpt-5.5", input: "ping", stream: true, service_tier: "priority" }); + expect(body.service_tier).toBe("priority"); + }); + + test("the canonical openai Codex-login route forwards priority on the native wire", async () => { + const config = { + fastMode: true, + defaultProvider: "openai", + providers: { + openai: { + adapter: "openai-responses", + baseUrl: "https://chatgpt.com/backend-api/codex", + authMode: "forward", + codexAccountMode: "direct", + supportsServiceTier: true, + }, + }, + } as unknown as OcxConfig; + const calls = captureCalls(); + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { + "content-type": "application/json", + // `direct` mode requires the caller's own Codex bearer token before the proxy + // forwards the request upstream; this is not a proxy admission secret. + authorization: "Bearer sk-codex-login-test", + }, + body: JSON.stringify({ model: "gpt-5.5", input: "ping", stream: true }), + }), + config, + { model: "", provider: "" }, + { inboundWire: "responses" }, + ); + expect(response.status).toBe(200); + expect(calls).toHaveLength(1); + expect(calls[0]!.url).toBe("https://chatgpt.com/backend-api/codex/responses"); + expect(calls[0]!.body.service_tier).toBe("priority"); + }); +});