Skip to content
Open
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
57 changes: 43 additions & 14 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,17 @@ The non-negotiable details:
`codex -m deepseek/deepseek-v4-flash -c 'model_reasoning_effort="max"' -a never exec
--skip-git-repo-check 'call a shell tool exactly once …'` and the same command with
`deepseek/deepseek-v4-pro`.
6. Do not edit `~/.codex/config.toml` by hand unless the user asks; the CLI owns its two
marker-owned root keys. GUI-written `model` / `model_reasoning_effort` lines are user-owned and
must be preserved.
6. Do not edit `~/.codex/config.toml` by hand unless the user asks. The CLI owns exactly four
marker-owned root keys (`openai_base_url`, `experimental_realtime_ws_base_url`, `model_provider`,
and `model_catalog_json`) plus the exact marker-owned `[model_providers.dscodex]` table. It must
migrate both older root-block shapes, update the provider URL when the router token or port
changes, and preserve every unrelated root key and custom provider. Refuse before persisting a
token if the user already owns `model_provider` or `[model_providers.dscodex]`; uninstall removes
only exact DSCodex-owned blocks. Ownership checks must understand TOML-equivalent quoted and
dotted keys/tables without mistaking text inside multiline strings for config. If a user adds
fields or nested tables to the marker-owned provider, uninstall must refuse before stopping the
service or deleting any backing state. GUI-written `model` / `model_reasoning_effort` lines are
user-owned and must be preserved.
7. Provider selection memory lives in `~/.codex/dscodex/model-selections.json`. OpenAI and
DeepSeek have separate reasoning-effort slots; only OpenAI owns the saved service tier. The
file also persists per-thread provider memory (bounded, last 500 threads) so resumed threads
Expand Down Expand Up @@ -77,13 +85,18 @@ The non-negotiable details:
`NO_PROXY` always includes loopback plus `api.deepseek.com`). Proxy credentials are redacted
in CLI output and DPAPI-protected on Windows; the proxy URL must never be confused with the
DeepSeek key, which stays DPAPI/0600-protected and is never printed or committed.
11. `install` generates a 256-bit router token and writes it into the managed `openai_base_url`;
`start` / `serve` must reconcile that marker-owned URL with the persisted token and selected
port, and `doctor` must verify the exact binding. The proxy must reject requests without that
path token. `serve` owns a 0600 pid-state file with a per-instance shutdown token. `stop` may
only use the authenticated shutdown endpoint and must atomically preserve replacement-instance
state; it must never terminate an unverified or recycled PID. Cap both compressed request bytes
and decompressed request bytes before parsing JSON.
11. `install` generates a 256-bit router token and writes it into the managed `openai_base_url` and
`[model_providers.dscodex].base_url`. The root `model_provider` selects `dscodex`; its provider
table must declare `wire_api = "responses"`, `requires_openai_auth = true`, and
`supports_websockets = false`. HTTP-only normal model traffic is required so the router can
inspect the model before choosing DeepSeek or ChatGPT; sending `deepseek/...` through the
built-in OpenAI provider's Responses WebSocket makes ChatGPT reject it as unsupported for the
account. `start` / `serve` must reconcile both marker-owned URLs with the persisted token and
selected port, and `doctor` must verify the exact binding. The proxy must reject requests
without that path token. `serve` owns a 0600 pid-state file with a per-instance shutdown token.
`stop` may only use the authenticated shutdown endpoint and must atomically preserve
replacement-instance state; it must never terminate an unverified or recycled PID. Cap both
compressed request bytes and decompressed request bytes before parsing JSON.
12. DeepSeek does not implement Codex remote compaction v2. For a DeepSeek-bound request containing
`compaction_trigger`, the router must remove tools and the trigger, ask the same DeepSeek model
for a compact handoff summary, and return exactly one synthetic `compaction` output item before
Expand Down Expand Up @@ -120,7 +133,23 @@ The non-negotiable details:
and `doctor` passes trivially. Windows config lives under `%USERPROFILE%\\.codex`; `0600` file
permissions do not apply on NTFS (DSCodex relies on the user account ACL). Autostart uses the
platform-native scheduler on all three OSes (launchd / systemd / Task Scheduler + VBS).
15. Non-routed client features: Voice, Pets, plugins, skills, and MCP are all client-side and
unaffected by the router. Voice is driven by GPT-Live and is never routed to DeepSeek.
The catalog declares `prefer_websockets = false` — the router answers probes with 426, Codex
falls back to HTTP/SSE, and `codex doctor` may show a warning but requests work fine.
15. Client features: Pets, plugins, skills, and MCP are all client-side and unaffected by the
router. Voice is driven by GPT-Live and is never routed to DeepSeek, but its WebRTC call
creation does pass through the router: `POST /v1/live` multipart bodies (an `sdp` part and a
JSON `session` part) are re-encoded as the official JSON shape and forwarded to
`chatgpt.com`'s `/backend-api/codex/realtime/calls` endpoint with the AVAS query params
(`intent=quicksilver&architecture=avas`) and the `OpenAI-Alpha: quicksilver=v2` header,
mirroring the official client. Live requests forward the client's full header set (cookies,
integrity-state, DeviceCheck) because that endpoint sits behind stricter Cloudflare checks.
DSCodex owns the root `experimental_realtime_ws_base_url` setting and points it at the
authenticated loopback router's `/v1/realtime` base. Current Codex normalizes that base to
`/v1/live/<call_id>` for V3 sideband sessions; the router also supports legacy
`/v1/realtime?call_id=...`. Both routes validate the call ID, preserve required
authorization/session/attestation headers, redact call IDs from logs, and connect only to the
fixed `api.openai.com/v1` Realtime upstream through the saved outbound proxy. The catalog
declares `prefer_websockets = false`, while the managed `dscodex` provider declares
`supports_websockets = false`, so normal model traffic (including Voice background Responses
work) is always inspectable HTTP/SSE. The router retains authenticated `/v1/responses`
WebSocket tunneling to ChatGPT only for older-client compatibility. Other upgrade probes
receive 426 so Codex falls back to HTTP/SSE; DeepSeek traffic must never use the WebSocket
tunnel.
10 changes: 8 additions & 2 deletions README.en.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ http://127.0.0.1:10110/<router-token>/v1 ← DSCodex loopback router

