@lyogvce/dsh-codex-subscription 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -13,10 +13,11 @@
13
13
  - 每次请求前检查 `access_token` 的 JWT `exp`:距离过期不足 5 分钟时自动通过单飞锁(Single-flight)向 `https://auth.openai.com/oauth/token` 刷新,并通过 `realpathSync` + 临时文件 `rename` 原子写回 `auth.json`,保证与 Codex CLI 永不互踢下线。
14
14
  2. **零网络轮询的本地模型目录发现**
15
15
  - 直接读取 `~/.codex/models_cache.json`(按文件 `mtime` 与大小自动感知更新),提取 `visibility === "list"` 的模型及其 `context_window`、`supported_reasoning_levels`(自动将 `ultra` 映射为 DSH 的 `max` 档位)与 `default_verbosity`。
16
- 3. **支持按配置屏蔽不想看到的模型(`disabledModels`)**
16
+ 3. **支持按配置屏蔽模型(`disabledModels`)与自定义上下文窗口(`contextWindows`)**
17
17
  - 在插件配置中通过 `disabledModels` 屏蔽指定模型 `slug`,保持模型选择器干净清爽。
18
+ - 通过 `contextWindows` 按模型 `slug` 自定义向 DSH 会话与自动压缩器暴露的预算窗口(例如设为 `400000`);未配置时默认使用模型物理上限 `max_context_window`(`872000`)。同时底层 `PiCodexModel.contextWindow` 始终保持物理上限 `872000`,彻底防止 `dsh-llm-pi-ai` 在预算窗口与物理上限之间误判 `CONTEXT_WINDOW_EXCEEDED`。
18
19
  4. **原生多轮思考链回放与工具调用**
19
- - 底层复用 DSH 内置的 `PiAiAdapter` 与 `@earendil-works/pi-ai` 的 `openaiCodexProvider()`,固定使用 `sse` 传输(含 `zstd` 请求体压缩、`session-id` / `thread-id` 缓存头、`reasoning.encrypted_content` 签名回放与图片自动缩放卸载)。
20
+ - 底层复用 DSH 内置的 `PiAiAdapter` 与 `@earendil-works/pi-ai` 的 `openaiCodexProvider()`,固定使用 `sse` 传输(含 `zstd` 请求体压缩、规范化纯 UUID `session-id` / `x-client-request-id` 缓存头、`reasoning.encrypted_content` 签名回放与图片自动缩放卸载)。
20
21
  5. **Codex 订阅限额内联展示(`conversation.input.right`)**
21
22
  - 当会话选中 `openai-codex` 模型时,在输入框右下角紧邻模型选择器左侧展示 `5h X% · 7d Y%` 限额胶囊(数据源来自 `https://chatgpt.com/backend-api/wham/usage`,内置 60 秒缓存、单飞合并与每轮回复结束自动刷新),点击可展开查看 5 小时 / 7 天窗口进度条、重置倒计时与手动刷新按钮;切换至其他 Provider 模型时自动隐藏。
22
23
 
@@ -32,9 +33,9 @@
32
33
  dsh plugin --profile desktop add @lyogvce/dsh-codex-subscription
33
34
  ```
34
35
 
35
- ### 2. 配置屏蔽模型(`disabledModels`)
36
+ ### 2. 配置屏蔽模型(`disabledModels`)与上下文窗口(`contextWindows`)
36
37
 
37
- 默认配置(见 [cordis.patch.yml](cordis.patch.yml))已屏蔽旧模型与备用模型,仅保留主力模型(如 `gpt-6.1-sol`、`gpt-6-astra`):
38
+ 默认配置(见 [cordis.patch.yml](cordis.patch.yml))已屏蔽旧模型与备用模型,仅保留主力模型(如 `gpt-6.1-sol`、`gpt-6-astra`)。未配置 `contextWindows` 时,模型默认使用 `872000` 上下文窗口;若希望主动限制长上下文预算(例如统一设为 400K),可在 Profile 的 `cordis.patch.yml` 中配置:
38
39
 
39
40
  ```yaml
40
41
  - id: codex-subscription
@@ -46,9 +47,12 @@ dsh plugin --profile desktop add @lyogvce/dsh-codex-subscription
46
47
  - gpt-5.6-sol
47
48
  - gpt-5.6-terra
48
49
  - gpt-5.6-luna
