dsh-gpt-sub
已验证@young1lin/dsh-gpt-sub · v0.1.3 · MIT · Web 界面
Direct ChatGPT/Codex subscription access for DeepSeek Harness, replacing CLIProxyAPI.
安装
dsh plugin add @young1lin/dsh-gpt-sub 用 dsh --profile default --dump-config 确认 layer 已生效 —— 参见安装指南。
源码
标签
说明文档
dsh-gpt-sub
Direct ChatGPT/Codex subscription access for DeepSeek Harness (DSH).
Published on npm as @young1lin/dsh-gpt-sub (public, MIT).
The plugin refreshes the Codex CLI subscription token, routes Codex egress, and uses Codex native context compaction for the openai-codex provider. Normal conversation requests use pi-ai; native encrypted checkpoints are stored in the Session and restored into subsequent Responses requests.
The result needs no loopback port, no shared local key, and no external proxy
binary. The codex CLI still owns the login.
~/.codex/auth.json ──(refresh + atomic write-back)──> gpt-sub ──> CODEX_NATIVE_TOKEN credential
│
DSH ──pi-ai openai-codex provider── Bearer <that token> ──> chatgpt.com/backend-api/codex/responses
through the process-global undici dispatcher, which
gpt-sub has scoped: chatgpt.com / auth.openai.com /
api.openai.com egress through the proxy with dropped-
connection retries; every other host is untouched
Tokens come from ~/.codex/auth.json — the same file the codex CLI uses. Refreshed
tokens are written back atomically (temp file + rename), so the CLI keeps working and never
observes a partial file.
Setup
1. Mount the bundle
Option A — install from npm (recommended). dsh plugin installs the package
into the profile and mounts the bundled cordis.patch.yml automatically — no
manual yml editing:
# Prereqs: Node ≥ 22.19, DSH 0.2.1-alpha.1 with the llm-pi-ai/stream extension,
# pnpm on PATH (corepack enable or npm i -g pnpm)
dsh plugin --profile web add @young1lin/dsh-gpt-sub
dsh web # restart the host (the host half is module code)
Remove it again with dsh plugin --profile web remove @young1lin/dsh-gpt-sub.
Option B — link a checkout (development). It is a profile bundle. Link it
into the profile and list it in dsh.profile.bundles:
// ~/.dsh/profiles/web/package.json
{
"dsh": {
"profile": {
"bundles": ["@deepseek-ai/dsh-base", "@deepseek-ai/dsh-web-app", "@young1lin/dsh-gpt-sub"]
}
},
"dependencies": {
"@young1lin/dsh-gpt-sub": "link:<path-to-your-checkout>"
}
}
The bundle ships its own cordis.patch.yml with two rows:
- an insert for the
gpt-subplugin itself (token sync, proxy routing, quota panel), - an edit (no
insert:) of the dormantllm-pi-airow@deepseek-ai/dsh-basemounts, seeding the provider route.
The token plugin's configuration is:
- id: gpt-sub
name: '@young1lin/dsh-gpt-sub'
config:
proxyUrl: http://127.0.0.1:7890
authFile: ~/.codex/auth.json
refreshMarginMinutes: 30
tokenRef: CODEX_NATIVE_TOKEN
syncIntervalMinutes: 10
bootstrapRetries: 3
Naming apiKeyEnv on the route grafts an api-key auth method beside the catalog provider's
OAuth one — pi-ai's openai-codex would otherwise refuse an explicit key with
Provider is not configured. That credential reference is the token this plugin republishes,
so the two rows meet in the middle.
The edit row requires @deepseek-ai/dsh-base to come earlier in dsh.profile.bundles
(every standard profile does). A patch row for an id that does not exist is warned and
skipped, not an error.
The cordis layer applies at profile load, so a freshly installed bundle needs one restart of
dsh web; after that, user-settings changes hot-reload per request.
2. Sign in with the codex CLI
Run codex and complete the sign-in; that writes ~/.codex/auth.json. This is the only
credential step — the plugin fails at start naming the path when the file is missing or
cannot serve tokens (tokens.access_token, tokens.refresh_token,
tokens.account_id), rather than leaving a route pointed at a credential nothing
maintains.
| Model | Context | Max output | Input | Thinking levels |
|---|---|---|---|---|
gpt-5.6-sol |
272,000 | 128,000 | text, image | minimal (wire: low), xhigh, max |
gpt-5.6-terra |
272,000 | 128,000 | text, image | minimal (wire: low), xhigh, max |
gpt-5.6-luna |
272,000 | 128,000 | text, image | minimal (wire: low), xhigh, max |
The thinking level is per session, chosen in the model picker alongside the model.
To narrow or correct the catalog, write a user llm-pi-ai: settings section — it merges
per provider on top of the seeded route, so other providers declared there are additive:
# ~/.dsh/settings.yaml — keeps the credential, narrows the route to one model
llm-pi-ai:
providers:
gpt-sub:
displayName: CodexSubscription (direct)
api: openai-codex
apiKeyEnv: CODEX_NATIVE_TOKEN
models:
- id: gpt-5.6-sol
apiKeyEnv must equal the plugin's tokenRef (default CODEX_NATIVE_TOKEN); both
sides name the same credential reference, so they always agree. You never store a value
under it yourself — this plugin is what keeps the reference populated, refreshing and
republishing the token on its own schedule. No baseURL is needed: the openai-codex
provider knows the Codex backend itself.
Configuration reference
| Option | Default | Meaning |
|---|---|---|
proxyUrl |
'' (direct) |
Proxy for reaching the Codex hosts. The shipped cordis.patch.yml sets http://127.0.0.1:7890. Empty string means direct. |
authFile |
~/.codex/auth.json |
Codex CLI credential file, shared with the codex command. |
tokenRef |
CODEX_NATIVE_TOKEN |
Credential the seeded openai-codex route reads (apiKeyEnv). Must match the llm-pi-ai row. |
refreshMarginMinutes |
30 |
Refresh the access token when less than this many minutes remain. |
tokenRef |
CODEX_NATIVE_TOKEN |
Credential the provider route names in settings.yaml; this plugin keeps it populated. |
syncIntervalMinutes |
10 |
How often to re-read auth.json and republish the token. |
bootstrapRetries |
3 |
Connection-level retries per request when the link to the proxy drops. |
stateFile |
~/.dsh/gpt-sub.json |
Persisted panel overrides (proxyUrl, authFile); a field present in the file wins over the config's. |
nativeCompactionTimeoutMs |
300000 |
Maximum duration of one native compaction request. |
nativeRetainTokens |
64000 |
Estimated token budget for recent user messages retained beside the encrypted checkpoint; 0 retains none. |
nativeResponseMaxBytes |
16777216 |
Maximum streamed compaction response bytes. |
Native context compaction
Select @young1lin/dsh-gpt-sub/compaction as the backend inside the existing preset's compaction group. This backend uses native compaction for Codex Subscription (openai-codex) models, including Luna. Automatic pressure, context overflow, and /compact send the actual selected model's full balanced history, current instructions, and tools to the Codex Responses endpoint with a final compaction_trigger input item. The backend requires one encrypted compaction output item and a completed response; an upstream rejection or cancellation preserves the existing history and reports the failure without switching to text summarization.
The Session stores a replacement message containing the retained user items, opaque checkpoint, exact provider/model identity, and token count. Subsequent requests expand that replacement into native Responses input, including after restoration and before a second compaction. Ciphertext length does not determine automatic compaction pressure. Other providers use DSH basic compaction and its existing configuration.
Native compaction activates only when the host's dsh-llm-pi-ai provides the llm-pi-ai/stream extension. On adapters without it the plugin loads normally, openai-codex keeps DSH basic compaction, and every other feature is unchanged — no companion checkout required. After a native checkpoint is created, keep the same provider and model in that Session; start a new Session to switch models. Keep the plugin enabled when resuming these Sessions.
For a local trial, build the plugin with pnpm install, pnpm run typecheck, pnpm test, and pnpm run build. Link the plugin, companion adapter, and local/compaction-provider into the Web profile as described in LOCAL-TRIAL.md; ordinary dsh web then loads these builds in existing presets. The local provider alias re-exports basic compaction's named API and selects the native backend as its default plugin. Cordis patch name fields assert existing module names and cannot rename them. Choose either configured Luna model, let a conversation grow, and run /compact. A successful request logs Codex native compaction (<selected-model>); send another message to exercise encrypted-context replay.
Why bootstrapRetries exists
This is not a generic retry knob, and it is not a nicety.
The machine this was written for reaches chatgpt.com only through a Clash proxy, and that
link drops connections at random. Measured direct to chatgpt.com through the same proxy,
bypassing any harness: 32KB failed twice, 64KB failed then succeeded, 96KB and 128KB
succeeded twice. Non-monotonic — so it is random TLS/connection failure, not a payload-size
threshold and not a protocol problem. DSH saw it as
stream error: stream ID 1; PROTOCOL_ERROR; received from peer, most visibly on a session's
first request.
The retry lives in undici's retry interceptor, composed onto the proxy agent and scoped to
the three Codex hosts, so no other provider's traffic changes behavior. It retries
connection-level failures only — statusCodes is deliberately empty, because an upstream
429 or 500 carries a body DSH knows how to read and surface, and replaying it here
would swallow it. POST is listed even though undici omits it by default: a connection
error arrives before any response, and undici itself refuses to replay a request whose body
was already consumed, so a partially streamed response is never silently concatenated.
Model calls are POSTs, so without this the retry would never fire at all.
If your network is reliable, this setting costs nothing.
Operations
refresh_token rejected. The refresh token is single-use and rotates on every refresh.
If it is rejected, the plugin fails with guidance to re-authenticate: run codex and sign in
again. That rewrites ~/.codex/auth.json, which the plugin picks up.
Upstream errors are never rewrapped. An upstream 4xx/5xx passes through with its
status and body unaltered — the routing layer retries no HTTP status, and nothing between
DSH and the backend rewrites an error. This is deliberate: a generic 500 hiding the real
cause once sent debugging down the wrong path for hours.
Token publication. The access token is republished to the credential on every sync tick (default every 10 minutes) and immediately after a panel-side credential-file switch, so the provider route never reads a stale token.
Settings panel diagnostics
The Codex section follows the language selected in Settings →
General (English or Chinese), using DSH's locale service like dsh-mcp-adapter.
Buttons, placeholders, countdowns, file browsing, confirmations, and existing
action feedback follow locale updates. While the panel remains mounted, a
locale update preserves an edited path or proxy URL; navigating to another
settings section unmounts the panel and discards unsaved edits.
Credit expiration dates use the selected language too; upstream diagnostic
messages, file paths, and account-provided titles remain unchanged.
The navigation row uses the Codex mark from Lobe Icons
(MIT, attribution included in the client bundle), not the shell's
default gear. Its monochrome SVG follows the theme and is restored on plugin
unload. Since the settings shell currently has no public icon slot, this uses
the same narrowly scoped, disposable DOM adaptation as dsh-mcp-adapter.
If a future shell changes its markup, the icon adaptation safely does nothing.
The section does more than render the usage bars. It shows
one bar per rate-limit window the account reports — the 5-hour rolling window
and the weekly window — each drawn Codex-style as the percent remaining: a
full bar at 100% that drains toward 0% as the allowance is spent, with its own
reset countdown. The countdown is exact, not rounded to one unit: Resets in 4h22m
(or 4小时22分后重置 in Chinese), 10m22s under an hour (seconds only appear there,
where they still change fast enough to read), 2d5h past a day, and a zero component is dropped
rather than padded — 4h, never 4h0m. An account with no 5-hour limit (pro
accounts currently report only a weekly window) shows a note where that bar
would be, so the absence reads as a fact about the plan rather than a broken
panel. Beside the plan name the header carries the account's remaining
on-demand usage resets (upstream's rate_limit_reset_credits.available_count)
as a badge — 还有 2 次重置 — whenever the plan reports them; each reset clears
a capped window without waiting out its timer. Below the bars the panel shows:
- Auth 来源 — the
authFilethe credentials come from, plus the access token's remaining lifetime. - 自动刷新 — when the next automatic refresh happens: the moment the token's
remaining life drops below
refreshMarginMinutes, refreshed on the next read (thesyncIntervalMinutestimer or a model call, whichever comes first). - Auth 文件 — the credential file itself, editable inline. The host validates
a candidate before anything is persisted or applied (readable, parseable, and
carrying the three required token fields — the check spends no refresh);
保存 then swaps the live token source immediately, republishes the access
token to the credential store, and persists the choice to
stateFile. Saving an empty value restores the cordis-configured default. 浏览 opens a small folder picker backed by a read-only host listing — a browser's native dialog cannot hand back absolute paths, so browsing runs through the host instead (names and paths only, never file contents). - HTTP 代理 — the proxy currently routing
chatgpt.com/auth.openai.com/api.openai.com, editable inline. 测试连接 probes the candidate through a one-off agent (one real usage read, 15s timeout) before you commit; 保存 swaps the live routing immediately and persists the choice tostateFile, so it survives restarts without touching cordis config layers. An empty value means direct. The line under the input notes whether the current value comes from cordis config or a page override.
Reset credit details are lazy-loaded: opening/reopening the panel, switching languages, and background quota/status polling do not query the credit list. Click Check reset credits / 查询重置次数 for the first read; after that, only the credit list's Refresh / 刷新 button or a confirmed consumption refreshes it. The header badge simply reuses the ordinary quota response, with no extra credit-list request.
Rate-limit reset credits (the codex CLI's rate-limit-reset-credits wham API): GET /gpt-sub/reset-credits lists the account's redeemable credits, POST /gpt-sub/reset-credits/consume (body { "creditId": "..." }, optional) consumes one. The host mints a fresh redeem_request_id per click, and the panel asks for confirmation first because consuming is destructive.
Host endpoints behind the panel: GET /gpt-sub/quota (the bars), GET /gpt-sub/status,
POST /gpt-sub/proxy ({ "proxyUrl": "..." }), POST /gpt-sub/proxy/test (same body;
probes the value without applying it), POST /gpt-sub/auth ({ "authFile": "..." }; empty
clears the page override), and the read-only GET /gpt-sub/auth/browse?path=... directory
listing behind the file picker (empty path means the home directory).
Known limitations
No model discovery. Models are declared statically in
settings.yaml; there is no upstream catalog lookup in this plugin.Single account. No multi-account routing, usage/quota accounting, or management UI. CLIProxyAPI has these; nothing here needs them.
The proxy routing is process-global. pi-ai issues model calls through the global
fetch, so the plugin wraps the process-wide undici dispatcher — scoped by hostname, so only the three Codex hosts change route. If the host or another plugin later installs its own global dispatcher over this one, the routing here is silently bypassed:chatgpt.comthen goes direct and answers with a Cloudflare block page instead of an API error.Composition order. The seeded
llm-pi-airoute edits dsh-base's row, so@deepseek-ai/dsh-basemust precede this bundle indsh.profile.bundles.
Development
pnpm build # tsc -> lib/ (src only)
pnpm typecheck # tsc -p tsconfig.test.json (src + tests, noEmit)
pnpm test # vitest
TokenStore, QuotaSource, and probeProxy take injectable fetchImpl / now seams
and an injected dispatcher, so unit tests never touch the network; the plugin supplies the
real ones at startup.
History
The first design ran an in-process loopback shim speaking openai-responses
(docs/superpowers/specs/); the credential-grafting design above replaced it, keeping
TokenStore's refresh semantics unchanged. No user-visible configuration carried over
except the fields in the table above.
tests/cordis-patch.spec.ts holds the bundle patch and the plugin's tokenRef
default together, so the seeded route cannot drift from the credential it reads.