Traffic is split by model name. Only DeepSeek-bound requests are rewritten; GPT traffic is forwarded transparently.

The installer manages root-level `model_provider = "dscodex"` plus `[model_providers.dscodex]`, pinning ordinary model requests to OAuth-authenticated HTTP Responses (`wire_api = "responses"`, `requires_openai_auth = true`, `supports_websockets = false`). This lets the router inspect the requested model before choosing DeepSeek or ChatGPT. Otherwise Codex can place a `deepseek/...` model on ChatGPT's account-only Responses WebSocket and report that the model is unsupported with a ChatGPT account. Voice keeps its separate Realtime sideband WebSocket.

## Compatibility

| Surface or behavior | Status |
Expand Down Expand Up @@ -123,13 +125,17 @@ Yes. Codex CLI and IDE extensions are supported on macOS, Linux, and Windows; na

Yes. Tool calls and web search use DeepSeek's Responses API. Because the text-only models cannot see images directly, DSCodex first asks GPT for an image description. Automatic and manual compaction produce an encrypted Codex compaction item.

### Why does Codex say DeepSeek is unsupported with a ChatGPT account?

Codex is still treating DeepSeek as a WebSocket model on its built-in OpenAI provider. After updating DSCodex, rerun `node src/cli.mjs install` (and `node src/cli.mjs autostart enable` when autostart is used), then fully quit and relaunch ChatGPT / Codex. DSCodex adds its marker-owned HTTP-only provider configuration. It preserves other custom providers and refuses to replace a user-owned `model_provider` or `[model_providers.dscodex]` table.

## Known edge cases