50
+ contextWindows:
51
+ gpt-6.1-sol: 400000
52
+ gpt-6-astra: 400000
49
53
  ```
50
54
 
51
- 可在 DSH 的 **设置 → 插件 → Codex 订阅直连** 页面或 `cordis.patch.yml` 中随时修改 `disabledModels`,实时生效无需重启。
55
+ 可在 DSH 的 **设置 → 插件 → Codex 订阅直连** 页面或 `cordis.patch.yml` 中随时修改 `disabledModels` 与 `contextWindows`,实时生效无需重启。
52
56
 
53
57
  ---
54
58
 
package/lib/client.js CHANGED
@@ -15,6 +15,9 @@ window.__ModuleLoader__.load({
15
15
  ]);
16
16
  //#endregion
17
17
  //#region src/usage.ts
18
+ function safeErrorMessage(error) {
19
+ return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted]").replace(/\b(Bearer\s+)[^\s,;'"&]+/giu, "$1[redacted]").replace(/(\b(?:code|token|access_token|refresh_token|id_token)=)[^&\s"']+/giu, "$1[redacted]").replace(/("(?:access_token|refresh_token|id_token)"\s*:\s*")[^"]+"/giu, "$1[redacted]\"").slice(0, 1e3);
20
+ }
18
21
  const USAGE_ROUTE_PATH = "/api/codex-subscription/usage";
19
22
  function clampPercent(value) {
20
23
  return Math.max(0, Math.min(100, Math.round(value)));
@@ -153,7 +156,7 @@ window.__ModuleLoader__.load({
153
156
  setState({
154
157
  quota: state.quota,
155
158
  loading: false,
156
- error: error instanceof Error ? error.message : String(error)
159
+ error: safeErrorMessage(error)
157
160
  });
158
161
  } finally {
159
162
  if (seq === requestSeq) {
package/lib/index.d.ts CHANGED
@@ -18,6 +18,7 @@ interface JwtClaims {
18
18
  }
19
19
  interface AuthManagerOptions {
20
20
  getCodexHome: () => string;
21
+ getClientVersion?: (() => string) | undefined;
21
22
  fetchImpl?: typeof fetch | undefined;
22
23
  now?: (() => number) | undefined;
23
24
  }
@@ -57,6 +58,7 @@ interface RawModelsCache {
57
58
  models?: RawCodexModelEntry[];
58
59
  }
59
60
  interface CodexModelMetadata {
61
+ readonly contextWindow: number;
60
62
  readonly supportVerbosity: boolean;
61
63
  readonly defaultVerbosity: 'low' | 'medium' | 'high';
62
64
  readonly defaultReasoningLevel?: string | undefined;
@@ -67,14 +69,16 @@ interface ParsedModelsCatalog {
67
69
  readonly metadataById: ReadonlyMap<string, CodexModelMetadata>;
68
70
  }
69
71
  declare function buildThinkingLevelMap(supportedLevels: unknown): Record<string, string | null>;
70
- declare function parseModelsCache(rawCache: RawModelsCache | null | undefined, baseModels: readonly PiCodexModel[], disabledModels?: readonly string[]): ParsedModelsCatalog;
72
+ declare function parseModelsCache(rawCache: RawModelsCache | null | undefined, baseModels: readonly PiCodexModel[], disabledModels?: readonly string[], contextWindows?: Readonly<Record<string, number>>): ParsedModelsCatalog;
71
73
  //#endregion
72
74
  //#region src/request.d.ts
73
75
  declare function buildCodexUserAgent(version?: string): string;
74
- declare function buildCodexHeaders(existingHeaders: Record<string, string> | undefined, clientVersion: string, sessionId?: string | undefined): Record<string, string>;
76
+ declare function normalizeCodexSessionId(sessionId: string | undefined): string | undefined;
77
+ declare function buildCodexHeaders(existingHeaders?: Record<string, string> | undefined, clientVersion?: string): Record<string, string>;
75
78
  declare function sanitizeCodexPayload(payload: Record<string, unknown> | null | undefined, modelMeta?: CodexModelMetadata | undefined): Record<string, unknown> | null | undefined;
76
79
  //#endregion
77
80
  //#region src/usage.d.ts
81
+ declare function safeErrorMessage(error: unknown): string;
78
82
  interface CodexQuotaWindow {
79
83
  readonly usedPercent: number;
80
84
  readonly resetAt: number | null;
@@ -131,18 +135,21 @@ export declare const __test: Readonly<{
131
135
  PROVIDER: "openai-codex";
132
136
  DISPLAY_NAME: "Codex";
133
137
  DEFAULT_DISABLED_MODELS: readonly string[];
138
+ DEFAULT_CONTEXT_WINDOW: 872000;
134
139
  CODEX_USAGE_URL: "https://chatgpt.com/backend-api/wham/usage";
135
140
  USAGE_ROUTE_PATH: "/api/codex-subscription/usage";
136
141
  DEFAULT_USAGE_TTL_MS: 60000;
137
142
  resolveCodexHome: typeof resolveCodexHome;
138
143
  decodeJwtPayload: typeof decodeJwtPayload;
139
144
  extractAccountId: typeof extractAccountId;
145
+ safeErrorMessage: typeof safeErrorMessage;
140
146
  createAuthManager: typeof createAuthManager;
141
147
  buildThinkingLevelMap: typeof buildThinkingLevelMap;
142
148
  parseModelsCache: typeof parseModelsCache;
143
149
  sanitizeCodexPayload: typeof sanitizeCodexPayload;
144
150
  buildCodexHeaders: typeof buildCodexHeaders;
145
151
  buildCodexUserAgent: typeof buildCodexUserAgent;
152
+ normalizeCodexSessionId: typeof normalizeCodexSessionId;
146
153
  parseUsagePayload: typeof parseUsagePayload;
147
154
  createUsageFetcher: typeof createUsageFetcher;
148
155
  }>;
package/lib/index.js CHANGED
@@ -1,167 +1,18 @@
1
- import { readFileSync, realpathSync, statSync } from "node:fs";
1
+ import { chmodSync, readFileSync, realpathSync, statSync } from "node:fs";
2
2
  import { dirname, join, resolve } from "node:path";
3
3
  import z from "@deepseek-ai/schemastery";
4
- import { LlmError, resolveImageAttachmentAccess } from "@deepseek-ai/dsh-llm";
4
+ import { LlmError, resolveImageAttachmentAccess, resolveRetryPolicy } from "@deepseek-ai/dsh-llm";
5
5
  import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
6
6
  import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
7
7
  import { randomUUID } from "node:crypto";
8
8
  import { chmod, rename, writeFile } from "node:fs/promises";
9
9
  import { arch, homedir, platform, release } from "node:os";
10
10
  import { execFileSync } from "node:child_process";
11
- //#region src/auth.ts
12
- const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
13
- const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
14
- const TOKEN_REFRESH_LEAD_MS = 3e5;
15
- const TOKEN_REFRESH_TIMEOUT_MS = 3e4;
16
- function resolveCodexHome(envCodexHome = process.env.CODEX_HOME) {
17
- const raw = typeof envCodexHome === "string" && envCodexHome.trim() !== "" ? envCodexHome.trim() : join(homedir(), ".codex");
18
- return raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : resolve(raw);
19
- }
20
- function decodeJwtPayload(token) {
21
- if (typeof token !== "string") return void 0;
22
- const parts = token.split(".");
23
- const body = parts[1];
24
- if (parts.length !== 3 || !body) return void 0;
25
- try {
26
- return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
27
- } catch {
28
- return;
29
- }
30
- }
31
- function extractAccountId(parsed, accessToken) {
32
- const fileAccountId = parsed?.tokens?.account_id;
33
- if (typeof fileAccountId === "string" && fileAccountId.trim() !== "") return fileAccountId.trim();
34
- const authClaim = decodeJwtPayload(accessToken)?.["https://api.openai.com/auth"];
35
- if (authClaim && typeof authClaim === "object" && !Array.isArray(authClaim)) {
36
- const jwtAccountId = authClaim.chatgpt_account_id;
37
- if (typeof jwtAccountId === "string" && jwtAccountId.trim() !== "") return jwtAccountId.trim();
38
- }
39
- }
40
- async function writeJsonAtomic(filePath, data) {
41
- let targetPath = filePath;
42
- try {
43
- targetPath = realpathSync(filePath);
44
- } catch {}
45
- const tempPath = join(dirname(targetPath), `.auth.json.tmp-${process.pid}-${randomUUID()}`);
46
- const content = `${JSON.stringify(data, null, 2)}\n`;
47
- await writeFile(tempPath, content, {
48
- encoding: "utf8",
49
- mode: 384
50
- });
51
- await chmod(tempPath, 384).catch(() => {});
52
- await rename(tempPath, targetPath);
53
- }
54
- function createAuthManager(options) {
55
- const { getCodexHome } = options;
56
- const fetchFn = options.fetchImpl ?? globalThis.fetch;
57
- const nowFn = options.now ?? Date.now;
58
- let inflightRefresh;
59
- let memoryFallback;
60
- function readAuthState() {
61
- const authPath = join(getCodexHome(), "auth.json");
62
- let rawText;
63
- try {
64
- rawText = readFileSync(authPath, "utf8");
65
- } catch (error) {
66
- if (memoryFallback?.authPath === authPath) return memoryFallback;
67
- throw new LlmError(`无法读取 Codex 登录凭证 (${authPath}),请先在终端运行 codex login`, "MISSING_CREDENTIAL", { cause: error });
68
- }
69
- let parsed;
70
- try {
71
- parsed = JSON.parse(rawText);
72
- } catch (error) {
73
- if (memoryFallback?.authPath === authPath) return memoryFallback;
74
- throw new LlmError(`Codex 凭证文件格式损坏 (${authPath})`, "INVALID_CREDENTIAL", { cause: error });
75
- }
76
- const accessToken = parsed?.tokens?.access_token;
77
- if (typeof accessToken !== "string" || accessToken.trim() === "") {
78
- if (memoryFallback?.authPath === authPath) return memoryFallback;
79
- throw new LlmError(`Codex 未登录 (${authPath} 中无 access_token),请运行 codex login`, "MISSING_CREDENTIAL");
80
- }
81
- if (memoryFallback?.authPath === authPath && memoryFallback.accessToken !== accessToken && isTokenFresh(memoryFallback.accessToken)) return memoryFallback;
82
- return {
83
- authPath,
84
- parsed,
85
- accessToken
86
- };
87
- }
88
- function isTokenFresh(accessToken) {
89
- const exp = decodeJwtPayload(accessToken)?.exp;
90
- if (typeof exp !== "number" || !Number.isFinite(exp)) return true;
91
- return exp * 1e3 - nowFn() > TOKEN_REFRESH_LEAD_MS;
92
- }
93
- async function refreshToken(authPath, rawAuth) {
94
- const refreshTok = rawAuth?.tokens?.refresh_token;
95
- if (typeof refreshTok !== "string" || refreshTok.trim() === "") throw new LlmError("Codex 凭证已过期且缺少 refresh_token,请重新运行 codex login", "AUTH_FAILED");
96
- const response = await fetchFn(CODEX_TOKEN_URL, {
97
- method: "POST",
98
- headers: { "Content-Type": "application/json" },
99
- body: JSON.stringify({
100
- client_id: CODEX_CLIENT_ID,
101
- grant_type: "refresh_token",
102
- refresh_token: refreshTok,
103
- scope: "openid profile email"
104
- }),
105
- signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS)
106
- });
107
- if (!response.ok) throw new LlmError(`Codex Token 刷新失败 (HTTP ${response.status}),请重新运行 codex login`, "AUTH_FAILED");
108
- const fresh = await response.json();
109
- if (typeof fresh?.access_token !== "string" || fresh.access_token.trim() === "") throw new LlmError("Codex Token 刷新响应缺少 access_token", "AUTH_FAILED");
110
- const nextAuth = {
111
- ...rawAuth,
112
- tokens: {
113
- ...rawAuth.tokens ?? {},
114
- access_token: fresh.access_token,
115
- ...typeof fresh.id_token === "string" && fresh.id_token !== "" ? { id_token: fresh.id_token } : {},
116
- ...typeof fresh.refresh_token === "string" && fresh.refresh_token !== "" ? { refresh_token: fresh.refresh_token } : {}
117
- },
118
- last_refresh: new Date(nowFn()).toISOString()
119
- };
120
- memoryFallback = {
121
- authPath,
122
- parsed: nextAuth,
123
- accessToken: fresh.access_token
124
- };
125
- try {
126
- await writeJsonAtomic(authPath, nextAuth);
127
- } catch (error) {
128
- console.error(`[dsh-codex-subscription] 写回刷新后的 Codex auth.json 失败 (${authPath}): ${error instanceof Error ? error.message : String(error)}`);
129
- }
130
- return {
131
- accessToken: fresh.access_token,
132
- accountId: extractAccountId(nextAuth, fresh.access_token)
133
- };
134
- }
135
- function toCredentials(state) {
136
- return {
137
- accessToken: state.accessToken,
138
- accountId: extractAccountId(state.parsed, state.accessToken)
139
- };
140
- }
141
- async function resolveCredentials() {
142
- const current = readAuthState();
143
- if (isTokenFresh(current.accessToken)) return toCredentials(current);
144
- if (!inflightRefresh) inflightRefresh = (async () => {
145
- const latest = readAuthState();
146
- if (isTokenFresh(latest.accessToken)) return toCredentials(latest);
147
- return refreshToken(latest.authPath, latest.parsed);
148
- })().finally(() => {
149
- inflightRefresh = void 0;
150
- });
151
- return inflightRefresh;
152
- }
153
- return {
154
- resolveCredentials,
155
- async resolveAccessToken() {
156
- return (await resolveCredentials()).accessToken;
157
- }
158
- };
159
- }
160
- //#endregion
161
11
  //#region src/catalog.ts
162
12
  const PROVIDER = "openai-codex";
163
13
  const DISPLAY_NAME = "Codex";
164
14
  const DEFAULT_CLIENT_VERSION = "0.163.0";
15
+ const DEFAULT_CONTEXT_WINDOW = 872e3;
165
16
  const THINKING_LEVELS = [
166
17
  "off",
167
18
  "minimal",
@@ -202,7 +53,10 @@ function normalizeDefaultReasoningLevel(rawLevel, thinkingLevelMap) {
202
53
  function isVerbosity(value) {
203
54
  return value === "low" || value === "medium" || value === "high";
204
55
  }
205
- function parseModelsCache(rawCache, baseModels, disabledModels = []) {
56
+ function isPositiveSafeInt(value) {
57
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
58
+ }
59
+ function parseModelsCache(rawCache, baseModels, disabledModels = [], contextWindows = {}) {
206
60
  const disabledSet = new Set((Array.isArray(disabledModels) ? disabledModels : []).filter((id) => typeof id === "string").map((id) => id.trim()).filter(Boolean));
207
61
  const clientVersion = typeof rawCache?.client_version === "string" && rawCache.client_version.trim() !== "" ? rawCache.client_version.trim() : DEFAULT_CLIENT_VERSION;
208
62
  const rawModels = Array.isArray(rawCache?.models) ? rawCache.models : [];
@@ -218,8 +72,11 @@ function parseModelsCache(rawCache, baseModels, disabledModels = []) {
218
72
  if (seen.has(id)) continue;
219
73
  seen.add(id);
220
74
  const base = baseModels.find((model) => model.id === id) ?? templateModel;
221
- const contextWindow = typeof entry.context_window === "number" && Number.isSafeInteger(entry.context_window) && entry.context_window > 0 ? entry.context_window : typeof entry.max_context_window === "number" && Number.isSafeInteger(entry.max_context_window) && entry.max_context_window > 0 ? entry.max_context_window : base?.contextWindow ?? 272e3;
222
- const maxTokens = Math.min(128e3, Math.max(32768, Math.floor(contextWindow / 4)));
75
+ const maxContextWindow = isPositiveSafeInt(entry.max_context_window) ? entry.max_context_window : DEFAULT_CONTEXT_WINDOW;
76
+ const configuredOverride = contextWindows && typeof contextWindows === "object" ? contextWindows[id] : void 0;
77
+ const targetContextWindow = isPositiveSafeInt(configuredOverride) ? configuredOverride : maxContextWindow;
78
+ const wireContextWindow = Math.max(maxContextWindow, targetContextWindow);
79
+ const maxTokens = Math.min(128e3, Math.max(32768, Math.floor(targetContextWindow / 4)));
223
80
  const inputs = Array.isArray(entry.input_modalities) ? entry.input_modalities.filter((m) => m === "text" || m === "image") : ["text", "image"];
224
81
  const thinkingLevelMap = buildThinkingLevelMap(entry.supported_reasoning_levels);
225
82
  const hasReasoning = Object.entries(thinkingLevelMap).some(([k, v]) => k !== "off" && v !== null);
@@ -227,6 +84,7 @@ function parseModelsCache(rawCache, baseModels, disabledModels = []) {
227
84
  const defaultVerbosity = isVerbosity(entry.default_verbosity) ? entry.default_verbosity : "low";
228
85
  const defaultReasoningLevel = normalizeDefaultReasoningLevel(entry.default_reasoning_level, thinkingLevelMap);
229
86
  metadataById.set(id, {
87
+ contextWindow: targetContextWindow,
230
88
  supportVerbosity,
231
89
  defaultVerbosity,
232
90
  defaultReasoningLevel
@@ -240,7 +98,7 @@ function parseModelsCache(rawCache, baseModels, disabledModels = []) {
240
98
  provider: PROVIDER,
241
99
  baseUrl: "https://chatgpt.com/backend-api",
242
100
  input: inputs.length > 0 ? inputs : ["text"],
243
- contextWindow,
101
+ contextWindow: wireContextWindow,
244
102
  maxTokens,
245
103
  reasoning: hasReasoning,
246
104
  thinkingLevelMap,
@@ -298,7 +156,12 @@ function buildCodexUserAgent(version = DEFAULT_CLIENT_VERSION) {
298
156
  const term = platform() === "darwin" ? "Apple_Terminal/455" : platform() === "win32" ? "WindowsTerminal" : "xterm-256color";
299
157
  return `codex_cli_rs/${version} (${detectOsLabel()}; ${cpu}) ${term}`;
300
158
  }
301
- function buildCodexHeaders(existingHeaders, clientVersion, sessionId) {
159
+ function normalizeCodexSessionId(sessionId) {
160
+ if (typeof sessionId !== "string") return void 0;
161
+ const stripped = sessionId.trim().replace(/^session-/iu, "").trim();
162
+ return stripped.length > 0 ? stripped : void 0;
163
+ }
164
+ function buildCodexHeaders(existingHeaders, clientVersion = DEFAULT_CLIENT_VERSION) {
302
165
  const headers = {};
303
166
  for (const [key, value] of Object.entries(existingHeaders ?? {})) {
304
167
  if (key.toLowerCase() === "user-agent") continue;
@@ -307,7 +170,6 @@ function buildCodexHeaders(existingHeaders, clientVersion, sessionId) {
307
170
  headers.originator = "codex_cli_rs";
308
171
  headers.version = clientVersion;
309
172
  headers["User-Agent"] = buildCodexUserAgent(clientVersion);
310
- if (typeof sessionId === "string" && sessionId.length > 0) headers["thread-id"] = sessionId;
311
173
  return headers;
312
174
  }
313
175
  function sanitizeCodexPayload(payload, modelMeta) {
@@ -352,6 +214,9 @@ function sanitizeCodexPayload(payload, modelMeta) {
352
214
  }
353
215
  //#endregion
354
216
  //#region src/usage.ts
217
+ function safeErrorMessage(error) {
218
+ return (error instanceof Error ? error.message : String(error)).replace(/\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/gu, "[redacted]").replace(/\b(Bearer\s+)[^\s,;'"&]+/giu, "$1[redacted]").replace(/(\b(?:code|token|access_token|refresh_token|id_token)=)[^&\s"']+/giu, "$1[redacted]").replace(/("(?:access_token|refresh_token|id_token)"\s*:\s*")[^"]+"/giu, "$1[redacted]\"").slice(0, 1e3);
219
+ }
355
220
  const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
356
221
  const USAGE_ROUTE_PATH = "/api/codex-subscription/usage";
357
222
  const DEFAULT_USAGE_TTL_MS = 6e4;
@@ -431,7 +296,7 @@ function createUsageFetcher(options) {
431
296
  } catch (error) {
432
297
  return {
433
298
  ok: false,
434
- error: error instanceof Error ? error.message : String(error),
299
+ error: safeErrorMessage(error),
435
300
  ...cached !== void 0 ? { quota: cached } : {}
436
301
  };
437
302
  }
@@ -453,6 +318,174 @@ function createUsageFetcher(options) {
453
318
  };
454
319
  }
455
320
  //#endregion
321
+ //#region src/auth.ts
322
+ const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
323
+ const CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token";
324
+ const TOKEN_REFRESH_LEAD_MS = 3e5;
325
+ const TOKEN_REFRESH_TIMEOUT_MS = 3e4;
326
+ function resolveCodexHome(envCodexHome = process.env.CODEX_HOME) {
327
+ const raw = typeof envCodexHome === "string" && envCodexHome.trim() !== "" ? envCodexHome.trim() : join(homedir(), ".codex");
328
+ return raw.startsWith("~/") ? join(homedir(), raw.slice(2)) : resolve(raw);
329
+ }
330
+ function decodeJwtPayload(token) {
331
+ if (typeof token !== "string") return void 0;
332
+ const parts = token.split(".");
333
+ const body = parts[1];
334
+ if (parts.length !== 3 || !body) return void 0;
335
+ try {
336
+ return JSON.parse(Buffer.from(body, "base64url").toString("utf8"));
337
+ } catch {
338
+ return;
339
+ }
340
+ }
341
+ function extractAccountId(parsed, accessToken) {
342
+ const fileAccountId = parsed?.tokens?.account_id;
343
+ if (typeof fileAccountId === "string" && fileAccountId.trim() !== "") return fileAccountId.trim();
344
+ const authClaim = decodeJwtPayload(accessToken)?.["https://api.openai.com/auth"];
345
+ if (authClaim && typeof authClaim === "object" && !Array.isArray(authClaim)) {
346
+ const jwtAccountId = authClaim.chatgpt_account_id;
347
+ if (typeof jwtAccountId === "string" && jwtAccountId.trim() !== "") return jwtAccountId.trim();
348
+ }
349
+ }
350
+ async function writeJsonAtomic(filePath, data) {
351
+ let targetPath = filePath;
352
+ try {
353
+ targetPath = realpathSync(filePath);
354
+ } catch {}
355
+ const tempPath = join(dirname(targetPath), `.auth.json.tmp-${process.pid}-${randomUUID()}`);
356
+ const content = `${JSON.stringify(data, null, 2)}\n`;
357
+ await writeFile(tempPath, content, {
358
+ encoding: "utf8",
359
+ mode: 384
360
+ });
361
+ await chmod(tempPath, 384).catch(() => {});
362
+ await rename(tempPath, targetPath);
363
+ }
364
+ function createAuthManager(options) {
365
+ const { getCodexHome } = options;
366
+ const fetchFn = options.fetchImpl ?? globalThis.fetch;
367
+ const nowFn = options.now ?? Date.now;
368
+ let inflightRefresh;
369
+ let memoryFallback;
370
+ function readAuthState() {
371
+ const authPath = join(getCodexHome(), "auth.json");
372
+ if (process.platform !== "win32") try {
373
+ const stat = statSync(authPath);
374
+ if ((stat.mode & 256) === 0) {
375
+ if (memoryFallback?.authPath === authPath) return memoryFallback;
376
+ const octal = (stat.mode & 511).toString(8).padStart(4, "0");
377
+ throw new LlmError(`Codex 凭证文件属主不可读 (${authPath}, mode ${octal}),请执行 chmod 600 "${authPath}"`, "INVALID_CREDENTIAL");
378
+ }
379
+ if ((stat.mode & 63) !== 0) try {
380
+ chmodSync(realpathSync(authPath), 384);
381
+ } catch {}
382
+ } catch (error) {
383
+ if (error instanceof LlmError) throw error;
384
+ }
385
+ let rawText;
386
+ try {
387
+ rawText = readFileSync(authPath, "utf8");
388
+ } catch (error) {
389
+ if (memoryFallback?.authPath === authPath) return memoryFallback;
390
+ const code = error?.code;
391
+ if (code === "EACCES" || code === "EPERM") throw new LlmError(`Codex 凭证文件无读取权限 (${authPath}),请执行 chmod 600 "${authPath}"`, "INVALID_CREDENTIAL", { cause: error });
392
+ throw new LlmError(`无法读取 Codex 登录凭证 (${authPath}),请先在终端运行 codex login`, "MISSING_CREDENTIAL", { cause: error });
393
+ }
394
+ let parsed;
395
+ try {
396
+ parsed = JSON.parse(rawText);
397
+ } catch (error) {
398
+ if (memoryFallback?.authPath === authPath) return memoryFallback;
399
+ throw new LlmError(`Codex 凭证文件格式损坏 (${authPath})`, "INVALID_CREDENTIAL", { cause: error });
400
+ }
401
+ const accessToken = parsed?.tokens?.access_token;
402
+ if (typeof accessToken !== "string" || accessToken.trim() === "") {
403
+ if (memoryFallback?.authPath === authPath) return memoryFallback;
404
+ throw new LlmError(`Codex 未登录 (${authPath} 中无 access_token),请运行 codex login`, "MISSING_CREDENTIAL");
405
+ }
406
+ if (memoryFallback?.authPath === authPath && memoryFallback.accessToken !== accessToken && isTokenFresh(memoryFallback.accessToken)) return memoryFallback;
407
+ return {
408
+ authPath,
409
+ parsed,
410
+ accessToken
411
+ };
412
+ }
413
+ function isTokenFresh(accessToken) {
414
+ const exp = decodeJwtPayload(accessToken)?.exp;
415
+ if (typeof exp !== "number" || !Number.isFinite(exp)) return true;
416
+ return exp * 1e3 - nowFn() > TOKEN_REFRESH_LEAD_MS;
417
+ }
418
+ async function refreshToken(authPath, rawAuth) {
419
+ const refreshTok = rawAuth?.tokens?.refresh_token;
420
+ if (typeof refreshTok !== "string" || refreshTok.trim() === "") throw new LlmError("Codex 凭证已过期且缺少 refresh_token,请重新运行 codex login", "AUTH_FAILED");
421
+ const response = await fetchFn(CODEX_TOKEN_URL, {
422
+ method: "POST",
423
+ headers: buildCodexHeaders({
424
+ "Content-Type": "application/json",
425
+ Accept: "application/json"
426
+ }, options.getClientVersion?.()),
427
+ body: JSON.stringify({
428
+ client_id: CODEX_CLIENT_ID,
429
+ grant_type: "refresh_token",
430
+ refresh_token: refreshTok,
431
+ scope: "openid profile email"
432
+ }),
433
+ signal: AbortSignal.timeout(TOKEN_REFRESH_TIMEOUT_MS)
434
+ });
435
+ if (!response.ok) throw new LlmError(`Codex Token 刷新失败 (HTTP ${response.status}),请重新运行 codex login`, "AUTH_FAILED");
436
+ const fresh = await response.json();
437
+ if (typeof fresh?.access_token !== "string" || fresh.access_token.trim() === "") throw new LlmError("Codex Token 刷新响应缺少 access_token", "AUTH_FAILED");
438
+ const nextAuth = {
439
+ ...rawAuth,
440
+ tokens: {
441
+ ...rawAuth.tokens ?? {},
442
+ access_token: fresh.access_token,
443
+ ...typeof fresh.id_token === "string" && fresh.id_token !== "" ? { id_token: fresh.id_token } : {},
444
+ ...typeof fresh.refresh_token === "string" && fresh.refresh_token !== "" ? { refresh_token: fresh.refresh_token } : {}
445
+ },
446
+ last_refresh: new Date(nowFn()).toISOString()
447
+ };
448
+ memoryFallback = {
449
+ authPath,
450
+ parsed: nextAuth,
451
+ accessToken: fresh.access_token
452
+ };
453
+ try {
454
+ await writeJsonAtomic(authPath, nextAuth);
455
+ } catch (error) {
456
+ console.error(`[dsh-codex-subscription] 写回刷新后的 Codex auth.json 失败 (${authPath}): ${safeErrorMessage(error)}`);
457
+ }
458
+ return {
459
+ accessToken: fresh.access_token,
460
+ accountId: extractAccountId(nextAuth, fresh.access_token)
461
+ };
462
+ }
463
+ function toCredentials(state) {
464
+ return {
465
+ accessToken: state.accessToken,
466
+ accountId: extractAccountId(state.parsed, state.accessToken)
467
+ };
468
+ }
469
+ async function resolveCredentials() {
470
+ const current = readAuthState();
471
+ if (isTokenFresh(current.accessToken)) return toCredentials(current);
472
+ if (!inflightRefresh) inflightRefresh = (async () => {
473
+ const latest = readAuthState();
474
+ if (isTokenFresh(latest.accessToken)) return toCredentials(latest);
475
+ return refreshToken(latest.authPath, latest.parsed);
476
+ })().finally(() => {
477
+ inflightRefresh = void 0;
478
+ });
479
+ return inflightRefresh;
480
+ }
481
+ return {
482
+ resolveCredentials,
483
+ async resolveAccessToken() {
484
+ return (await resolveCredentials()).accessToken;
485
+ }
486
+ };
487
+ }
488
+ //#endregion
456
489
  //#region src/index.ts
457
490
  const name = "codex-subscription";
458
491
  const inject = ["llm"];
@@ -460,7 +493,10 @@ const STREAM_IDLE_TIMEOUT_MS = 3e5;
460
493
  const MAX_REQUEST_IMAGE_BYTES = 20971520;
461
494
  const REQUEST_IMAGE_PIXEL_BUDGET = 4194304;
462
495
  const REQUEST_IMAGE_MAX_BYTES = 1048576;
463
- const Config = z.object({ disabledModels: z.array(z.string()).default([...DEFAULT_DISABLED_MODELS]).description("在模型选择器中屏蔽的模型 slug 列表").volatile() });
496
+ const Config = z.object({
497
+ disabledModels: z.array(z.string()).default([...DEFAULT_DISABLED_MODELS]).description("在模型选择器中屏蔽的模型 slug 列表").volatile(),
498
+ contextWindows: z.dict(z.number()).default({}).description("按模型 slug 覆盖上下文窗口大小(tokens),未配置时默认使用 872000").volatile()
499
+ });
464
500
  function readVolatile(field, fallback) {
465
501
  const value = typeof field?.get === "function" ? field.get() : field;
466
502
  return value === void 0 || value === null ? fallback : value;
@@ -473,23 +509,34 @@ var CodexPiAiAdapter = class extends PiAiAdapter {
473
509
  }
474
510
  modelInfo(snapshot, provider, model) {
475
511
  const info = super.modelInfo(snapshot, provider, model);
476
- const defaultLevel = this.#getModelMetadata(model)?.defaultReasoningLevel;
477
- if (defaultLevel !== void 0 && info.reasoning !== void 0 && info.reasoning.efforts.some((e) => e.id === defaultLevel)) return {
512
+ const meta = this.#getModelMetadata(model);
513
+ if (meta === void 0) return info;
514
+ const defaultLevel = meta.defaultReasoningLevel;
515
+ const hasDefaultLevel = defaultLevel !== void 0 && info.reasoning !== void 0 && info.reasoning.efforts.some((e) => e.id === defaultLevel);
516
+ return {
478
517
  ...info,
479
- reasoning: {
518
+ context: {
519
+ ...info.context ?? {},
520
+ contextWindow: meta.contextWindow
521
+ },
522
+ ...hasDefaultLevel && info.reasoning !== void 0 ? { reasoning: {
480
523
  ...info.reasoning,
481
524
  defaultEffort: defaultLevel
482
- }
525
+ } } : {}
483
526
  };
484
- return info;
485
527
  }
486
528
  };
487
529
  function apply(ctx, config = {}) {
488
530
  const baseProvider = openaiCodexProvider();
489
531
  const baseModels = baseProvider.getModels();
532
+ const retryPolicy = resolveRetryPolicy(void 0, "codex-subscription retryPolicy");
490
533
  const getCodexHome = () => resolveCodexHome();
491
534
  const getDisabledModels = () => readVolatile(config.disabledModels, DEFAULT_DISABLED_MODELS);
492
- const authManager = createAuthManager({ getCodexHome });
535
+ const getContextWindows = () => readVolatile(config.contextWindows, {});
536
+ const authManager = createAuthManager({
537
+ getCodexHome,
538
+ getClientVersion: () => ensureSnapshot().catalog.clientVersion
539
+ });
493
540
  let fileSignature;
494
541
  let cachedRaw;
495
542
  let warnedMissingFile = false;
@@ -514,11 +561,13 @@ function apply(ctx, config = {}) {
514
561
  function wrapOptions(model, options = {}) {
515
562
  const { catalog } = ensureSnapshot();
516
563
  const modelMeta = catalog.metadataById.get(model?.id);
517
- const { temperature: _temperature, onPayload, ...rest } = options;
564
+ const { temperature: _temperature, sessionId, onPayload, ...rest } = options;
565
+ const normalizedSessionId = normalizeCodexSessionId(sessionId);
518
566
  return {
519
567
  ...rest,
568
+ ...normalizedSessionId !== void 0 ? { sessionId: normalizedSessionId } : {},
520
569
  transport: "sse",
521
- headers: buildCodexHeaders(rest.headers, catalog.clientVersion, rest.sessionId),
570
+ headers: buildCodexHeaders(rest.headers, catalog.clientVersion),
522
571
  async onPayload(payload, requestModel) {
523
572
  return sanitizeCodexPayload(await onPayload?.(payload, requestModel) ?? payload, modelMeta);
524
573
  }
@@ -549,6 +598,7 @@ function apply(ctx, config = {}) {
549
598
  function ensureSnapshot() {
550
599
  const cachePath = join(getCodexHome(), "models_cache.json");
551
600
  const disabled = getDisabledModels();
601
+ const contextWindows = getContextWindows();
552
602
  try {
553
603
  const stat = statSync(cachePath);
554
604
  const nextFileSig = `${cachePath}:${stat.mtimeMs}:${stat.size}`;
@@ -560,12 +610,16 @@ function apply(ctx, config = {}) {
560
610
  } catch (error) {
561
611
  if (!warnedMissingFile) {
562
612
  warnedMissingFile = true;
563
- console.error(`[dsh-codex-subscription] 读取 Codex models_cache.json 失败 (${cachePath}): ${error instanceof Error ? error.message : String(error)}`);
613
+ console.error(`[dsh-codex-subscription] 读取 Codex models_cache.json 失败 (${cachePath}): ${safeErrorMessage(error)}`);
564
614
  }
565
615
  }
566
- const nextSnapshotSig = JSON.stringify([fileSignature ?? "none", disabled]);
616
+ const nextSnapshotSig = JSON.stringify([
617
+ fileSignature ?? "none",
618
+ disabled,
619
+ contextWindows
620
+ ]);
567
621
  if (nextSnapshotSig !== snapshotSignature || currentProfilesMap === void 0) {
568
- currentCatalog = cachedRaw !== void 0 ? parseModelsCache(cachedRaw, baseModels, disabled) : {
622
+ currentCatalog = cachedRaw !== void 0 ? parseModelsCache(cachedRaw, baseModels, disabled, contextWindows) : {
569
623
  clientVersion: DEFAULT_CLIENT_VERSION,
570
624
  models: [],
571
625
  metadataById: new Map()
@@ -574,6 +628,7 @@ function apply(ctx, config = {}) {
574
628
  provider: PROVIDER,
575
629
  displayName: DISPLAY_NAME,
576
630
  piProvider,
631
+ retryPolicy,
577
632
  configuredMaxTokens: new Map(),
578
633
  modelErrors: new Map(),
579
634
  maxRequestImageBytes: MAX_REQUEST_IMAGE_BYTES,
@@ -647,18 +702,21 @@ const __test = Object.freeze({
647
702
  PROVIDER,
648
703
  DISPLAY_NAME,
649
704
  DEFAULT_DISABLED_MODELS,
705
+ DEFAULT_CONTEXT_WINDOW,
650
706
  CODEX_USAGE_URL,
651
707
  USAGE_ROUTE_PATH,
652
708
  DEFAULT_USAGE_TTL_MS,
653
709
  resolveCodexHome,
654
710
  decodeJwtPayload,
655
711
  extractAccountId,
712
+ safeErrorMessage,
656
713
  createAuthManager,
657
714
  buildThinkingLevelMap,
658
715
  parseModelsCache,
659
716
  sanitizeCodexPayload,
660
717
  buildCodexHeaders,
661
718
  buildCodexUserAgent,
719
+ normalizeCodexSessionId,
662
720
  parseUsagePayload,
663
721
  createUsageFetcher
664
722
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lyogvce/dsh-codex-subscription",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "复用本机 ~/.codex 订阅凭证与 models_cache.json 模型缓存,在 DSH 中通过 SSE 直连调用 Codex 模型。",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",