From 0ef5c655fc3cbb99d9d6e174d50af0c58a02dccd Mon Sep 17 00:00:00 2001 From: fontanierh Date: Mon, 19 Oct 2026 11:52:23 +0200 Subject: [PATCH 1/2] Keep DeepSeek image replays below the provider body limit --- .../docs/reference/configuration/providers.md | 25 +- scripts/test-layout/layout.json | 1 + src/adapters/base.ts | 2 + src/config/provider-validation.ts | 9 +- src/lib/translator-budget.ts | 2 +- src/server/responses/deepseek-image-files.ts | 121 ++++++++ src/server/responses/request-transport.ts | 35 +++ src/types/provider.ts | 2 + structure/account-auto-pause.md | 4 + structure/config.md | 2 +- structure/transports/byte-accounting.md | 26 ++ structure/transports/responses.md | 2 +- tests/fixtures/test-layout-expected.json | 2 +- tests/providers/deepseek-image-files.test.ts | 269 ++++++++++++++++++ .../providers/model-rename-migration.test.ts | 3 +- .../provider-config-validation.test.ts | 43 +++ 16 files changed, 540 insertions(+), 8 deletions(-) create mode 100644 src/server/responses/deepseek-image-files.ts create mode 100644 tests/providers/deepseek-image-files.test.ts diff --git a/docs-site/src/content/docs/reference/configuration/providers.md b/docs-site/src/content/docs/reference/configuration/providers.md index 62bb5f93c0f..f834a070d31 100644 --- a/docs-site/src/content/docs/reference/configuration/providers.md +++ b/docs-site/src/content/docs/reference/configuration/providers.md @@ -8,6 +8,29 @@ description: Provider entries, authentication, endpoints, model catalogs, quotas A provider tells opencodex where a model lives, which wire adapter it speaks, and how requests are authenticated. +## DeepSeek: tokens versus image bytes + +DeepSeek documents a separate [48 MiB request-body limit](https://api-docs.deepseek.com/guides/files_api/). +A screenshot-heavy session can reach that limit before its token context fills; increasing +`model_auto_compact_token_limit` cannot prevent it. + +Uploads are **off by default**. Set `modelCapabilities: { "deepseek-flash": { deepseekImageFiles: true } }` +on your provider if you accept separately persisted images on DeepSeek's servers for one hour. +PATCH `null` on the axis or model entry clears the opt-in. + +For opted-in models on first-party Responses URLs, bodies above 40 MiB replace older inline images +with [Files API references](https://api-docs.deepseek.com/guides/files_api/), aiming for 32 MiB. +Image bytes, history and token usage stay unchanged. Files request one-hour expiry; successful +partial uploads remain until expiry even if generation fails. The bounded reference cache is +credential-isolated and lasts at most 50 minutes (less if the provider reports earlier expiry). +A retry can reuse successful uploads; another credential uploads its own copy. + +Foreign gateways and Chat Completions are unchanged. Explicit `detail: low` stays inline because +file references ignore it. Failures stop without discarding images or retrying generation. Repairs +have a 60-second upload deadline, a 128 MiB input cap and at most two simultaneous preparations; +provider pacing/concurrency still applies. Oversized text or unconvertible images still need +compaction; this transport repair does not extend the model's context window. + ## Context windows on custom providers When a custom provider omits a model's context window, OpenCodex can infer it from the bundled @@ -1785,7 +1808,7 @@ contracts. ### Per-model capability declarations -`modelCapabilities` stores explicit declarations keyed by exact upstream model ID. IDs preserve case and must not contain surrounding whitespace. Each entry may contain `inputModalities` (`text`, `image`, `audio`, `video`), `contextTier` (`default`, `long_context`) and `video.processing` (`static`, `agentic`). These are operator declarations, not proof of provider support. Context-tier and video fields currently record intent only and do not activate upstream behavior or increase catalog windows. +`modelCapabilities` stores explicit declarations keyed by exact upstream model ID. IDs preserve case and must not contain surrounding whitespace. Each entry may contain `inputModalities` (`text`, `image`, `audio`, `video`), `contextTier` (`default`, `long_context`), `video.processing` (`static`, `agentic`) and `deepseekImageFiles` (`boolean`; see [DeepSeek image bytes](#deepseek-tokens-versus-image-bytes)). These are operator declarations, not proof of provider support. Context-tier and video fields currently record intent only and do not activate upstream behavior or increase catalog windows. The raw provider editor and provider API expose this map. POST/PUT replace an explicitly supplied map and reject null entries. PATCH merges individual axes; null clears a map, model, axis or video processing value, while `{}` makes no change. Omitted provider overwrites preserve the existing map. Malformed hand-edited files retain valid independent axes and treat malformed explicit input modalities as text-only, with a diagnostic. diff --git a/scripts/test-layout/layout.json b/scripts/test-layout/layout.json index 1c6e4ff4d1b..29f0cd17df3 100644 --- a/scripts/test-layout/layout.json +++ b/scripts/test-layout/layout.json @@ -873,6 +873,7 @@ "server-jev-model-decision-e2e.test.ts": "server", "server-jev-model-quota-budget.test.ts": "server", "decision-routes.test.ts": "server", "deepinfra-provider.test.ts": "providers", + "deepseek-image-files.test.ts": "providers", "deepseek-inbound-wire.test.ts": "providers", "deepseek-reasoning-replay-gaps.test.ts": "providers", "deepseek-bare-tool-call-replay.test.ts": "providers", diff --git a/src/adapters/base.ts b/src/adapters/base.ts index b30f1586a78..f0b11af06c3 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -14,6 +14,8 @@ export interface IncomingMeta { abortSignal?: AbortSignal; /** Lease acquired before the response is committed; the physical send transfers it to its body. */ pacingSlot?: ProviderRequestSlot; + /** Internal queued rebuild already owns the outer provider fetch admission. */ + dispatchPacingHeld?: boolean; /** Combo children must surface a pre-output refusal so the selector can try the next target. */ comboAttempt?: boolean; /** diff --git a/src/config/provider-validation.ts b/src/config/provider-validation.ts index 70842b39add..e093eea6e9a 100644 --- a/src/config/provider-validation.ts +++ b/src/config/provider-validation.ts @@ -463,7 +463,7 @@ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = f if (allowTombstones && row === null) continue; if (!capabilityRecord(row)) return "modelCapabilities rows must be plain objects"; for (const [axis, declaration] of Object.entries(row)) { - if (!["inputModalities", "contextTier", "video"].includes(axis)) return "modelCapabilities contains an unknown axis"; + if (!["inputModalities", "contextTier", "video", "deepseekImageFiles"].includes(axis)) return "modelCapabilities contains an unknown axis"; if (allowTombstones && declaration === null) continue; if (axis === "inputModalities") { if (!Array.isArray(declaration) || declaration.length === 0 @@ -472,6 +472,8 @@ export function modelCapabilitiesConfigError(value: unknown, allowTombstones = f } } else if (axis === "contextTier") { if (declaration !== "default" && declaration !== "long_context") return "modelCapabilities contextTier must be default or long_context"; + } else if (axis === "deepseekImageFiles") { + if (typeof declaration !== "boolean") return "modelCapabilities deepseekImageFiles must be a boolean"; } else { if (!capabilityRecord(declaration) || Object.keys(declaration).some(key => key !== "processing")) { return "modelCapabilities video must be a plain object containing only processing"; @@ -501,6 +503,9 @@ export function mergeModelCapabilities( } else if (axis === "contextTier") { if (value === null) delete row.contextTier; else row.contextTier = value as ModelCapabilities["contextTier"]; + } else if (axis === "deepseekImageFiles") { + if (value === null) delete row.deepseekImageFiles; + else row.deepseekImageFiles = value as boolean; } else if (axis === "video") { if (value === null) delete row.video; else { @@ -528,7 +533,7 @@ export function sanitizeModelCapabilitiesForLoad(value: unknown): Record = {}; - for (const axis of ["inputModalities", "contextTier", "video"] as const) { + for (const axis of ["inputModalities", "contextTier", "video", "deepseekImageFiles"] as const) { if (!Object.hasOwn(raw, axis)) continue; const declaration = raw[axis]; if (modelCapabilitiesConfigError({ [id]: { [axis]: declaration } }) === null) row[axis] = declaration; diff --git a/src/lib/translator-budget.ts b/src/lib/translator-budget.ts index f6e184683dd..c188071cc4b 100644 --- a/src/lib/translator-budget.ts +++ b/src/lib/translator-budget.ts @@ -23,7 +23,7 @@ export type TranslatorBufferKind = | "cursor_kv" | "request_copies"; -export type ExternallyCappedKind = "passthrough_serialization" | "mcp_payload"; +export type ExternallyCappedKind = "passthrough_serialization" | "mcp_payload" | "provider_image_upload"; export class TranslatorBudgetExceededError extends Error { readonly code = "translation_buffer_limit"; diff --git a/src/server/responses/deepseek-image-files.ts b/src/server/responses/deepseek-image-files.ts new file mode 100644 index 00000000000..9834a040af9 --- /dev/null +++ b/src/server/responses/deepseek-image-files.ts @@ -0,0 +1,121 @@ +import type { TranslatorBudget } from "../../lib/translator-budget"; +import type { AdapterRequest } from "../../adapters/base"; +import { readBoundedResponseBody } from "../../lib/bounded-body"; + +// DeepSeek's image-token accounting does not include the inline base64 wire cost. +// https://api-docs.deepseek.com/guides/vision/ documents a separate 48 MiB body cap. +const INLINE_THRESHOLD = 40 * 1024 * 1024; +const TARGET_BYTES = 32 * 1024 * 1024; +const MAX_FILE_BYTES = 64 * 1024 * 1024; +const MAX_REPAIR_BYTES = 128 * 1024 * 1024; +const MAX_CACHE_ENTRIES = 512; +const CACHE_MS = 50 * 60 * 1000; +let activeRepairs = 0; +const files = new Map(); + +/** Narrow parsed JSON objects without treating arrays as image or message records. */ +function record(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} + +/** Only the first-party Responses image contract, never a similarly named gateway. */ +export function needsDeepSeekImageFiles(request: AdapterRequest): boolean { + const url = new URL(request.url); + return url.origin === "https://api.deepseek.com" + && (url.pathname === "/responses" || url.pathname === "/v1/responses") + && request.method === "POST" + && Buffer.byteLength(request.body, "utf8") > INLINE_THRESHOLD; +} + +/** + * Replace older inline images with same-account, expiring file references. The client history + * and images stay intact; provider token usage and Codex's compaction policy remain truthful. + * The caller owns the provider's transport, cancellation and credential-selection fence. + */ +export async function deepSeekImageFileBody( + request: AdapterRequest, + execute: typeof globalThis.fetch, + abortSignal?: AbortSignal, + options?: { allowedModels: readonly string[]; budget?: TranslatorBudget }, +): Promise { + if (!options?.allowedModels.length || !needsDeepSeekImageFiles(request)) return undefined; + const requestBytes = Buffer.byteLength(request.body, "utf8"); + if (requestBytes > MAX_REPAIR_BYTES) throw new Error("DeepSeek image replay exceeds the 128 MiB repair limit; compact the request."); + if (activeRepairs >= 2) throw new Error("DeepSeek image replay preparation is busy; retry after an active request completes."); + activeRepairs++; + const releaseCopy = options.budget?.observeAcceptedRequestCopy(requestBytes * 2); + try { + abortSignal?.throwIfAborted(); + const body: unknown = JSON.parse(request.body); + if (!record(body) || !options.allowedModels.includes(String(body.model)) + || !Array.isArray(body.input)) return undefined; + const auth = new Headers(request.headers).get("authorization"); + if (!auth) return undefined; + const scope = new Bun.CryptoHasher("sha256").update(auth).digest("hex"); + const signal = AbortSignal.any([...(abortSignal ? [abortSignal] : []), AbortSignal.timeout(60_000)]); + let bytes = Buffer.byteLength(request.body, "utf8"); + let changed = false; + for (const item of body.input) { + if (!record(item)) continue; + const parts = item.type === "function_call_output" || item.type === "custom_tool_call_output" + ? item.output : item.type === "message" || item.role ? item.content : undefined; + if (!Array.isArray(parts)) continue; + for (const part of parts) { + if (bytes <= TARGET_BYTES) break; + // file_id ignores detail; keep explicit low-resolution requests inline. + if (!record(part) || part.type !== "input_image" || part.file_id !== undefined + || part.detail === "low" || typeof part.image_url !== "string") continue; + const match = /^data:(image\/(?:png|jpeg|webp|gif));base64,/.exec(part.image_url); + if (!match) continue; + signal.throwIfAborted(); + const data = part.image_url.slice(match[0].length); + if (data.length > Math.ceil(MAX_FILE_BYTES / 3) * 4) continue; + const hash = new Bun.CryptoHasher("sha256").update(part.image_url).digest("hex"); + const key = `${scope}:${hash}`; + let entry = files.get(key); + if (entry && entry.until <= Date.now()) { files.delete(key); entry = undefined; } + if (!entry) { + // Conservative peak: UTF-16 base64 slice/re-encoding, decoded buffer, Blob and multipart. + const releaseUpload = options.budget?.observeExternallyCapped("provider_image_upload", data.length * 4 + Math.ceil(data.length * 3 / 4) * 3 + 65_536); + try { + const image = Buffer.from(data, "base64"); + if (image.length > MAX_FILE_BYTES || image.toString("base64") !== data) continue; + const form = new FormData(); + form.set("purpose", "user_data"); + form.set("expires_after[anchor]", "created_at"); + form.set("expires_after[seconds]", "3600"); + form.set("file", new Blob([image], { type: match[1] }), "image"); + // Never follow a redirect with credentials or copy request-specific Content-Length. + const response = await execute("https://api.deepseek.com/files", { + method: "POST", headers: { authorization: auth }, body: form, signal, redirect: "manual", + }); + if (!response.ok) { + await response.body?.cancel(); + throw new Error(`DeepSeek image upload failed (HTTP ${response.status}); conversation images were preserved.`); + } + const result = await readBoundedResponseBody(response, { signal, maxBytes: 65_536 }); + let uploaded: unknown; + try { uploaded = JSON.parse(result.text); } catch { /* Safe, fixed error below. */ } + if (result.truncated || !record(uploaded) || typeof uploaded.id !== "string" + || !/^file-api-[A-Za-z0-9_-]{1,200}$/.test(uploaded.id)) { + throw new Error("DeepSeek image upload returned an invalid file reference; conversation images were preserved."); + } + // Cache only opaque ids and hashes, not image bytes or plaintext credentials. + entry = { id: uploaded.id, until: Date.now() + CACHE_MS }; + if (typeof uploaded.expires_at === "number") entry.until = Math.min(entry.until, uploaded.expires_at * 1000 - 60_000); + while (files.size >= MAX_CACHE_ENTRIES) files.delete(files.keys().next().value!); + files.set(key, entry); + } finally { releaseUpload?.(); } + } + const removedBytes = part.image_url.length + "image_url".length; + delete part.image_url; + part.file_id = entry.id; + bytes += entry.id.length + "file_id".length - removedBytes; + changed = true; + } + } + if (!changed) return undefined; + const releaseSerialization = options.budget?.observeExternallyCapped("passthrough_serialization", requestBytes * 2); + try { return JSON.stringify(body); } finally { releaseSerialization?.(); } + } finally { releaseCopy?.(); activeRepairs--; } +} diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index 42ca8b7723a..ff456db42dd 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -72,6 +72,7 @@ import { } from "../../providers/api-key-selection"; import { resolveAdapter, resolveWireProtocolOverride } from "../adapter-resolve"; import { physicalSendObserverInit, providerFetch, sendWithConnectionPolicy } from "./fetch-helpers"; +import { deepSeekImageFileBody, needsDeepSeekImageFiles } from "./deepseek-image-files"; import type { ProviderFetchOptions } from "./fetch-helpers"; import { captureConfigGeneration } from "../../lib/state-store-sweeper"; import { recordAnthropicAccountQuotaFromHeadersForInstance, hasPassiveAccountQuota } from "../../providers/quota"; @@ -590,10 +591,43 @@ export async function prepareResponsesTransport( }; } const build = resolved.buildRequest.bind(resolved); + const fileProvider = route.provider; + const fileProviderName = route.providerName; resolved.buildRequest = async (requestParsed, incoming) => { const request = await build(requestParsed, incoming); // Capture at adapter creation, never from mutable serving state after an await. if (binding) requestBindings.set(request, binding); + const allowedImageFileModels = fileProvider.modelCapabilities + ? Object.entries(fileProvider.modelCapabilities) + .filter(([, cap]) => cap?.deepseekImageFiles === true) + .map(([model]) => model) + : []; + if (allowedImageFileModels.length && needsDeepSeekImageFiles(request)) { + try { + // Ordinary uploads use provider pacing. A queued rebuild retains its enclosing + // fetch lease; reacquiring that same concurrency slot would deadlock at a limit of one. + const uploadFetch = providerFetch(fileProvider, options.codexWsRuntimeIdentity, { + httpOnly: true, providerName: fileProviderName, modelId: requestParsed.modelId, + beforeDispatch: () => { + if (!selectionIsCurrent(binding)) throw new Error("Credential selection changed before image upload"); + }, + }); + const body = await deepSeekImageFileBody(request, incoming.dispatchPacingHeld ? uploadFetch.unpacedFetch! : uploadFetch, + incoming.abortSignal ?? options.abortSignal ?? req.signal, { + allowedModels: allowedImageFileModels, budget: incoming.translatorBudget, + }); + if (body !== undefined) { + request.releaseBodyObservation?.(); + request.body = body; + const headers = new Headers(request.headers); + headers.delete("content-length"); + request.headers = Object.fromEntries(headers); + request.releaseBodyObservation = incoming.translatorBudget.observeExternallyCapped( + "passthrough_serialization", Buffer.byteLength(body, "utf8"), + ); + } + } catch (error) { request.releaseBodyObservation?.(); throw error; } + } return request; }; if (resolved.runTurn) { @@ -843,6 +877,7 @@ export async function prepareResponsesTransport( adapterName: nextAdapter.name, oauthCredentialSnapshot: replayOAuthCredentialSnapshot }); const rebuilt = await nextAdapter.buildRequest(requestParsed, { headers: requestState.selectedForwardHeaders, providerName: route.providerName, translatorBudget, + dispatchPacingHeld: true, ...(imageTierBias > 0 ? { imageTierBias } : {}), }); const bodySize = checkOutboundBodySize(rebuilt.body, config.maxUpstreamBodyBytes); diff --git a/src/types/provider.ts b/src/types/provider.ts index 46fdb1c85b1..7647ba41470 100644 --- a/src/types/provider.ts +++ b/src/types/provider.ts @@ -288,6 +288,8 @@ export interface ModelCapabilities { /** Requested tier only; does not imply an upstream window or activate an unverified wire. */ contextTier?: "default" | "long_context"; video?: { processing?: "static" | "agentic" }; + /** Opt in exact first-party DeepSeek models to one-hour image file persistence. */ + deepseekImageFiles?: boolean; } export interface OcxProviderConfig { diff --git a/structure/account-auto-pause.md b/structure/account-auto-pause.md index 4d3a29ffb43..d9344617fde 100644 --- a/structure/account-auto-pause.md +++ b/structure/account-auto-pause.md @@ -20,6 +20,10 @@ per account, and an observer without a final-send report never attributes a resp observer had reported. Regression coverage: `tests/responses/responses-account-auto-pause-wire-owner.test.ts`. +DeepSeek's expiring image uploads during adapter construction use the captured credential +selection fence, but are not inference attempts and do not contribute to account refusal +counters. Their transport and lifetime follow the [Responses image replay contract](transports/responses.md#fetch-helper-import-boundary). + ## Permanent account refusal policy `providers..accountAutoPause` is optional, default off, with an integer diff --git a/structure/config.md b/structure/config.md index 158126ee598..10271963372 100644 --- a/structure/config.md +++ b/structure/config.md @@ -561,7 +561,7 @@ The optional `autoReviewOverride` block stores a global approval reviewer select Display-name validation retains prototype-shaped model IDs as data; reviewer-target map validation remains separate and rejects its reserved keys. ## Explicit per-model capability declarations -`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier and video.processing axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. Separately, `modelContextTiers` actively selects `default` or `long_context` for GitHub Copilot models: config and management writes validate exact IDs, PATCH merges entries and null clears the map, CLI edit writes the map, and OAuth login preserves it. +`modelCapabilities` on `src/types/provider.ts` stores exact model-ID entries with optional inputModalities, contextTier, video.processing and deepseekImageFiles axes. `src/config/provider-validation.ts` strictly validates writes and merges PATCH axes without sharing live objects; null map/model/axis/processing tombstones delete, while empty PATCH objects do nothing. Complete POST/PUT replacements reject tombstones. File reads retain valid axes; malformed explicit modalities restrict to text with a diagnostic. The two catalog writers receive explicit config and gather fingerprints include the map. This storage contract alone does not activate a context tier, advertise a larger window or enable video processing. Separately, `modelContextTiers` actively selects `default` or `long_context` for GitHub Copilot models: config and management writes validate exact IDs, PATCH merges entries and null clears the map, CLI edit writes the map, and OAuth login preserves it. The text-only consumer reads exact inputModalities declarations before legacy hints. CLI add/edit `--text-only` targets one model and preserves sibling declarations; `src/vision/eligibility.ts` routes declared text-only models into existing image-description or explicit-omission handling. Positive routed image declarations override stale candidate metadata, while native catalog authority retains its existing legacy policy. `PUT /api/combos` accepts a request-only `visionSidecarTargets` array of exact combo-member `{ provider, model }` pairs (validated against the submitted combo's normalized targets, deduplicated, rejected while `imageInput` is `"disabled"`). An entry whose provider is unconfigured, whose model already accepts image input through any source, or whose exact declaration, custom row, or provider `modelInputModalities` hint lacks `text` is rejected with 400 instead of being overwritten; an entry the sidecar already covers through any runtime source (exact declaration, custom row, `noVisionModels`, legacy record, registry enrichment) is skipped as a no-op. Accepted entries merge `inputModalities: ["text"]` through the same validation/merge helpers, and the combo write, reference migrations, disabled-selector rewrite and capability patches commit in one provider-patch transaction — a failed save restores all of them. The field never persists under `config.combos`, and removing a member from the combo leaves its provider declaration in place until cleared explicitly. diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 2c11779f71e..692ad260cef 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -15,6 +15,32 @@ the translator budget, which is why so many documents link here rather than rest ## Request-copy accounting +### DeepSeek image replay + +First-party DeepSeek Responses bodies above 40 MiB can use +`src/server/responses/deepseek-image-files.ts` during credential-bound adapter construction. +Explicit per-model `modelCapabilities..deepseekImageFiles: true` declarations give owner consent +to separately persisted files; absent or `false` disables uploads. Only HTTPS api.deepseek.com +Responses POSTs for opted-in models qualify. + +Old inline images become byte-identical Files references until the body is at most 32 MiB. +Files request one-hour expiry. The 512-entry cache holds credential/image hashes and opaque ids, +never plaintext credentials or images; expiry is at most 50 minutes and respects earlier server expiry. +Partial successes survive until expiry and can be reused on retry with the same credential. +Low-detail images stay inline; errors preserve client history and do not replay inference. + +A 128 MiB repair-input ceiling and two concurrent preparations bound the additional workspace. +The translator budget observes the parsed string copy, conservative base64/decoded/Blob/multipart +workspace and replacement serialization; all temporary observations release on success, failure +and cancellation. These are externally capped observations, not permission to exceed arbitrary +translator collector limits. The original request and final retained wire body retain their existing owners. + +Uploads use the selected provider transport, pacing and physical credential-generation fence. +A queued dispatch rebuild already holds its enclosing provider concurrency lease: only that nested +upload skips a second pacing acquisition, avoiding a limit-one deadlock without admitting a new turn. +The existing final physical-send fence still guards inference. Caller cancellation and a 60-second +upload deadline cover uploads and bounded response reads. Usage and client compaction stay truthful. + The optional Advisor protocol guard follows the [Advisor retention contract](../advisor.md#optional-subsystem-boundary). Its raw leg, reconstruction and appended replay history use independent local counters, also charged to the request translator budget when supplied by delivery or empty-completion retry. diff --git a/structure/transports/responses.md b/structure/transports/responses.md index c39dd69a53a..35fabd7e5b2 100644 --- a/structure/transports/responses.md +++ b/structure/transports/responses.md @@ -37,7 +37,7 @@ through their existing upstream-error path without relaying Location. This serve redirect following; providers requiring a redirect must be configured with their final API URL. ### Fetch-helper import boundary - +Explicitly opted-in first-party DeepSeek image replay uses `src/server/responses/deepseek-image-files.ts` during credential-bound construction; see the [image byte contract](byte-accounting.md#deepseek-image-replay). Token usage, client compaction and inference dispatch fences are preserved. `src/server/responses/fetch-helpers.ts` is a transport leaf shared by Responses, compact, and native Chat. Its runtime imports are limited to the Codex WebSocket transport, provider request pacing, and the upstream HTTP-version helper. Server, provider, and WebSocket data types remain type-only edges. Opt-in `codexWsReuseAcrossTurns` (default off) in `src/server/responses/codex-ws-pool.ts` keeps a socket for an account and thread across turns, waits at most 750ms for a busy socket before dialing another, and leaves `x-codex-turn-state` / `x-codex-turn-metadata` on each frame. diff --git a/tests/fixtures/test-layout-expected.json b/tests/fixtures/test-layout-expected.json index b7c535715ab..1ec03372b32 100644 --- a/tests/fixtures/test-layout-expected.json +++ b/tests/fixtures/test-layout-expected.json @@ -655,7 +655,7 @@ "debug-settings.test.ts": "server", "debug.test.ts": "lib", "decision-discovery.test.ts": "server", "server-jev-model-decision-e2e.test.ts": "server", "server-jev-model-quota-budget.test.ts": "server", "decision-routes.test.ts": "server", "deepinfra-provider.test.ts": "providers", - "deepseek-inbound-wire.test.ts": "providers", "deepseek-reasoning-replay-gaps.test.ts": "providers", + "deepseek-image-files.test.ts": "providers", "deepseek-inbound-wire.test.ts": "providers", "deepseek-reasoning-replay-gaps.test.ts": "providers", "deepseek-bare-tool-call-replay.test.ts": "providers", "deepseek-reasoning-replay.test.ts": "providers", "deepseek-responses-item-id-repair.test.ts": "providers", "desktop-3p-guard.test.ts": "clients", "desktop-3p-removal.test.ts": "clients", "desktop-3p-write-files.test.ts": "clients", diff --git a/tests/providers/deepseek-image-files.test.ts b/tests/providers/deepseek-image-files.test.ts new file mode 100644 index 00000000000..30fcfa3e3d8 --- /dev/null +++ b/tests/providers/deepseek-image-files.test.ts @@ -0,0 +1,269 @@ +import { describe, expect, test } from "bun:test"; +import { deepSeekImageFileBody, needsDeepSeekImageFiles } from "../../src/server/responses/deepseek-image-files"; +import type { AdapterRequest } from "../../src/adapters/base"; +import { providerRequestPacingStatus, resetProviderRequestPacingForTest, waitForProviderRequestSlot } from "../../src/providers/request-pacing"; +import { createTranslatorBudget } from "../../src/lib/translator-budget"; +import { handleResponses } from "../../src/server/responses/core"; +import { providerConfigSeed } from "../../src/providers/derive"; +import { getProviderRegistryEntry } from "../../src/providers/registry"; +import type { OcxConfig } from "../../src/types"; +import { acquireOwnedSpendHome } from "../helpers/owned-spend-home"; + +const optIn = { allowedModels: ["deepseek-flash"] }; +const image = `data:image/png;base64,${Buffer.alloc(20 * 1024 * 1024, 7).toString("base64")}`; +/** Build an oversized, credential-scoped replay without sending real image data upstream. */ +function request(key: string): AdapterRequest { + return { + url: "https://api.deepseek.com/responses", method: "POST", + headers: { authorization: `Bearer test-image-${key}`, "content-type": "application/json" }, + body: JSON.stringify({ model: "deepseek-flash", input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "keep this text" }, { type: "input_image", image_url: image }] }, + { type: "custom_tool_call_output", call_id: "call1", output: [{ type: "input_image", image_url: image, detail: "original" }] }, + ] }), + }; +} + +describe("DeepSeek image request body limits", () => { + + test("uploads require owner opt-in and can allow any explicitly compatible model", async () => { + let sends = 0; + const upload = (async () => { sends++; return Response.json({ id: "file-api-custom" }); }) as typeof fetch; + const original = request("opt-in"); + expect(await deepSeekImageFileBody(original, upload)).toBeUndefined(); + const custom = { ...original, body: original.body.replace('"deepseek-flash"', '"deepseek-future-vision"') }; + expect(await deepSeekImageFileBody(custom, upload, undefined, optIn)).toBeUndefined(); + expect(sends).toBe(0); + const budget = createTranslatorBudget(); + try { + expect(await deepSeekImageFileBody(custom, upload, undefined, { + allowedModels: ["deepseek-future-vision"], budget, + })).toContain("file-api-custom"); + expect(budget.snapshot().highWaterBytes).toBeGreaterThan(Buffer.byteLength(custom.body) * 2); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { budget.dispose(); } + expect(sends).toBe(1); + }); + + test("failed uploads release copy/multipart observations and allow a safe retry", async () => { + const budget = createTranslatorBudget(); + const original = request("accounting-failure"); + try { + await expect(deepSeekImageFileBody(original, (async () => new Response(null, { status: 503 })) as typeof fetch, + undefined, { ...optIn, budget })).rejects.toThrow("HTTP 503"); + expect(budget.snapshot().currentBytes).toBe(0); + expect(await deepSeekImageFileBody(original, (async () => Response.json({ id: "file-api-retry" })) as typeof fetch, + undefined, { ...optIn, budget })).toContain("file-api-retry"); + expect(budget.snapshot().currentBytes).toBe(0); + } finally { budget.dispose(); } + }); + + test("uploads identical image bytes with expiry, preserves history, and reuses only same-account files", async () => { + const original = request("reuse"); + const source = original.body; + let sends = 0; + const upload = (async (url, init) => { + sends++; + expect(url).toBe("https://api.deepseek.com/files"); + expect(init?.redirect).toBe("manual"); + expect(new Headers(init?.headers).get("content-type")).toBeNull(); + const form = init!.body as FormData; + expect(form.get("purpose")).toBe("user_data"); + expect(form.get("expires_after[seconds]")).toBe("3600"); + expect(Buffer.from(await (form.get("file") as Blob).arrayBuffer())).toEqual(Buffer.alloc(20 * 1024 * 1024, 7)); + return Response.json({ id: `file-api-upload${sends}`, expires_at: Date.now() / 1000 + 3600 }); + }) as typeof fetch; + const built = await deepSeekImageFileBody(original, upload, undefined, optIn); + expect(Buffer.byteLength(built!)).toBeLessThan(32 * 1024 * 1024); + const body = JSON.parse(built!); + expect(body.input[0].content[0].text).toBe("keep this text"); + expect(body.input[0].content[1]).toEqual({ type: "input_image", file_id: "file-api-upload1" }); + expect(body.input[1].output[0].image_url).toBe(image); + expect(original.body).toBe(source); + await deepSeekImageFileBody(original, upload, undefined, optIn); + expect(sends).toBe(1); + await deepSeekImageFileBody(request("other-account"), upload, undefined, optIn); + expect(sends).toBe(2); + }); + + test("small requests, foreign destinations and unlisted models are untouched", async () => { + let sends = 0; + const upload = (async () => { sends++; throw new Error("must not send"); }) as typeof fetch; + const large = request("scope"); + for (const url of ["https://api.openai.com/v1/responses", "http://api.deepseek.com/responses", "https://api.deepseek.com.evil.invalid/responses", "https://gateway.example/responses"]) { + const other = { ...large, url }; + expect(needsDeepSeekImageFiles(other)).toBe(false); + expect(await deepSeekImageFileBody(other, upload, undefined, optIn)).toBeUndefined(); + } + expect(await deepSeekImageFileBody({ ...large, body: '{"model":"deepseek-flash","input":"hello"}' }, upload, undefined, optIn)).toBeUndefined(); + expect(await deepSeekImageFileBody({ ...large, body: large.body.replace('"deepseek-flash"', '"deepseek-v4-flash"') }, upload, undefined, optIn)).toBeUndefined(); + expect(sends).toBe(0); + }); + + test("upload refusal never drops an image or follows a redirect", async () => { + const original = request("refusal"); + const source = original.body; + let sends = 0; + const upload = (async () => { sends++; return new Response("private upstream body", { status: 302, headers: { location: "https://elsewhere.invalid" } }); }) as typeof fetch; + await expect(deepSeekImageFileBody(original, upload, undefined, optIn)).rejects.toThrow("HTTP 302"); + expect(sends).toBe(1); + expect(original.body).toBe(source); + }); + + test("caller cancellation prevents uploads", async () => { + const controller = new AbortController(); controller.abort(); + let sends = 0; + const upload = (async () => { sends++; throw new Error("unexpected upload"); }) as typeof fetch; + await expect(deepSeekImageFileBody(request("abort"), upload, controller.signal, optIn)).rejects.toThrow(); + expect(sends).toBe(0); + }); + + test("malformed file ids are not cached or forwarded", async () => { + const upload = (async () => Response.json({ id: "untrusted\nreference" })) as typeof fetch; + await expect(deepSeekImageFileBody(request("bad-id"), upload, undefined, optIn)).rejects.toThrow("invalid file reference"); + }); + + test("provider expiry bounds cache reuse", async () => { + let calls = 0; + const upload = (async () => Response.json({ id: `file-api-expiry${++calls}`, expires_at: Date.now() / 1000 + 30 })) as typeof fetch; + await deepSeekImageFileBody(request("expiry"), upload, undefined, optIn); + await deepSeekImageFileBody(request("expiry"), upload, undefined, optIn); + expect(calls).toBe(2); + }); + + test("only two bounded preparations can allocate upload workspace at once", async () => { + let entered = 0; + let finish!: () => void; + const waiting = new Promise(resolve => { finish = resolve; }); + const upload = (async () => { entered++; await waiting; return Response.json({ id: "file-api-limited" }); }) as typeof fetch; + const original = request("concurrency"); + const first = deepSeekImageFileBody(original, upload, undefined, optIn); + const second = deepSeekImageFileBody(original, upload, undefined, optIn); + try { + expect(entered).toBe(2); + await expect(deepSeekImageFileBody(original, upload, undefined, optIn)).rejects.toThrow("preparation is busy"); + } finally { finish(); await Promise.all([first, second]); } + expect(await deepSeekImageFileBody(original, upload, undefined, optIn)).toContain("file-api-limited"); + }); + + test("partial upload failure preserves history and retry reuses only successful files", async () => { + const original = request("partial"); + const body = JSON.parse(original.body); + body.input[1].output[0].image_url = image.replace("BwcH", "CAgI"); + body.input.push({ type: "message", role: "user", content: [{ type: "input_image", image_url: image.replace("BwcH", "CQkJ") }] }); + original.body = JSON.stringify(body); + const source = original.body; + let calls = 0; + const upload = (async () => { + calls++; + return calls === 2 ? new Response(null, { status: 503 }) : Response.json({ id: `file-api-partial${calls}` }); + }) as typeof fetch; + await expect(deepSeekImageFileBody(original, upload, undefined, optIn)).rejects.toThrow("HTTP 503"); + expect(original.body).toBe(source); + expect(await deepSeekImageFileBody(original, upload, undefined, optIn)).toContain("file-api-partial1"); + expect(calls).toBe(3); + }); + + test("explicit low detail remains inline", async () => { + const original = request("low"); + const body = JSON.parse(original.body); + body.input[0].content[1].detail = "low"; + body.input[1].output[0].detail = "low"; + original.body = JSON.stringify(body); + expect(await deepSeekImageFileBody(original, (async () => { throw new Error("unexpected upload"); }) as typeof fetch, + undefined, optIn)).toBeUndefined(); + }); + + test("credential changes while uploads queue prevent stale physical sends", async () => { + const restoreFetch = globalThis.fetch; + const releaseSpend = acquireOwnedSpendHome(); + resetProviderRequestPacingForTest(); + const provider = { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "test-queued-old", + modelCapabilities: { "deepseek-flash": { deepseekImageFiles: true } }, requestPacing: { enabled: true, maxConcurrentRequests: 1 } }; + const config = { providers: { deepseek: provider } } as OcxConfig; + const slot = await waitForProviderRequestSlot("deepseek", provider, "deepseek-flash"); + const seen: string[] = []; + globalThis.fetch = (async (url, init) => { + seen.push(`${String(url)}:${new Headers(init?.headers).get("authorization")}`); + expect((providerRequestPacingStatus("deepseek", provider).inFlight ?? 0)).toBeLessThanOrEqual(1); + return String(url).endsWith("/files") ? Response.json({ id: "file-api-queued" }) + : Response.json({ id: "resp_queued", object: "response", status: "completed", output: [] }); + }) as typeof fetch; + const abort = AbortSignal.timeout(4_000); + try { + const pending = handleResponses(new Request("http://localhost/v1/responses", { method: "POST", signal: abort, + headers: { "content-type": "application/json" }, body: request("queued").body }), config, { model: "", provider: "" }); + void pending.catch(() => {}); + const deadline = Date.now() + 2_000; + while (!providerRequestPacingStatus("deepseek", provider).queued) { + if (Date.now() > deadline) throw new Error("upload never queued"); + await Bun.sleep(5); + } + provider.apiKey = "test-queued-new"; + slot?.release(); + await expect(pending).rejects.toThrow("Credential selection changed before image upload"); + expect(seen).toEqual([]); + } finally { slot?.release(); globalThis.fetch = restoreFetch; resetProviderRequestPacingForTest(); releaseSpend(); } + }); + + test("credential change after upload rebuilds safely inside the held inference lease", async () => { + const restoreFetch = globalThis.fetch; + const releaseSpend = acquireOwnedSpendHome(); + resetProviderRequestPacingForTest(); + const provider = { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "test-rebuild-old", + modelCapabilities: { "deepseek-flash": { deepseekImageFiles: true } }, requestPacing: { enabled: true, maxConcurrentRequests: 1 } }; + const config = { providers: { deepseek: provider } } as OcxConfig; + const seen: string[] = []; + globalThis.fetch = (async (url, init) => { + const auth = new Headers(init?.headers).get("authorization"); + seen.push(`${String(url)}:${auth}`); + expect(providerRequestPacingStatus("deepseek", provider).inFlight).toBe(1); + if (String(url).endsWith("/files")) { + provider.apiKey = "test-rebuild-new"; + return Response.json({ id: auth?.endsWith("old") ? "file-api-old" : "file-api-new" }); + } + expect(auth).toBe("Bearer test-rebuild-new"); + expect(String(init?.body)).toContain("file-api-new"); + expect(String(init?.body)).not.toContain("file-api-old"); + return Response.json({ id: "resp_rebuilt", object: "response", status: "completed", output: [] }); + }) as typeof fetch; + try { + const response = await handleResponses(new Request("http://localhost/v1/responses", { method: "POST", + signal: AbortSignal.timeout(4_000), headers: { "content-type": "application/json" }, body: request("rebuild").body }), + config, { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(seen).toHaveLength(3); + } finally { globalThis.fetch = restoreFetch; resetProviderRequestPacingForTest(); releaseSpend(); } + }); + + test("the real Responses route shrinks a >48 MiB replay and preserves reported token usage", async () => { + const restoreFetch = globalThis.fetch; + const releaseSpend = acquireOwnedSpendHome(); + let uploads = 0; let generations = 0; + globalThis.fetch = (async (url, init) => { + if (String(url) === "https://api.deepseek.com/files") { + uploads++; + return Response.json({ id: "file-api-endtoend" }); + } + expect(String(url)).toBe("https://api.deepseek.com/responses"); + expect(Buffer.byteLength(String(init!.body))).toBeLessThan(48 * 1024 * 1024); + const body = JSON.parse(String(init!.body)); + expect(body.input[0].content[1].file_id).toBe("file-api-endtoend"); + generations++; + return Response.json({ id: "resp_files", object: "response", status: "completed", output: [], + usage: { input_tokens: 332507, output_tokens: 10, total_tokens: 332517 } }); + }) as typeof fetch; + try { + const source = request("route").body; + expect(Buffer.byteLength(source)).toBeGreaterThan(48 * 1024 * 1024); + const config = { providers: { deepseek: { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "test-route-key", modelCapabilities: { "deepseek-flash": { deepseekImageFiles: true } } } } } as OcxConfig; + const response = await handleResponses(new Request("http://localhost/v1/responses", { + method: "POST", headers: { "content-type": "application/json" }, body: source, + }), config, { model: "", provider: "" }); + const result = await response.json() as { usage?: { input_tokens: number } }; + expect(response.status).toBe(200); + expect(result.usage?.input_tokens).toBe(332507); + expect(uploads).toBe(1); expect(generations).toBe(1); + } finally { globalThis.fetch = restoreFetch; releaseSpend(); } + }); +}); diff --git a/tests/providers/model-rename-migration.test.ts b/tests/providers/model-rename-migration.test.ts index ced37ac9c6a..bf63bdd037d 100644 --- a/tests/providers/model-rename-migration.test.ts +++ b/tests/providers/model-rename-migration.test.ts @@ -233,7 +233,7 @@ describe("registry model rename migration (#1610)", () => { const stale = { defaultProvider: "kimi-code", providers: { "kimi-code": { adapter: "openai-chat", baseUrl: "https://api.kimi.com/coding/v1", apiKey: "fixture-key", models: [from], modelAliases: { [from]: "work" }, - modelAdapters: { [from]: "openai-responses" }, modelCapabilities: { [from]: { inputModalities: ["text"] } }, + modelAdapters: { [from]: "openai-responses" }, modelCapabilities: { [from]: { inputModalities: ["text"], deepseekImageFiles: true } }, modelMaxInputTokens: { [from]: 12345 }, modelPinnedReasoningEfforts: { [from]: "high" }, autoReviewModelOverrides: { [from]: "reviewer/model" }, requestPacing: { enabled: true, models: { [from]: { minIntervalMs: 125 } } }, @@ -246,6 +246,7 @@ describe("registry model rename migration (#1610)", () => { expect(route.modelId).toBe(to); expect(route.staticPolicy.model.adapter).toBe("openai-responses"); expect(route.staticPolicy.model.inputModalities).toEqual(["text"]); + expect(prov.modelCapabilities?.[to]?.deepseekImageFiles).toBe(true); expect(route.staticPolicy.model.maxInputTokens).toBe(12345); expect(prov.modelPinnedReasoningEfforts).toEqual({ [to]: "high" }); expect(prov.autoReviewModelOverrides).toEqual({ [to]: "reviewer/model" }); diff --git a/tests/providers/provider-config-validation.test.ts b/tests/providers/provider-config-validation.test.ts index dd0479d8ae7..dc437ab535d 100644 --- a/tests/providers/provider-config-validation.test.ts +++ b/tests/providers/provider-config-validation.test.ts @@ -1,9 +1,12 @@ import { describe, expect, test } from "bun:test"; +import { providerManagementConfigError } from "../../src/server/auth-cors"; import { providerConfigSchema } from "../../src/config/schema/leaf-validators"; import { apiKeyTransportConfigError, booleanRecordConfigError, + mergeModelCapabilities, modelAdapterRecordConfigError, + modelCapabilitiesConfigError, modelDisplayNamesConfigError, nonBlankStringArrayConfigError, normalizeNonBlankStringArray, @@ -13,10 +16,50 @@ import { providerHeadersConfigError, projectContextConfigError, reasoningSummaryDeliveryRecordConfigError, + sanitizeModelCapabilitiesForLoad, upstreamHttpVersionConfigError, } from "../../src/config/provider-validation"; describe("provider config validation leaf", () => { + test("DeepSeek upload consent is a bounded exact-model allowlist at load and write boundaries", () => { + const base = { adapter: "openai-responses", baseUrl: "https://api.deepseek.com", authMode: "key" }; + for (const value of [ + undefined, + { "deepseek-flash": { deepseekImageFiles: true } }, + { "deepseek-future-vision": { deepseekImageFiles: false } }, + ]) { + const candidate = { ...base, modelCapabilities: value }; + expect(providerConfigSchema.safeParse(candidate).success).toBe(true); + expect(providerManagementConfigError("deepseek", candidate)).toBeNull(); + } + for (const value of [ + { "deepseek-flash": { deepseekImageFiles: "yes" } }, + { "deepseek-flash": { deepseekImageFiles: null } }, + { " deepseek-flash ": { deepseekImageFiles: true } }, + ]) { + const candidate = { ...base, modelCapabilities: value }; + expect(providerConfigSchema.safeParse(candidate).success).toBe(false); + expect(providerManagementConfigError("deepseek", candidate)).toContain("modelCapabilities"); + } + expect(modelCapabilitiesConfigError({ "deepseek-flash": { deepseekImageFiles: null } }, true)).toBeNull(); + const merged = mergeModelCapabilities( + { "deepseek-flash": { inputModalities: ["text", "image"], deepseekImageFiles: true } }, + { "deepseek-flash": { deepseekImageFiles: null } }, + ); + expect(merged).toEqual({ "deepseek-flash": { inputModalities: ["text", "image"] } }); + expect(mergeModelCapabilities( + { "deepseek-flash": { deepseekImageFiles: true } }, + { "deepseek-flash": { deepseekImageFiles: null } }, + )).toBeUndefined(); + expect(sanitizeModelCapabilitiesForLoad({ + "deepseek-flash": { deepseekImageFiles: true }, + "broken": { deepseekImageFiles: "yes", contextTier: "long_context" }, + })).toEqual({ + "deepseek-flash": { deepseekImageFiles: true }, + "broken": { contextTier: "long_context" }, + }); + }); + test("accepts only credential-free HTTP(S) base URLs", () => { expect(providerBaseUrlConfigError("https://example.test/v1")).toBeNull(); expect(providerBaseUrlConfigError("file:///tmp/provider")).toBe("baseUrl must be an http(s) URL"); From 6b509102848c0fe88eb771817cfde5a05fb15a21 Mon Sep 17 00:00:00 2001 From: fontanierh Date: Mon, 19 Oct 2026 12:55:16 +0200 Subject: [PATCH 2/2] fix: scope image preparation and retain rebuild upload pacing --- src/adapters/base.ts | 2 + src/adapters/openai-responses/passthrough.ts | 4 +- src/server/responses/request-transport.ts | 17 +++++-- structure/transports/byte-accounting.md | 7 ++- .../openai/openai-chat-model-suffix.test.ts | 1 + tests/providers/deepseek-image-files.test.ts | 49 ++++++++++++++++++- 6 files changed, 72 insertions(+), 8 deletions(-) diff --git a/src/adapters/base.ts b/src/adapters/base.ts index f0b11af06c3..3748c881a0b 100644 --- a/src/adapters/base.ts +++ b/src/adapters/base.ts @@ -129,6 +129,8 @@ export interface AdapterRequest { method: string; headers: Record; body: string; + /** Exact final wire model, captured before serialization for model-scoped transport gates. */ + outboundModelId?: string; /** Final upstream wire names of custom tools lowered to functions while building this request. */ convertedRoutedCustomToolNames?: ReadonlySet; /** Native custom-tool wire names authorized for representation-only response repair. */ diff --git a/src/adapters/openai-responses/passthrough.ts b/src/adapters/openai-responses/passthrough.ts index 135e8f66ee8..1fb6f4440c0 100644 --- a/src/adapters/openai-responses/passthrough.ts +++ b/src/adapters/openai-responses/passthrough.ts @@ -512,7 +512,8 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): if (!isCanonicalOpenAiForwardProvider(provider)) { validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools); } - const body = JSON.stringify(applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName)); + const wireBody = applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName); + const body = JSON.stringify(wireBody); const releaseBodyObservation = translatorBudget.observeExternallyCapped( "passthrough_serialization", Buffer.byteLength(body, "utf8"), @@ -522,6 +523,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig): method: "POST", headers, body, + ...(isPlainObject(wireBody) && typeof wireBody.model === "string" ? { outboundModelId: wireBody.model } : {}), releaseBodyObservation, ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}), ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}), diff --git a/src/server/responses/request-transport.ts b/src/server/responses/request-transport.ts index ff456db42dd..77529220e7c 100644 --- a/src/server/responses/request-transport.ts +++ b/src/server/responses/request-transport.ts @@ -602,7 +602,7 @@ export async function prepareResponsesTransport( .filter(([, cap]) => cap?.deepseekImageFiles === true) .map(([model]) => model) : []; - if (allowedImageFileModels.length && needsDeepSeekImageFiles(request)) { + if (allowedImageFileModels.includes(request.outboundModelId ?? requestParsed.modelId) && needsDeepSeekImageFiles(request)) { try { // Ordinary uploads use provider pacing. A queued rebuild retains its enclosing // fetch lease; reacquiring that same concurrency slot would deadlock at a limit of one. @@ -612,8 +612,19 @@ export async function prepareResponsesTransport( if (!selectionIsCurrent(binding)) throw new Error("Credential selection changed before image upload"); }, }); - const body = await deepSeekImageFileBody(request, incoming.dispatchPacingHeld ? uploadFetch.unpacedFetch! : uploadFetch, - incoming.abortSignal ?? options.abortSignal ?? req.signal, { + const uploadSignal = incoming.abortSignal ?? options.abortSignal ?? req.signal; + const pacedUpload: typeof fetch = incoming.dispatchPacingHeld + ? (async (input, init) => { + // The inference lease owns concurrency, but each physical upload still + // consumes an interval slot, including after a credential rebuild. + const slot = await waitForProviderRequestSlot(fileProviderName, fileProvider, requestParsed.modelId, + init?.signal ?? uploadSignal, { concurrency: false }); + try { return await uploadFetch.unpacedFetch!(input, init); } + finally { releaseProviderRequestSlot(slot); } + }) as typeof fetch + : uploadFetch; + const body = await deepSeekImageFileBody(request, pacedUpload, + uploadSignal, { allowedModels: allowedImageFileModels, budget: incoming.translatorBudget, }); if (body !== undefined) { diff --git a/structure/transports/byte-accounting.md b/structure/transports/byte-accounting.md index 692ad260cef..a0fcb3945f3 100644 --- a/structure/transports/byte-accounting.md +++ b/structure/transports/byte-accounting.md @@ -21,7 +21,9 @@ First-party DeepSeek Responses bodies above 40 MiB can use `src/server/responses/deepseek-image-files.ts` during credential-bound adapter construction. Explicit per-model `modelCapabilities..deepseekImageFiles: true` declarations give owner consent to separately persisted files; absent or `false` disables uploads. Only HTTPS api.deepseek.com -Responses POSTs for opted-in models qualify. +Responses POSTs for opted-in models qualify. The adapter captures the exact final wire model +before serialization, so unlisted models never enter the bounded preparation pool; the helper +also checks the parsed body model before uploading. Old inline images become byte-identical Files references until the body is at most 32 MiB. Files request one-hour expiry. The 512-entry cache holds credential/image hashes and opaque ids, @@ -37,7 +39,8 @@ translator collector limits. The original request and final retained wire body r Uploads use the selected provider transport, pacing and physical credential-generation fence. A queued dispatch rebuild already holds its enclosing provider concurrency lease: only that nested -upload skips a second pacing acquisition, avoiding a limit-one deadlock without admitting a new turn. +upload skips a second concurrency acquisition but still acquires an interval-only pacing slot, +avoiding a limit-one deadlock without bypassing upload rate limits or admitting a new turn. The existing final physical-send fence still guards inference. Caller cancellation and a 60-second upload deadline cover uploads and bounded response reads. Usage and client compaction stay truthful. diff --git a/tests/adapters/openai/openai-chat-model-suffix.test.ts b/tests/adapters/openai/openai-chat-model-suffix.test.ts index 96ba37e1fc5..f9d4ba93279 100644 --- a/tests/adapters/openai/openai-chat-model-suffix.test.ts +++ b/tests/adapters/openai/openai-chat-model-suffix.test.ts @@ -134,6 +134,7 @@ describe("openai-chat adapter wire model normalization", () => { const adapter = withTestTranslatorBudget(createResponsesPassthroughAdapter(provider)); const req = await adapter.buildRequest(parseRequest({ model: "glm-5.2[1m]", input: "hi" })); expect((JSON.parse(req.body as string) as { model?: unknown }).model).toBe("glm-5.2"); + expect(req.outboundModelId).toBe("glm-5.2"); expect(req.url).toBe("https://api.z.ai/api/v1/responses"); }); }); diff --git a/tests/providers/deepseek-image-files.test.ts b/tests/providers/deepseek-image-files.test.ts index 30fcfa3e3d8..1f008006ba4 100644 --- a/tests/providers/deepseek-image-files.test.ts +++ b/tests/providers/deepseek-image-files.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; import { deepSeekImageFileBody, needsDeepSeekImageFiles } from "../../src/server/responses/deepseek-image-files"; import type { AdapterRequest } from "../../src/adapters/base"; -import { providerRequestPacingStatus, resetProviderRequestPacingForTest, waitForProviderRequestSlot } from "../../src/providers/request-pacing"; +import { providerRequestPacingStatus, resetProviderRequestPacingForTest, setProviderRequestPacingRuntimeForTest, waitForProviderRequestSlot } from "../../src/providers/request-pacing"; import { createTranslatorBudget } from "../../src/lib/translator-budget"; import { handleResponses } from "../../src/server/responses/core"; import { providerConfigSeed } from "../../src/providers/derive"; @@ -205,12 +205,54 @@ describe("DeepSeek image request body limits", () => { } finally { slot?.release(); globalThis.fetch = restoreFetch; resetProviderRequestPacingForTest(); releaseSpend(); } }); + test("unlisted models bypass occupied image preparation slots", async () => { + const restoreFetch = globalThis.fetch; + const releaseSpend = acquireOwnedSpendHome(); + let finish!: () => void; + const waiting = new Promise(resolve => { finish = resolve; }); + let entered = 0; + const upload = (async () => { entered++; await waiting; return Response.json({ id: "file-api-blocked-model-gate" }); }) as typeof fetch; + const first = deepSeekImageFileBody(request("model-gate-first"), upload, undefined, optIn); + const second = deepSeekImageFileBody(request("model-gate-second"), upload, undefined, optIn); + let sends = 0; + globalThis.fetch = (async (url, init) => { + expect(String(url)).toBe("https://api.deepseek.com/responses"); + expect(JSON.parse(String(init?.body)).model).toBe("deepseek-flash"); + sends++; + return Response.json({ id: "resp_unlisted", object: "response", status: "completed", output: [] }); + }) as typeof fetch; + try { + expect(entered).toBe(2); + const provider = { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "test-unlisted-model", + modelCapabilities: { "deepseek-pro": { deepseekImageFiles: true } } }; + const body = JSON.stringify({ model: "deepseek-flash", input: [{ type: "message", role: "user", content: [ + { type: "input_image", image_url: `data:image/png;base64,${Buffer.alloc(31 * 1024 * 1024, 9).toString("base64")}` }, + ] }] }); + expect(Buffer.byteLength(body)).toBeGreaterThan(40 * 1024 * 1024); + expect(Buffer.byteLength(body)).toBeLessThan(48 * 1024 * 1024); + const response = await handleResponses(new Request("http://localhost/v1/responses", { method: "POST", + headers: { "content-type": "application/json" }, body }), { providers: { deepseek: provider } } as OcxConfig, + { model: "", provider: "" }); + await response.text(); + expect(response.status).toBe(200); + expect(sends).toBe(1); + } finally { finish(); await Promise.all([first, second]); globalThis.fetch = restoreFetch; releaseSpend(); } + }); + test("credential change after upload rebuilds safely inside the held inference lease", async () => { const restoreFetch = globalThis.fetch; const releaseSpend = acquireOwnedSpendHome(); resetProviderRequestPacingForTest(); + let pacingNow = 0; + setProviderRequestPacingRuntimeForTest({ + now: () => pacingNow, + setTimer: (callback, delayMs) => setTimeout(() => { pacingNow += delayMs; callback(); }, 0), + clearTimer: handle => clearTimeout(handle as ReturnType), + enqueueMicrotask: callback => queueMicrotask(callback), + }); + const uploadTimes: number[] = []; const provider = { ...providerConfigSeed(getProviderRegistryEntry("deepseek")!), apiKey: "test-rebuild-old", - modelCapabilities: { "deepseek-flash": { deepseekImageFiles: true } }, requestPacing: { enabled: true, maxConcurrentRequests: 1 } }; + modelCapabilities: { "deepseek-flash": { deepseekImageFiles: true } }, requestPacing: { enabled: true, maxConcurrentRequests: 1, minIntervalMs: 1000 } }; const config = { providers: { deepseek: provider } } as OcxConfig; const seen: string[] = []; globalThis.fetch = (async (url, init) => { @@ -218,6 +260,7 @@ describe("DeepSeek image request body limits", () => { seen.push(`${String(url)}:${auth}`); expect(providerRequestPacingStatus("deepseek", provider).inFlight).toBe(1); if (String(url).endsWith("/files")) { + uploadTimes.push(pacingNow); provider.apiKey = "test-rebuild-new"; return Response.json({ id: auth?.endsWith("old") ? "file-api-old" : "file-api-new" }); } @@ -233,6 +276,8 @@ describe("DeepSeek image request body limits", () => { await response.text(); expect(response.status).toBe(200); expect(seen).toHaveLength(3); + // Initial upload, inference lease, then a distinct interval for the rebuilt upload. + expect(uploadTimes).toEqual([0, 2000]); } finally { globalThis.fetch = restoreFetch; resetProviderRequestPacingForTest(); releaseSpend(); } });