- **Usage stats.** The Codex app's Profile page is read-only — DeepSeek usage cannot be added.
- **Why reasoning folds mid-task.** DeepSeek emits `response.completed` after every tool round; Codex folds the reasoning block, runs the tool, and opens a new request. API behavior, not a bug. No-tool turns fold once at the end.
- **GPT vision.** Borrows the request's ChatGPT OAuth headers (no extra key). Without OAuth headers images pass through untouched. Default model `gpt-5.6-sol`, override with `DSCODEX_VISION_MODEL`.
- **Key storage, proxy resolution, bridge details, platform differences.** See `AGENTS.md`.
- **Voice / Pets / plugins / skills / MCP.** All client-side; Voice runs on GPT-Live and is never routed to DeepSeek.
- **Voice / Pets / plugins / skills / MCP.** Pets, plugins, skills, and MCP are all client-side. Voice runs on GPT-Live and is never routed to DeepSeek. Its WebRTC call creation is forwarded to chatgpt.com's `realtime/calls` endpoint, and its authenticated Realtime sideband WebSocket reaches the fixed OpenAI Realtime upstream through the configured proxy. Background Responses work uses the same routable HTTP/SSE path as ordinary model traffic. An authenticated ChatGPT Responses WebSocket relay remains only for older-client compatibility; DeepSeek never uses it.
- **DeepSeek → GPT thread history.** Switching an existing task from DeepSeek back to GPT can currently leave plaintext `reasoning_text` in history and cause a persistent GPT 400 response; see [#17](https://github.com/fish2lab/DSCodex/issues/17). Switching back to DeepSeek or starting a new GPT task remains available.

## Uninstall
Expand All @@ -138,7 +144,7 @@ Yes. Tool calls and web search use DeepSeek's Responses API. Because the text-on
node src/cli.mjs stop && node src/cli.mjs uninstall
```

Removes only DSCodex-owned config and files. The pre-install backup stays at `~/.codex/config.toml.pre-dscodex.bak`.
Removes only DSCodex-owned config and files. The pre-install backup stays at `~/.codex/config.toml.pre-dscodex.bak`. If fields or subtables were manually added to the marker-owned `[model_providers.dscodex]`, uninstall refuses before stopping the service or deleting state; restore the original table shape or handle those customizations manually first.

## References

Expand Down
10 changes: 8 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,8 @@ http://127.0.0.1:10110/<router-token>/v1 ← DSCodex 本地路由

按模型名分流。路由仅改写 DeepSeek-bound 请求,GPT 流量透明旁路。

安装器会管理根级 `model_provider = "dscodex"` 与 `[model_providers.dscodex]`,将普通模型请求固定为带 ChatGPT OAuth 的 HTTP Responses(`wire_api = "responses"`、`requires_openai_auth = true`、`supports_websockets = false`)。这样路由器能先读取请求中的模型名再决定去 DeepSeek 或 ChatGPT;否则 Codex 可能把 `deepseek/...` 直接放进 ChatGPT 账户专用的 Responses WebSocket,并返回 “model is not supported when using Codex with a ChatGPT account”。Voice 仍使用独立的 Realtime sideband WebSocket。

## 兼容性

| 场景 | 状态 |
Expand Down Expand Up @@ -124,13 +126,17 @@ http://127.0.0.1:10110/<router-token>/v1 ← DSCodex 本地路由

能。工具调用和 web search 走 DeepSeek Responses API;文字模型无法直接看到图片,因此 DSCodex 先用 GPT 生成图片描述;自动或手动压缩由 DSCodex 生成加密的 Codex compaction item。

### 为什么提示 DeepSeek 不支持 ChatGPT account?

这表示当前 Codex 仍把 DeepSeek 当成内置 OpenAI provider 的 WebSocket 模型。更新 DSCodex 后重新运行 `node src/cli.mjs install`(如使用自启,再运行 `node src/cli.mjs autostart enable`),然后完全退出并重开 ChatGPT / Codex。DSCodex 会补齐 marker-owned 的 HTTP-only provider 配置;不会覆盖用户的其他自定义 provider,若 `model_provider` 或 `[model_providers.dscodex]` 已由用户配置则会拒绝并提示冲突。

## 已知边界

- **用量统计。** Codex 的 Profile 页面只读,无法计入 DeepSeek 用量。
- **思考反复折叠。** DeepSeek 每轮工具调用结束发 `response.completed`,Codex 折叠→执行→展开下一轮思考。这是 API 行为。无工具的单轮只折叠一次。
- **GPT 识图。** 借用请求自带的 OAuth 头,无需额外 key。无 OAuth 时图片原样透传。默认模型 `gpt-5.6-sol`,`DSCODEX_VISION_MODEL` 可换。
- **Key 存储、代理解析、bridge 细节、平台差异。** 详见 `AGENTS.md`。
- **Voice / Pets / 插件 / 技能 / MCP。** 均为客户端功能;Voice 由 GPT-Live 驱动,不会路由到 DeepSeek。
- **Voice / Pets / 插件 / 技能 / MCP。** Pets、插件、技能、MCP 均为客户端功能;Voice 由 GPT-Live 驱动,不会路由到 DeepSeek。语音通话创建请求(WebRTC)会由路由器转发到 chatgpt.com 的 `realtime/calls` 端点。DSCodex 还会管理 `experimental_realtime_ws_base_url`,让 V3 的 `/v1/live/<call_id>` 侧带 WebSocket(并兼容旧版 `/v1/realtime?call_id=...`)通过已保存的出站代理连接固定的 OpenAI Realtime 上游;Voice 发起的后台 Responses 任务则和普通模型请求一样走可按模型分流的 HTTP/SSE。路由器仅为旧客户端保留经过认证的 ChatGPT Responses WebSocket 兼容通道,DeepSeek 不走该通道
- **DeepSeek → GPT 任务历史。** 同一任务从 DeepSeek 切回 GPT 时,历史中的明文 `reasoning_text` 目前可能导致 GPT 请求返回 400;见 [#17](https://github.com/fish2lab/DSCodex/issues/17)。切回 DeepSeek 或新建 GPT 任务可继续使用。

## 卸载
Expand All @@ -139,7 +145,7 @@ http://127.0.0.1:10110/<router-token>/v1 ← DSCodex 本地路由
node src/cli.mjs stop && node src/cli.mjs uninstall
```

只删除 DSCodex 写入的配置和文件。备份保留在 `~/.codex/config.toml.pre-dscodex.bak`。
只删除 DSCodex 写入的配置和文件。备份保留在 `~/.codex/config.toml.pre-dscodex.bak`。如果手动给 marker-owned 的 `[model_providers.dscodex]` 增加了字段或子表,卸载会在停服务或删除状态前拒绝;先恢复该表的原始结构,或自行处理这些定制项。

## 参考

Expand Down
9 changes: 8 additions & 1 deletion src/cli.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { createInterface } from "node:readline";
import { fileURLToPath } from "node:url";
import { buildCatalog, syncCatalog } from "./catalog.mjs";
import {
assertSafeToUninstall,
ensureManagedRouterBinding,
install,
managedRouterConfigMatches,
Expand Down Expand Up @@ -491,7 +492,10 @@ async function serve(port) {
const shutdown = () => {
if (shuttingDown) return;
shuttingDown = true;
const forceTimer = setTimeout(() => server.closeAllConnections?.(), 5_000);
const forceTimer = setTimeout(() => {
server.closeUpgradeConnections?.();
server.closeAllConnections?.();
}, 5_000);
server.close(() => {
clearTimeout(forceTimer);
removePidState(paths, { pid: process.pid, instanceId });
Expand Down Expand Up @@ -1046,6 +1050,9 @@ async function main() {
case "doctor": await doctor(port); break;
case "stop": await stop(); break;
case "uninstall":
// Validate marker ownership before stopping services or deleting any
// generated state; a customized provider must make uninstall a no-op.
assertSafeToUninstall({ paths });
await autostartDisable(paths, { quiet: true });
await stop();
uninstall({ paths });
Expand Down
Loading