Skip to content
Merged
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
24 changes: 24 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,30 @@ The non-negotiable details:
`input_image` parts in messages and in tool outputs directly to DeepSeek. The entry keeps
`input_modalities = ["text", "image"]` so the desktop app may issue `view_image` calls. GPT
image descriptions and `DSCODEX_VISION_MODEL` are not used by the router.

One carve-out to "forward natively", in `src/image-compaction.mjs`: Codex resends the whole
transcript every turn, images travel inside it as base64 that never shrinks, and the gateway
refuses bodies at ~47 MB, so a session that viewed enough pictures stops being sendable at all.
The answer is **shrink, don't delete**. Every image older than the
newest `keep_recent_images` is re-encoded to **lossy WebP** at `image_max_side` (default 1024)
before anything is sacrificed, so the model still sees every picture. Measured on a real
44-image session: 47.92 MB of image payload became 10.1 MB, the request went 50.67 → 12.88 MB,
nothing was deleted, and DeepSeek still read the dice faces and their numbers out of
2182 KB → 12 KB images. Deleting is only the last resort, and a deleted picture must leave an
`input_text` record naming its position, media type, and size — never a silent hole. The WebP
encoder is **found, never required**: this package ships exactly one runtime dependency (`ws`)
and that stays true. The router resolves `sharp` the same way it resolves `ws` — lazily, via
`createRequire` — from `webp_encoder_dir` / `DSCODEX_WEBP_ENCODER_DIR`, or from the documented
drop-in directory `~/.codex/dscodex/encoders`, and reports which one answered in the startup
banner. When nothing resolves it logs "WebP encoder unavailable" and falls back to the record
path rather than failing, so a fresh clone is slower, never broken. Never add the encoder to
`dependencies` or `optionalDependencies`: the promise is one runtime dependency, and
`npm install` would then fetch it. Transcoded bytes are cached by content hash under
`~/.codex/dscodex/image-cache/` because the same picture is re-sent every turn (cold 2.3 s vs
warm 0.24 s on that session). All five knobs — `max_upstream_bytes`, `keep_recent_images`,
`image_max_side`, `webp_quality`, `webp_encoder_dir` — resolve from the process environment and
then from `~/.codex/dscodex/config.json`, because the autostarted router never inherits a shell
variable; `max_upstream_bytes: 0` disables the compaction entirely.
8. The hosted DeepSeek Responses API accepts only the string levels
`none|minimal|low|medium|high|xhigh|max`; integer Juice values and `ultra` are rejected with
HTTP 400. The catalog exposes exactly two stops, High (`high`) and Max (`max`, the default), and
Expand Down
1 change: 1 addition & 0 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ Yes. Tool calls and web search go through DeepSeek's Responses API. Flash handle
- **Usage stats.** The Codex Profile page is read-only, so DeepSeek usage cannot be added to it.
- **Reasoning folds mid-task.** DeepSeek emits `response.completed` after every tool round; Codex folds the reasoning block, runs the tool, and opens the next round. This is API behavior, not a bug. Tool-free turns fold once at the end.
- **Native vision.** Images and tool-returned images go straight to `deepseek-flash`; GPT image descriptions and `DSCODEX_VISION_MODEL` are no longer used.
- **Request size.** Codex resends the whole transcript every turn and images sit in it as base64 that never shrinks, so a long session eventually reaches DeepSeek's ~47 MB request ceiling and every turn answers with an HTML `413 Payload Too Large` that no retry clears. When an outbound body passes 44 MB the router **shrinks the old pictures instead of deleting them**: everything older than the newest 4 is re-encoded to **lossy WebP** (long side 1024, quality 75). Measured on a real 44-image session: image payload 47.92 MB → 10.1 MB, whole request 50.67 → 12.88 MB, nothing deleted, and the model still read the dice faces and their numbers. Deleting only happens when shrinking cannot fit the body, and it always leaves a record naming the picture's position, media type, and size. Transcoded bytes are cached by content hash under `~/.codex/dscodex/image-cache/`, so each picture is encoded once. The encoder is **found, not required**: this package still declares exactly one runtime dependency (`ws`) and `npm install` fetches nothing extra. To get the shrink path, drop an encoder into `~/.codex/dscodex/encoders` (`npm install --prefix ~/.codex/dscodex/encoders sharp`) or point `webp_encoder_dir` somewhere else; without one the router falls back to records, so a fresh clone is slower rather than broken. Five knobs — `max_upstream_bytes`, `keep_recent_images`, `image_max_side`, `webp_quality`, `webp_encoder_dir` — live in `~/.codex/dscodex/config.json` and are applied on router restart; `max_upstream_bytes: 0` disables the compaction and lets oversized requests fail with the gateway's 413. An autostarted router never sees a variable set in some shell, so the config file is the setting that works.
- **DeepSeek → GPT thread history.** Before forwarding to GPT the router strips foreign reasoning (any `reasoning_text` content, or an `encrypted_content` that is not ChatGPT ciphertext; DeepSeek now fills it with a UUID placeholder, see #23) and restores its own encrypted compaction summary as assistant context. Native GPT reasoning and ordinary requests keep their original bytes; rollout files are never rewritten. The same rewrite runs on HTTP SSE and on every Responses WebSocket `response.create`.
- **Sub-agents.** DeepSeek-bound `agent_message` items are replayed as `user` messages and content blocks DeepSeek cannot deserialize (`encrypted_content`, …) are rewritten to `input_text`, so spawning a sub-agent no longer fails with 422 / 400 (#24).
- **Official GPT WebSocket.** Desktop 26.908+ dials the loopback WS first. The router has to be running for that upgrade to reach chatgpt.com; if it is down, official models Reconnecting 5/5. A DeepSeek-hinted handshake (always present with ChatGPT auth) is rejected with HTTP 426 so the client switches to HTTP Responses with no reconnect retries; without the hint a DeepSeek model on the socket is closed with 1008 to force the same fallback.
Expand Down
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,7 @@ ChatGPT 桌面端 26.908+ 会先连 `ws://127.0.0.1:10110/<token>/v1/responses`
- **用量统计。** Codex 的 Profile 页面只读,DeepSeek 用量无法计入。
- **思考块反复折叠。** DeepSeek 每轮工具调用结束都发 `response.completed`,Codex 随之折叠思考、执行工具、再展开下一轮。这是 API 行为,不是 bug;无工具的单轮只折叠一次。
- **原生识图。** 图片和工具返回的图片直接交给 `deepseek-flash`,不再借 GPT 代读;`DSCODEX_VISION_MODEL` 不再生效。
- **单次请求体积。** Codex 每轮重发整段历史,图片以 base64 常驻且不会变小,长会话迟早撞上 DeepSeek 网关约 47 MB 的请求上限,表现是那段会话每轮都回 HTML `413 Payload Too Large`,重试无用。路由器在出站请求超过 44 MB 时**把老图改小而不是删掉**:除最新 4 张外,全部转成 **有损 WebP**(长边 1024、质量 75)。实测一段 44 张图的真实会话:图片从 47.92 MB 降到 10.1 MB,整个请求 50.67 → 12.88 MB,**一张都没丢**,模型照样读得出骰子面数和上面的数字。只有连缩图都压不下去时才会真的丢图,且必留一条写明「第几张、什么类型、多大」的记录。转码结果按内容哈希缓存在 `~/.codex/dscodex/image-cache/`,所以同一张图只在第一次编码。**编码器是「找到就用」,不是依赖**:DSCodex 对外仍然只承诺 `ws` 一个运行依赖,`npm install` 不会因此多装东西。想要缩图能力,把编码器放到 `~/.codex/dscodex/encoders` 即可(`npm install --prefix ~/.codex/dscodex/encoders sharp`,或用 `webp_encoder_dir` 指到别处);没放就自动退回记录方式——全新克隆只会慢一点,不会坏。五个旋钮 `max_upstream_bytes` / `keep_recent_images` / `image_max_side` / `webp_quality` / `webp_encoder_dir` 写进 `~/.codex/dscodex/config.json` 即生效,重启路由器后读取;`max_upstream_bytes: 0` 完全关闭压缩。注意自启动的路由器**读不到**你在某个 shell 里临时设的变量,改写配置文件才行。
- **DeepSeek → GPT 任务历史。** 转发 GPT 前剥掉外来 reasoning(带 `reasoning_text` 内容,或 `encrypted_content` 非 ChatGPT 密文;DeepSeek 现在会填一个 UUID 占位串,见 #23),把 DSCodex 自己的加密压缩摘要恢复为助手上下文;GPT 原生 reasoning 与普通请求保持原始字节,rollout 文件不改写。HTTP SSE 与每条 Responses WebSocket `response.create` 都做这件事。
- **子 agent。** 发给 DeepSeek 的 `agent_message` 以 `user` 角色重放,`encrypted_content` 等 DeepSeek 不认识的内容块改写成 `input_text`,spawn 子 agent 不再 422 / 400(#24)。
- **官方 GPT WebSocket。** 桌面端 26.908+ 先连 loopback WS。路由器必须在跑,upgrade 才会透传到 chatgpt.com;停掉就 Reconnecting 5/5。DeepSeek 的 WS 握手带模型提示(ChatGPT 登录始终会带)时会被直接拒绝(HTTP 426),客户端零重试切到 HTTP Responses;提示缺失时仍在首帧按 close 1008 回退。
Expand Down
1 change: 1 addition & 0 deletions llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ Full constraints: https://github.com/fish2lab/DSCodex/blob/main/AGENTS.md

- Native DeepSeek tool loops: shell, apply_patch, function calls, web search
- Native vision: `input_image` parts are forwarded to `deepseek-flash`; no GPT image descriptions
- Oversized-transcript guard: DeepSeek's gateway refuses request bodies at ~47 MB and Codex resends the whole transcript (images included, as base64) every turn, so a picture-heavy session would otherwise become permanently unsendable. Above a 44 MB budget the router shrinks rather than deletes: every image older than the newest 4 is re-encoded to lossy WebP (long side 1024, quality 75), which took a real 44-image session from a 50.67 MB request to 12.88 MB with no picture lost and no loss of legibility. Deleting is the last resort and always leaves a self-describing record. Transcodes are cached by content hash. The encoder is found rather than required - the package still declares one runtime dependency (`ws`) and installs nothing extra; drop one into `~/.codex/dscodex/encoders`, and without it the guard falls back to records. Tunable through `~/.codex/dscodex/config.json` (`max_upstream_bytes`, `keep_recent_images`, `image_max_side`, `webp_quality`, `webp_encoder_dir`; `0` disables), which is what an autostarted router can actually read.
- Remote compaction v2 for DeepSeek: the summary is encrypted into a DSCodex compaction item, never stored as plaintext in the rollout
- GPT passthrough stays byte-for-byte unless foreign DeepSeek `reasoning_text` or an undecryptable DSCodex compaction item must be stripped (HTTP SSE and every Responses WebSocket `response.create`, not only the first frame)
- Optional macOS app-server bridge for per-provider effort memory in the picker; off by default because it moves the app onto stdio and breaks Computer Use
Expand Down
24 changes: 24 additions & 0 deletions src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,8 @@ import {
writeProxyUrl,
writeStoredKey,
} from "./keys.mjs";
import { compactionBudgetSource, compactionTunables } from "./compaction-limits.mjs";
import { ImageTranscodeCache, loadWebpEncoder } from "./image-compaction.mjs";
import {
envProxySupported,
proxyEnvFor,
Expand Down Expand Up @@ -489,6 +491,13 @@ async function serve(port) {
};
const shutdownToken = randomBytes(32).toString("base64url");
const instanceId = `${process.pid}-${Date.now()}-${randomBytes(8).toString("hex")}`;
const { webpEncoderDir, ...compaction } = compactionTunables(paths);
// WebP transcodes are cached by content hash: the same picture is re-sent
// every turn, and encoding it again each time would be the whole cost of this
// feature. A missing encoder is normal, not fatal: DSCodex ships one runtime
// dependency (`ws`), and the encoder is something the environment provides.
const imageCache = new ImageTranscodeCache(join(paths.stateDir, "image-cache"));
const { encode: imageEncoder, source: encoderSource } = await loadWebpEncoder({ directory: webpEncoderDir });
let server;
let shuttingDown = false;
const shutdown = () => {
Expand All @@ -511,6 +520,9 @@ async function serve(port) {
shutdownToken,
instanceId,
onShutdown: shutdown,
...compaction,
imageCache,
imageEncoder,
});
// The serve process owns the pid file so `stop` works no matter who launched
// it — `start`, launchd, systemd, or the Windows Task Scheduler.
Expand All @@ -525,6 +537,18 @@ async function serve(port) {
writePidState(paths, { pid: process.pid, port, routerToken, shutdownToken, instanceId });
console.log(`${ts()} DSCodex ${VERSION} listening at http://${HOST}:${port}/v1`);
console.log(`${ts()} DeepSeek key: ${deepSeekKey ? "configured" : "missing (GPT OAuth passthrough still works)"}`);
const budget = compaction.maxUpstreamBytes;
console.log(`${ts()} Image-history compaction: ${Number.isFinite(budget)
? `bodies over ${(budget / 1048576).toFixed(0)}MB shrink the oldest images to WebP q${compaction.webpQuality}`
+ ` at ${compaction.imageMaxSide}px, keeping the ${compaction.keepRecentImages} newest untouched`
: "disabled (max_upstream_bytes=0); oversized bodies will fail with 413"}`
+ ` [budget from ${compactionBudgetSource(paths)}]`);
console.log(`${ts()} WebP encoder: ${encoderSource}${imageEncoder ? "" : " — oversized bodies fall back to text records"}`);
console.log(`${ts()} Compaction knobs: DSCODEX_MAX_UPSTREAM_BYTES / max_upstream_bytes (0 disables),`
+ " DSCODEX_KEEP_RECENT_IMAGES / keep_recent_images, DSCODEX_IMAGE_MAX_SIDE / image_max_side,"
+ " DSCODEX_WEBP_QUALITY / webp_quality, DSCODEX_WEBP_ENCODER_DIR / webp_encoder_dir — the stored ones live in"
+ " ~/.codex/dscodex/config.json and are re-read on every router start, so restart-dscodex.ps1 applies them;"
+ " a shell variable only reaches a manually started router.");
});
}

Expand Down
119 changes: 119 additions & 0 deletions src/compaction-limits.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,119 @@
// Tunables for the image-history compaction in the proxy.
//
// Resolution order matches the DeepSeek key: a one-off process environment
// variable wins, then the durable value in ~/.codex/dscodex/config.json, then
// the built-in default.
//
// The stored file exists because an environment variable on its own is not
// reachable here: the router is started by the Windows Task Scheduler through a
// wscript shim, so a variable typed into a shell never reaches it, and the
// supervisor that owns the router outlives a router-only hot reload. The config
// file is re-read by the router process itself, which `restart-dscodex.ps1`
// replaces — so the setting lands without touching the scheduler.

import { readRouterConfig } from "./keys.mjs";
import {
DEFAULT_IMAGE_MAX_SIDE,
DEFAULT_KEEP_RECENT_IMAGES,
DEFAULT_WEBP_EFFORT,
DEFAULT_WEBP_QUALITY,
} from "./image-compaction.mjs";
import { join } from "node:path";

// The defaults live with the code that uses them, but this module is where the
// effective values are resolved, so it re-exports them for callers and tests.
export {
DEFAULT_IMAGE_MAX_SIDE,
DEFAULT_KEEP_RECENT_IMAGES,
DEFAULT_WEBP_EFFORT,
DEFAULT_WEBP_QUALITY,
} from "./image-compaction.mjs";

// DeepSeek's gateway rejects bodies at ~47 MB (measured 2026-10-01: 46 MB is
// served, 47 MB answers 413). 44 MB leaves headroom under that ceiling.
export const DEFAULT_UPSTREAM_BYTE_BUDGET = 44 * 1024 * 1024;

export const MAX_UPSTREAM_FIELD = "max_upstream_bytes";
export const KEEP_RECENT_FIELD = "keep_recent_images";
export const IMAGE_MAX_SIDE_FIELD = "image_max_side";
export const WEBP_QUALITY_FIELD = "webp_quality";
export const WEBP_ENCODER_DIR_FIELD = "webp_encoder_dir";

// Convention, not configuration: an encoder dropped here is picked up with no
// settings to edit. It sits in the user's DSCodex state directory, so the
// checkout still ships nothing but `ws`.
export const DEFAULT_ENCODER_DIRNAME = "encoders";

function integerFrom(value) {
if (value === undefined || value === null) return null;
if (typeof value === "string" && value.trim() === "") return null;
const parsed = Number(value);
return Number.isInteger(parsed) ? parsed : null;
}

function tuned({ env, envName, config, field, fallback, min, max }) {
const fromEnv = integerFrom(env?.[envName]);
if (fromEnv !== null) return clamp(fromEnv, min, max);
const stored = integerFrom(config?.[field]);
if (stored !== null) return clamp(stored, min, max);
return fallback;
}

function clamp(value, min, max) {
if (min !== undefined && value < min) return min;
if (max !== undefined && value > max) return max;
return value;
}

function stringFrom(value) {
return typeof value === "string" && value.trim() ? value.trim() : null;
}

// Not a limit, but resolved the same way so the banner can report every knob
// from one place. Points at a Node package directory holding the encoder.
function stringTuned({ env, envName, config, field, fallback = "" }) {
return stringFrom(env?.[envName]) ?? stringFrom(config?.[field]) ?? fallback;
}

// A byte budget of `0` is the documented off switch: no budget is applied, so
// an oversized body is forwarded as-is and fails with the gateway's own 413
// instead of losing older images. Anything below 1 KB would only ever fire on
// requests that cannot succeed, so it is clamped up to 1 KB.
export function compactionTunables(paths, env = process.env) {
const config = paths?.keyFile ? readRouterConfig(paths.keyFile) : {};
const budget = tuned({
env, envName: "DSCODEX_MAX_UPSTREAM_BYTES", config, field: MAX_UPSTREAM_FIELD,
fallback: DEFAULT_UPSTREAM_BYTE_BUDGET,
});
const keepRecent = tuned({
env, envName: "DSCODEX_KEEP_RECENT_IMAGES", config, field: KEEP_RECENT_FIELD,
fallback: DEFAULT_KEEP_RECENT_IMAGES, min: 1,
});
const maxSide = tuned({
env, envName: "DSCODEX_IMAGE_MAX_SIDE", config, field: IMAGE_MAX_SIDE_FIELD,
fallback: DEFAULT_IMAGE_MAX_SIDE, min: 64,
});
const quality = tuned({
env, envName: "DSCODEX_WEBP_QUALITY", config, field: WEBP_QUALITY_FIELD,
fallback: DEFAULT_WEBP_QUALITY, min: 1, max: 100,
});
return {
maxUpstreamBytes: budget === 0 ? Number.POSITIVE_INFINITY : Math.max(1024, budget),
keepRecentImages: keepRecent,
imageMaxSide: maxSide,
webpQuality: quality,
webpEffort: DEFAULT_WEBP_EFFORT,
webpEncoderDir: stringTuned({
env, envName: "DSCODEX_WEBP_ENCODER_DIR", config, field: WEBP_ENCODER_DIR_FIELD,
}) || (paths?.stateDir ? join(paths.stateDir, DEFAULT_ENCODER_DIRNAME) : ""),
};
}

// Where the effective budget came from, for the startup banner. A value the
// operator cannot see is a value they cannot correct.
export function compactionBudgetSource(paths, env = process.env) {
if (integerFrom(env?.DSCODEX_MAX_UPSTREAM_BYTES) !== null) return "env";
const config = paths?.keyFile ? readRouterConfig(paths.keyFile) : {};
if (integerFrom(config?.[MAX_UPSTREAM_FIELD]) !== null) return `config.json:${MAX_UPSTREAM_FIELD}`;
return "default";
}
Loading
Loading