@lyogvce/dsh-codex-subscription 0.0.0-stage → 0.2.0
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 +59 -2
- package/cordis.patch.yml +10 -0
- package/icon.svg +6 -0
- package/lib/client.js +426 -0
- package/lib/index.d.ts +150 -0
- package/lib/index.js +666 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +73 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,666 @@
|
|
|
1
|
+
import { readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { dirname, join, resolve } from "node:path";
|
|
3
|
+
import z from "@deepseek-ai/schemastery";
|
|
4
|
+
import { LlmError, resolveImageAttachmentAccess } from "@deepseek-ai/dsh-llm";
|
|
5
|
+
import { PiAiAdapter } from "@deepseek-ai/dsh-llm-pi-ai";
|
|
6
|
+
import { openaiCodexProvider } from "@earendil-works/pi-ai/providers/openai-codex";
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import { chmod, rename, writeFile } from "node:fs/promises";
|
|
9
|
+
import { arch, homedir, platform, release } from "node:os";
|
|
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
|
+
//#region src/catalog.ts
|
|
162
|
+
const PROVIDER = "openai-codex";
|
|
163
|
+
const DISPLAY_NAME = "Codex";
|
|
164
|
+
const DEFAULT_CLIENT_VERSION = "0.163.0";
|
|
165
|
+
const THINKING_LEVELS = [
|
|
166
|
+
"off",
|
|
167
|
+
"minimal",
|
|
168
|
+
"low",
|
|
169
|
+
"medium",
|
|
170
|
+
"high",
|
|
171
|
+
"xhigh",
|
|
172
|
+
"max"
|
|
173
|
+
];
|
|
174
|
+
const DEFAULT_DISABLED_MODELS = Object.freeze([
|
|
175
|
+
"gpt-6-sol",
|
|
176
|
+
"gpt-6-luna",
|
|
177
|
+
"gpt-5.6-sol",
|
|
178
|
+
"gpt-5.6-terra",
|
|
179
|
+
"gpt-5.6-luna"
|
|
180
|
+
]);
|
|
181
|
+
function buildThinkingLevelMap(supportedLevels) {
|
|
182
|
+
const efforts = new Set((Array.isArray(supportedLevels) ? supportedLevels : []).map((item) => {
|
|
183
|
+
if (typeof item === "string") return item;
|
|
184
|
+
if (item !== null && typeof item === "object" && "effort" in item) {
|
|
185
|
+
const effort = item.effort;
|
|
186
|
+
return typeof effort === "string" ? effort : "";
|
|
187
|
+
}
|
|
188
|
+
return "";
|
|
189
|
+
}).filter((value) => value.length > 0));
|
|
190
|
+
const map = {};
|
|
191
|
+
for (const level of THINKING_LEVELS) if (level === "off") map.off = efforts.has("none") ? "none" : null;
|
|
192
|
+
else if (efforts.has(level) || level === "max" && efforts.has("ultra")) map[level] = level;
|
|
193
|
+
else map[level] = null;
|
|
194
|
+
return map;
|
|
195
|
+
}
|
|
196
|
+
function normalizeDefaultReasoningLevel(rawLevel, thinkingLevelMap) {
|
|
197
|
+
if (typeof rawLevel !== "string" || rawLevel.trim() === "") return void 0;
|
|
198
|
+
const trimmed = rawLevel.trim();
|
|
199
|
+
const mapped = trimmed === "none" ? "off" : trimmed === "ultra" ? "max" : trimmed;
|
|
200
|
+
return thinkingLevelMap[mapped] ? mapped : void 0;
|
|
201
|
+
}
|
|
202
|
+
function isVerbosity(value) {
|
|
203
|
+
return value === "low" || value === "medium" || value === "high";
|
|
204
|
+
}
|
|
205
|
+
function parseModelsCache(rawCache, baseModels, disabledModels = []) {
|
|
206
|
+
const disabledSet = new Set((Array.isArray(disabledModels) ? disabledModels : []).filter((id) => typeof id === "string").map((id) => id.trim()).filter(Boolean));
|
|
207
|
+
const clientVersion = typeof rawCache?.client_version === "string" && rawCache.client_version.trim() !== "" ? rawCache.client_version.trim() : DEFAULT_CLIENT_VERSION;
|
|
208
|
+
const rawModels = Array.isArray(rawCache?.models) ? rawCache.models : [];
|
|
209
|
+
const templateModel = baseModels.find((model) => model.id !== "gpt-5.3-codex-spark") ?? baseModels[0];
|
|
210
|
+
const visibleEntries = rawModels.filter((entry) => Boolean(entry && typeof entry.slug === "string" && entry.slug.trim() !== "")).filter((entry) => entry.visibility === "list" && !disabledSet.has(entry.slug.trim())).sort((a, b) => {
|
|
211
|
+
return (typeof a.priority === "number" && Number.isFinite(a.priority) ? a.priority : 0) - (typeof b.priority === "number" && Number.isFinite(b.priority) ? b.priority : 0);
|
|
212
|
+
});
|
|
213
|
+
const models = [];
|
|
214
|
+
const metadataById = new Map();
|
|
215
|
+
const seen = new Set();
|
|
216
|
+
for (const entry of visibleEntries) {
|
|
217
|
+
const id = entry.slug.trim();
|
|
218
|
+
if (seen.has(id)) continue;
|
|
219
|
+
seen.add(id);
|
|
220
|
+
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)));
|
|
223
|
+
const inputs = Array.isArray(entry.input_modalities) ? entry.input_modalities.filter((m) => m === "text" || m === "image") : ["text", "image"];
|
|
224
|
+
const thinkingLevelMap = buildThinkingLevelMap(entry.supported_reasoning_levels);
|
|
225
|
+
const hasReasoning = Object.entries(thinkingLevelMap).some(([k, v]) => k !== "off" && v !== null);
|
|
226
|
+
const supportVerbosity = entry.support_verbosity !== false;
|
|
227
|
+
const defaultVerbosity = isVerbosity(entry.default_verbosity) ? entry.default_verbosity : "low";
|
|
228
|
+
const defaultReasoningLevel = normalizeDefaultReasoningLevel(entry.default_reasoning_level, thinkingLevelMap);
|
|
229
|
+
metadataById.set(id, {
|
|
230
|
+
supportVerbosity,
|
|
231
|
+
defaultVerbosity,
|
|
232
|
+
defaultReasoningLevel
|
|
233
|
+
});
|
|
234
|
+
models.push({
|
|
235
|
+
...base ?? {},
|
|
236
|
+
id,
|
|
237
|
+
name: typeof entry.display_name === "string" && entry.display_name.trim() !== "" ? entry.display_name.trim() : id,
|
|
238
|
+
...typeof entry.description === "string" && entry.description.trim() !== "" ? { description: entry.description.trim() } : {},
|
|
239
|
+
api: "openai-codex-responses",
|
|
240
|
+
provider: PROVIDER,
|
|
241
|
+
baseUrl: "https://chatgpt.com/backend-api",
|
|
242
|
+
input: inputs.length > 0 ? inputs : ["text"],
|
|
243
|
+
contextWindow,
|
|
244
|
+
maxTokens,
|
|
245
|
+
reasoning: hasReasoning,
|
|
246
|
+
thinkingLevelMap,
|
|
247
|
+
cost: {
|
|
248
|
+
input: 0,
|
|
249
|
+
output: 0,
|
|
250
|
+
cacheRead: 0,
|
|
251
|
+
cacheWrite: 0
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
return {
|
|
256
|
+
clientVersion,
|
|
257
|
+
models,
|
|
258
|
+
metadataById
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
//#endregion
|
|
262
|
+
//#region src/request.ts
|
|
263
|
+
const FORBIDDEN_PAYLOAD_FIELDS = Object.freeze([
|
|
264
|
+
"max_output_tokens",
|
|
265
|
+
"max_completion_tokens",
|
|
266
|
+
"temperature",
|
|
267
|
+
"top_p",
|
|
268
|
+
"previous_response_id",
|
|
269
|
+
"user",
|
|
270
|
+
"safety_identifier",
|
|
271
|
+
"prompt_cache_options"
|
|
272
|
+
]);
|
|
273
|
+
let cachedOsLabel;
|
|
274
|
+
function detectOsLabel() {
|
|
275
|
+
if (cachedOsLabel !== void 0) return cachedOsLabel;
|
|
276
|
+
const sys = platform();
|
|
277
|
+
if (sys === "darwin") try {
|
|
278
|
+
const parts = execFileSync("sw_vers", ["-productVersion"], {
|
|
279
|
+
encoding: "utf8",
|
|
280
|
+
timeout: 2e3
|
|
281
|
+
}).trim().split(".");
|
|
282
|
+
while (parts.length < 3) parts.push("0");
|
|
283
|
+
cachedOsLabel = `Mac OS ${parts.slice(0, 3).join(".")}`;
|
|
284
|
+
return cachedOsLabel;
|
|
285
|
+
} catch {
|
|
286
|
+
cachedOsLabel = "Mac OS";
|
|
287
|
+
return cachedOsLabel;
|
|
288
|
+
}
|
|
289
|
+
if (sys === "win32") {
|
|
290
|
+
cachedOsLabel = `Windows ${release()}`;
|
|
291
|
+
return cachedOsLabel;
|
|
292
|
+
}
|
|
293
|
+
cachedOsLabel = `Linux ${release()}`;
|
|
294
|
+
return cachedOsLabel;
|
|
295
|
+
}
|
|
296
|
+
function buildCodexUserAgent(version = DEFAULT_CLIENT_VERSION) {
|
|
297
|
+
const cpu = arch() === "arm64" ? "arm64" : arch() === "x64" ? "x86_64" : arch();
|
|
298
|
+
const term = platform() === "darwin" ? "Apple_Terminal/455" : platform() === "win32" ? "WindowsTerminal" : "xterm-256color";
|
|
299
|
+
return `codex_cli_rs/${version} (${detectOsLabel()}; ${cpu}) ${term}`;
|
|
300
|
+
}
|
|
301
|
+
function buildCodexHeaders(existingHeaders, clientVersion, sessionId) {
|
|
302
|
+
const headers = {};
|
|
303
|
+
for (const [key, value] of Object.entries(existingHeaders ?? {})) {
|
|
304
|
+
if (key.toLowerCase() === "user-agent") continue;
|
|
305
|
+
headers[key] = value;
|
|
306
|
+
}
|
|
307
|
+
headers.originator = "codex_cli_rs";
|
|
308
|
+
headers.version = clientVersion;
|
|
309
|
+
headers["User-Agent"] = buildCodexUserAgent(clientVersion);
|
|
310
|
+
if (typeof sessionId === "string" && sessionId.length > 0) headers["thread-id"] = sessionId;
|
|
311
|
+
return headers;
|
|
312
|
+
}
|
|
313
|
+
function sanitizeCodexPayload(payload, modelMeta) {
|
|
314
|
+
if (!payload || typeof payload !== "object") return payload;
|
|
315
|
+
const cleaned = { ...payload };
|
|
316
|
+
for (const field of FORBIDDEN_PAYLOAD_FIELDS) delete cleaned[field];
|
|
317
|
+
if (cleaned.service_tier !== "priority" && cleaned.service_tier !== "ultrafast") delete cleaned.service_tier;
|
|
318
|
+
const currentText = cleaned.text && typeof cleaned.text === "object" && !Array.isArray(cleaned.text) ? { ...cleaned.text } : void 0;
|
|
319
|
+
if (modelMeta?.supportVerbosity === false) {
|
|
320
|
+
if (currentText !== void 0) {
|
|
321
|
+
delete currentText.verbosity;
|
|
322
|
+
if (Object.keys(currentText).length === 0) delete cleaned.text;
|
|
323
|
+
else cleaned.text = currentText;
|
|
324
|
+
}
|
|
325
|
+
} else {
|
|
326
|
+
const verbosity = modelMeta?.defaultVerbosity ?? "low";
|
|
327
|
+
cleaned.text = {
|
|
328
|
+
...currentText ?? {},
|
|
329
|
+
verbosity
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
if (cleaned.reasoning === void 0 && modelMeta?.defaultReasoningLevel !== void 0 && modelMeta.defaultReasoningLevel !== "off") cleaned.reasoning = {
|
|
333
|
+
effort: modelMeta.defaultReasoningLevel,
|
|
334
|
+
summary: "auto"
|
|
335
|
+
};
|
|
336
|
+
if (cleaned.reasoning && typeof cleaned.reasoning === "object" && !Array.isArray(cleaned.reasoning)) {
|
|
337
|
+
const reasoningObj = cleaned.reasoning;
|
|
338
|
+
const effort = reasoningObj.effort === "ultra" ? "max" : reasoningObj.effort;
|
|
339
|
+
if (effort === "none") delete cleaned.include;
|
|
340
|
+
else {
|
|
341
|
+
cleaned.reasoning = {
|
|
342
|
+
...reasoningObj,
|
|
343
|
+
effort,
|
|
344
|
+
summary: reasoningObj.summary ?? "auto"
|
|
345
|
+
};
|
|
346
|
+
const include = Array.isArray(cleaned.include) ? [...cleaned.include] : [];
|
|
347
|
+
if (!include.includes("reasoning.encrypted_content")) include.push("reasoning.encrypted_content");
|
|
348
|
+
cleaned.include = include;
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
return cleaned;
|
|
352
|
+
}
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region src/usage.ts
|
|
355
|
+
const CODEX_USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
|
|
356
|
+
const USAGE_ROUTE_PATH = "/api/codex-subscription/usage";
|
|
357
|
+
const DEFAULT_USAGE_TTL_MS = 6e4;
|
|
358
|
+
const USAGE_FETCH_TIMEOUT_MS = 15e3;
|
|
359
|
+
function clampPercent(value) {
|
|
360
|
+
return Math.max(0, Math.min(100, Math.round(value)));
|
|
361
|
+
}
|
|
362
|
+
function parseWindow(raw, fetchedAtMs) {
|
|
363
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
364
|
+
const record = raw;
|
|
365
|
+
const usedRaw = record.used_percent;
|
|
366
|
+
if (typeof usedRaw !== "number" || !Number.isFinite(usedRaw)) return null;
|
|
367
|
+
const resetAtRaw = record.reset_at;
|
|
368
|
+
const resetAfterRaw = record.reset_after_seconds;
|
|
369
|
+
let resetAt = null;
|
|
370
|
+
if (typeof resetAtRaw === "number" && Number.isFinite(resetAtRaw) && resetAtRaw > 0) resetAt = Math.round(resetAtRaw);
|
|
371
|
+
else if (typeof resetAfterRaw === "number" && Number.isFinite(resetAfterRaw) && resetAfterRaw >= 0) resetAt = Math.floor(fetchedAtMs / 1e3) + Math.round(resetAfterRaw);
|
|
372
|
+
return {
|
|
373
|
+
usedPercent: clampPercent(usedRaw),
|
|
374
|
+
resetAt
|
|
375
|
+
};
|
|
376
|
+
}
|
|
377
|
+
function parseUsagePayload(raw, fetchedAt) {
|
|
378
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw)) return null;
|
|
379
|
+
const record = raw;
|
|
380
|
+
const rateLimit = record.rate_limit;
|
|
381
|
+
if (!rateLimit || typeof rateLimit !== "object" || Array.isArray(rateLimit)) return null;
|
|
382
|
+
const rateRecord = rateLimit;
|
|
383
|
+
const primary = parseWindow(rateRecord.primary_window, fetchedAt);
|
|
384
|
+
if (primary === null) return null;
|
|
385
|
+
const secondary = parseWindow(rateRecord.secondary_window, fetchedAt);
|
|
386
|
+
return {
|
|
387
|
+
planType: typeof record.plan_type === "string" && record.plan_type.trim() !== "" ? record.plan_type.trim() : "plus",
|
|
388
|
+
limitReached: rateRecord.limit_reached === true || rateRecord.allowed === false || primary.usedPercent >= 100 || secondary !== null && secondary.usedPercent >= 100,
|
|
389
|
+
primary,
|
|
390
|
+
secondary,
|
|
391
|
+
fetchedAt
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
function createUsageFetcher(options) {
|
|
395
|
+
const fetchFn = options.fetchImpl ?? globalThis.fetch;
|
|
396
|
+
const nowFn = options.now ?? Date.now;
|
|
397
|
+
const ttlMs = options.ttlMs ?? 6e4;
|
|
398
|
+
let cached;
|
|
399
|
+
let stale = false;
|
|
400
|
+
let inflight;
|
|
401
|
+
async function fetchFresh() {
|
|
402
|
+
try {
|
|
403
|
+
const creds = await options.resolveCredentials();
|
|
404
|
+
const headers = options.buildHeaders({
|
|
405
|
+
Authorization: `Bearer ${creds.accessToken}`,
|
|
406
|
+
Accept: "application/json",
|
|
407
|
+
...creds.accountId !== void 0 ? { "ChatGPT-Account-Id": creds.accountId } : {}
|
|
408
|
+
});
|
|
409
|
+
const response = await fetchFn(CODEX_USAGE_URL, {
|
|
410
|
+
method: "GET",
|
|
411
|
+
headers,
|
|
412
|
+
signal: AbortSignal.timeout(USAGE_FETCH_TIMEOUT_MS)
|
|
413
|
+
});
|
|
414
|
+
if (!response.ok) return {
|
|
415
|
+
ok: false,
|
|
416
|
+
error: `Codex 限额查询失败 (HTTP ${response.status})`,
|
|
417
|
+
...cached !== void 0 ? { quota: cached } : {}
|
|
418
|
+
};
|
|
419
|
+
const parsed = parseUsagePayload(await response.json(), nowFn());
|
|
420
|
+
if (parsed === null) return {
|
|
421
|
+
ok: false,
|
|
422
|
+
error: "Codex 限额响应格式无法解析",
|
|
423
|
+
...cached !== void 0 ? { quota: cached } : {}
|
|
424
|
+
};
|
|
425
|
+
cached = parsed;
|
|
426
|
+
stale = false;
|
|
427
|
+
return {
|
|
428
|
+
ok: true,
|
|
429
|
+
quota: parsed
|
|
430
|
+
};
|
|
431
|
+
} catch (error) {
|
|
432
|
+
return {
|
|
433
|
+
ok: false,
|
|
434
|
+
error: error instanceof Error ? error.message : String(error),
|
|
435
|
+
...cached !== void 0 ? { quota: cached } : {}
|
|
436
|
+
};
|
|
437
|
+
}
|
|
438
|
+
}
|
|
439
|
+
return {
|
|
440
|
+
invalidate() {
|
|
441
|
+
stale = true;
|
|
442
|
+
},
|
|
443
|
+
async getUsage(force = false) {
|
|
444
|
+
if (!force && !stale && cached !== void 0 && nowFn() - cached.fetchedAt < ttlMs) return {
|
|
445
|
+
ok: true,
|
|
446
|
+
quota: cached
|
|
447
|
+
};
|
|
448
|
+
if (!inflight) inflight = fetchFresh().finally(() => {
|
|
449
|
+
inflight = void 0;
|
|
450
|
+
});
|
|
451
|
+
return inflight;
|
|
452
|
+
}
|
|
453
|
+
};
|
|
454
|
+
}
|
|
455
|
+
//#endregion
|
|
456
|
+
//#region src/index.ts
|
|
457
|
+
const name = "codex-subscription";
|
|
458
|
+
const inject = ["llm"];
|
|
459
|
+
const STREAM_IDLE_TIMEOUT_MS = 3e5;
|
|
460
|
+
const MAX_REQUEST_IMAGE_BYTES = 20971520;
|
|
461
|
+
const REQUEST_IMAGE_PIXEL_BUDGET = 4194304;
|
|
462
|
+
const REQUEST_IMAGE_MAX_BYTES = 1048576;
|
|
463
|
+
const Config = z.object({ disabledModels: z.array(z.string()).default([...DEFAULT_DISABLED_MODELS]).description("在模型选择器中屏蔽的模型 slug 列表").volatile() });
|
|
464
|
+
function readVolatile(field, fallback) {
|
|
465
|
+
const value = typeof field?.get === "function" ? field.get() : field;
|
|
466
|
+
return value === void 0 || value === null ? fallback : value;
|
|
467
|
+
}
|
|
468
|
+
var CodexPiAiAdapter = class extends PiAiAdapter {
|
|
469
|
+
#getModelMetadata;
|
|
470
|
+
constructor(options, getModelMetadata) {
|
|
471
|
+
super(options);
|
|
472
|
+
this.#getModelMetadata = getModelMetadata;
|
|
473
|
+
}
|
|
474
|
+
modelInfo(snapshot, provider, model) {
|
|
475
|
+
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 {
|
|
478
|
+
...info,
|
|
479
|
+
reasoning: {
|
|
480
|
+
...info.reasoning,
|
|
481
|
+
defaultEffort: defaultLevel
|
|
482
|
+
}
|
|
483
|
+
};
|
|
484
|
+
return info;
|
|
485
|
+
}
|
|
486
|
+
};
|
|
487
|
+
function apply(ctx, config = {}) {
|
|
488
|
+
const baseProvider = openaiCodexProvider();
|
|
489
|
+
const baseModels = baseProvider.getModels();
|
|
490
|
+
const getCodexHome = () => resolveCodexHome();
|
|
491
|
+
const getDisabledModels = () => readVolatile(config.disabledModels, DEFAULT_DISABLED_MODELS);
|
|
492
|
+
const authManager = createAuthManager({ getCodexHome });
|
|
493
|
+
let fileSignature;
|
|
494
|
+
let cachedRaw;
|
|
495
|
+
let warnedMissingFile = false;
|
|
496
|
+
let snapshotSignature;
|
|
497
|
+
let currentCatalog = {
|
|
498
|
+
clientVersion: DEFAULT_CLIENT_VERSION,
|
|
499
|
+
models: [],
|
|
500
|
+
metadataById: new Map()
|
|
501
|
+
};
|
|
502
|
+
let currentProfilesMap;
|
|
503
|
+
const requestTokenAuth = Object.freeze({
|
|
504
|
+
name: "Codex CLI auth.json token",
|
|
505
|
+
async resolve({ credential }) {
|
|
506
|
+
const token = credential?.key;
|
|
507
|
+
if (typeof token !== "string" || token.length === 0) return void 0;
|
|
508
|
+
return {
|
|
509
|
+
auth: { apiKey: token },
|
|
510
|
+
source: "codex-auth-json"
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
});
|
|
514
|
+
function wrapOptions(model, options = {}) {
|
|
515
|
+
const { catalog } = ensureSnapshot();
|
|
516
|
+
const modelMeta = catalog.metadataById.get(model?.id);
|
|
517
|
+
const { temperature: _temperature, onPayload, ...rest } = options;
|
|
518
|
+
return {
|
|
519
|
+
...rest,
|
|
520
|
+
transport: "sse",
|
|
521
|
+
headers: buildCodexHeaders(rest.headers, catalog.clientVersion, rest.sessionId),
|
|
522
|
+
async onPayload(payload, requestModel) {
|
|
523
|
+
return sanitizeCodexPayload(await onPayload?.(payload, requestModel) ?? payload, modelMeta);
|
|
524
|
+
}
|
|
525
|
+
};
|
|
526
|
+
}
|
|
527
|
+
const usageFetcher = createUsageFetcher({
|
|
528
|
+
resolveCredentials: () => authManager.resolveCredentials(),
|
|
529
|
+
buildHeaders: (authHeaders) => buildCodexHeaders(authHeaders, ensureSnapshot().catalog.clientVersion)
|
|
530
|
+
});
|
|
531
|
+
const piProvider = Object.freeze({
|
|
532
|
+
...baseProvider,
|
|
533
|
+
id: PROVIDER,
|
|
534
|
+
name: DISPLAY_NAME,
|
|
535
|
+
auth: Object.freeze({
|
|
536
|
+
...baseProvider.auth,
|
|
537
|
+
apiKey: requestTokenAuth
|
|
538
|
+
}),
|
|
539
|
+
getModels: () => ensureSnapshot().catalog.models,
|
|
540
|
+
stream: (model, context, options) => {
|
|
541
|
+
usageFetcher.invalidate();
|
|
542
|
+
return baseProvider.stream(model, context, wrapOptions(model, options));
|
|
543
|
+
},
|
|
544
|
+
streamSimple: (model, context, options) => {
|
|
545
|
+
usageFetcher.invalidate();
|
|
546
|
+
return baseProvider.streamSimple(model, context, wrapOptions(model, options));
|
|
547
|
+
}
|
|
548
|
+
});
|
|
549
|
+
function ensureSnapshot() {
|
|
550
|
+
const cachePath = join(getCodexHome(), "models_cache.json");
|
|
551
|
+
const disabled = getDisabledModels();
|
|
552
|
+
try {
|
|
553
|
+
const stat = statSync(cachePath);
|
|
554
|
+
const nextFileSig = `${cachePath}:${stat.mtimeMs}:${stat.size}`;
|
|
555
|
+
if (nextFileSig !== fileSignature) {
|
|
556
|
+
cachedRaw = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
557
|
+
fileSignature = nextFileSig;
|
|
558
|
+
warnedMissingFile = false;
|
|
559
|
+
}
|
|
560
|
+
} catch (error) {
|
|
561
|
+
if (!warnedMissingFile) {
|
|
562
|
+
warnedMissingFile = true;
|
|
563
|
+
console.error(`[dsh-codex-subscription] 读取 Codex models_cache.json 失败 (${cachePath}): ${error instanceof Error ? error.message : String(error)}`);
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
const nextSnapshotSig = JSON.stringify([fileSignature ?? "none", disabled]);
|
|
567
|
+
if (nextSnapshotSig !== snapshotSignature || currentProfilesMap === void 0) {
|
|
568
|
+
currentCatalog = cachedRaw !== void 0 ? parseModelsCache(cachedRaw, baseModels, disabled) : {
|
|
569
|
+
clientVersion: DEFAULT_CLIENT_VERSION,
|
|
570
|
+
models: [],
|
|
571
|
+
metadataById: new Map()
|
|
572
|
+
};
|
|
573
|
+
currentProfilesMap = new Map([[PROVIDER, Object.freeze({
|
|
574
|
+
provider: PROVIDER,
|
|
575
|
+
displayName: DISPLAY_NAME,
|
|
576
|
+
piProvider,
|
|
577
|
+
configuredMaxTokens: new Map(),
|
|
578
|
+
modelErrors: new Map(),
|
|
579
|
+
maxRequestImageBytes: MAX_REQUEST_IMAGE_BYTES,
|
|
580
|
+
requestImagePixelBudget: REQUEST_IMAGE_PIXEL_BUDGET,
|
|
581
|
+
requestImageMaxBytes: REQUEST_IMAGE_MAX_BYTES,
|
|
582
|
+
streamIdleTimeoutMs: STREAM_IDLE_TIMEOUT_MS,
|
|
583
|
+
cacheRetention: "short",
|
|
584
|
+
transport: "sse"
|
|
585
|
+
})]]);
|
|
586
|
+
snapshotSignature = nextSnapshotSig;
|
|
587
|
+
}
|
|
588
|
+
return {
|
|
589
|
+
catalog: currentCatalog,
|
|
590
|
+
profiles: currentProfilesMap
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
const adapter = new CodexPiAiAdapter({
|
|
594
|
+
profiles: () => ensureSnapshot().profiles,
|
|
595
|
+
resolveApiKey: (_provider, _profile) => authManager.resolveAccessToken(),
|
|
596
|
+
auth: Object.freeze({
|
|
597
|
+
credentials: {
|
|
598
|
+
async read() {},
|
|
599
|
+
async list() {
|
|
600
|
+
return [];
|
|
601
|
+
},
|
|
602
|
+
async modify() {},
|
|
603
|
+
async delete() {}
|
|
604
|
+
},
|
|
605
|
+
authContext: {
|
|
606
|
+
async env() {},
|
|
607
|
+
async fileExists() {
|
|
608
|
+
return false;
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
}),
|
|
612
|
+
resolveAttachments: () => ctx.get?.("attachments"),
|
|
613
|
+
resolveImageAccess: (attachments, ref) => resolveImageAttachmentAccess(attachments, (hostPath) => (ctx.get?.("fs"))?.processPathFromHostPath(hostPath), ref),
|
|
614
|
+
onReplayDegrade: (provider, model, reason) => {
|
|
615
|
+
console.error(`[dsh-codex-subscription] replay state for "${provider}/${model}" degraded (${reason})`);
|
|
616
|
+
}
|
|
617
|
+
}, (modelId) => ensureSnapshot().catalog.metadataById.get(modelId));
|
|
618
|
+
ctx.llm.registerAdapter([PROVIDER], adapter);
|
|
619
|
+
const mountUsageRoute = (connCtx) => {
|
|
620
|
+
const connection = connCtx.get?.("connection");
|
|
621
|
+
const registerFetch = connection?.fetch?.register;
|
|
622
|
+
if (typeof registerFetch !== "function") return;
|
|
623
|
+
const registerRoute = () => registerFetch.call(connection?.fetch, {
|
|
624
|
+
path: USAGE_ROUTE_PATH,
|
|
625
|
+
methods: ["GET"],
|
|
626
|
+
requestBody: "buffered",
|
|
627
|
+
fetch: async (request) => {
|
|
628
|
+
const force = new URL(request.url, "http://localhost").searchParams.get("force") === "1";
|
|
629
|
+
const result = await usageFetcher.getUsage(force);
|
|
630
|
+
return new Response(JSON.stringify(result), {
|
|
631
|
+
status: 200,
|
|
632
|
+
headers: { "content-type": "application/json" }
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
});
|
|
636
|
+
if (typeof connCtx.effect === "function") connCtx.effect(() => registerRoute(), "codex-subscription: usage route");
|
|
637
|
+
else registerRoute();
|
|
638
|
+
};
|
|
639
|
+
if (typeof ctx.inject === "function") ctx.inject(["connection"], mountUsageRoute);
|
|
640
|
+
else mountUsageRoute(ctx);
|
|
641
|
+
ctx.on?.("loader/volatile-update", () => {
|
|
642
|
+
ensureSnapshot();
|
|
643
|
+
ctx.llm.emitAdaptersUpdated?.();
|
|
644
|
+
});
|
|
645
|
+
}
|
|
646
|
+
const __test = Object.freeze({
|
|
647
|
+
PROVIDER,
|
|
648
|
+
DISPLAY_NAME,
|
|
649
|
+
DEFAULT_DISABLED_MODELS,
|
|
650
|
+
CODEX_USAGE_URL,
|
|
651
|
+
USAGE_ROUTE_PATH,
|
|
652
|
+
DEFAULT_USAGE_TTL_MS,
|
|
653
|
+
resolveCodexHome,
|
|
654
|
+
decodeJwtPayload,
|
|
655
|
+
extractAccountId,
|
|
656
|
+
createAuthManager,
|
|
657
|
+
buildThinkingLevelMap,
|
|
658
|
+
parseModelsCache,
|
|
659
|
+
sanitizeCodexPayload,
|
|
660
|
+
buildCodexHeaders,
|
|
661
|
+
buildCodexUserAgent,
|
|
662
|
+
parseUsagePayload,
|
|
663
|
+
createUsageFetcher
|
|
664
|
+
});
|
|
665
|
+
//#endregion
|
|
666
|
+
export { Config, __test, apply, inject, name };
|
package/locale/en.json
ADDED