Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down
1 change: 1 addition & 0 deletions scripts/test-layout/layout.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions src/adapters/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -127,6 +129,8 @@ export interface AdapterRequest {
method: string;
headers: Record<string, string>;
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<string>;
/** Native custom-tool wire names authorized for representation-only response repair. */
Expand Down
4 changes: 3 additions & 1 deletion src/adapters/openai-responses/passthrough.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand All @@ -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 } : {}),
Expand Down
9 changes: 7 additions & 2 deletions src/config/provider-validation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -528,7 +533,7 @@ export function sanitizeModelCapabilitiesForLoad(value: unknown): Record<string,
for (const [id, raw] of Object.entries(value).slice(0, MODEL_DISCOVERY_MAX_MODELS)) {
if (!capabilityRecord(raw) || !isValidModelDiscoveryModelId(id) || ["__proto__", "prototype", "constructor"].includes(id)) continue;
const row: Record<string, unknown> = {};
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;
Expand Down
2 changes: 1 addition & 1 deletion src/lib/translator-budget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down
121 changes: 121 additions & 0 deletions src/server/responses/deepseek-image-files.ts
Original file line number Diff line number Diff line change
@@ -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<string, { id: string; until: number }>();

/** Narrow parsed JSON objects without treating arrays as image or message records. */
function record(value: unknown): value is Record<string, unknown> {
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<string | undefined> {
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--; }
}
Loading
Loading