aisubs 0.3.0 → 0.3.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/CHANGELOG.md +19 -0
- package/README.md +36 -0
- package/dist/account-key.js +1 -1
- package/dist/auth.js +10 -6
- package/dist/compatibility.js +34 -40
- package/dist/dashboard/assets/index-BJDbjHnw.css +2 -0
- package/dist/dashboard/assets/index-DJBtdmoj.js +84 -0
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard.js +58 -3
- package/dist/http.js +129 -4
- package/dist/providers/chatgpt.js +27 -0
- package/dist/providers/copilot.js +20 -0
- package/dist/providers/grok.js +2 -2
- package/dist/realtime.js +2 -0
- package/dist/store.js +27 -1
- package/dist/types.d.ts +1 -0
- package/dist/usage.js +1 -1
- package/package.json +3 -1
- package/scripts/codex-catalog.mjs +203 -0
- package/dist/dashboard/assets/index-CEDww1hA.css +0 -2
- package/dist/dashboard/assets/index-DrnM3oWy.js +0 -84
|
@@ -6,8 +6,8 @@
|
|
|
6
6
|
<meta name="color-scheme" content="light dark" />
|
|
7
7
|
<meta name="theme-color" content="#181818" />
|
|
8
8
|
<title>AI Subs</title>
|
|
9
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
-
<link rel="stylesheet" crossorigin href="/assets/index-
|
|
9
|
+
<script type="module" crossorigin src="/assets/index-DJBtdmoj.js"></script>
|
|
10
|
+
<link rel="stylesheet" crossorigin href="/assets/index-BJDbjHnw.css">
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<div id="root"></div>
|
package/dist/dashboard.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import cors from "@fastify/cors";
|
|
2
2
|
import websocket from "@fastify/websocket";
|
|
3
3
|
import Fastify from "fastify";
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
4
5
|
import { randomBytes, timingSafeEqual } from "node:crypto";
|
|
5
|
-
import { readFile } from "node:fs/promises";
|
|
6
|
+
import { access, readFile, writeFile } from "node:fs/promises";
|
|
7
|
+
import { homedir } from "node:os";
|
|
6
8
|
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
7
9
|
import { fileURLToPath } from "node:url";
|
|
8
10
|
import { clientAbortSignal, handleSubscriptionAuthApi, routeSegments, sendWebResponse, } from "./http.js";
|
|
@@ -71,6 +73,42 @@ async function responseFailure(response) {
|
|
|
71
73
|
const message = isRecord(failure) ? stringValue(failure.message) : stringValue(failure);
|
|
72
74
|
return (message ?? `HTTP ${response.status}`).slice(0, 2_000);
|
|
73
75
|
}
|
|
76
|
+
function runCodexCatalog(env) {
|
|
77
|
+
const script = join(dirname(fileURLToPath(import.meta.url)), "..", "scripts", "codex-catalog.mjs");
|
|
78
|
+
return new Promise((resolvePromise, reject) => {
|
|
79
|
+
const child = spawn(process.execPath, [script], { env, stdio: ["ignore", "pipe", "pipe"] });
|
|
80
|
+
let output = "";
|
|
81
|
+
child.stdout.on("data", (chunk) => {
|
|
82
|
+
output += chunk.toString();
|
|
83
|
+
});
|
|
84
|
+
child.stderr.on("data", (chunk) => {
|
|
85
|
+
output += chunk.toString();
|
|
86
|
+
});
|
|
87
|
+
child.once("error", reject);
|
|
88
|
+
child.once("close", (code) => resolvePromise({ output: output.trim(), code: code ?? 1 }));
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
function removeRootSetting(config, pattern) {
|
|
92
|
+
const firstTable = config.search(/^\[/m);
|
|
93
|
+
const rootEnd = firstTable < 0 ? config.length : firstTable;
|
|
94
|
+
return `${config.slice(0, rootEnd).replace(pattern, "")}${config.slice(rootEnd)}`;
|
|
95
|
+
}
|
|
96
|
+
async function restoreOfficialCodexConfig() {
|
|
97
|
+
const path = process.env.CODEX_CONFIG ?? join(homedir(), ".codex", "config.toml");
|
|
98
|
+
const backup = `${path}.aisubs-backup`;
|
|
99
|
+
const config = await readFile(path, "utf8").catch(() => null);
|
|
100
|
+
if (!config) {
|
|
101
|
+
return "No existing Codex config found at ~/.codex/config.toml";
|
|
102
|
+
}
|
|
103
|
+
await access(backup).catch(() => writeFile(backup, config, { mode: 0o600 }));
|
|
104
|
+
let restored = removeRootSetting(config, /^model_catalog_json\s*=.*\n?/m);
|
|
105
|
+
restored = removeRootSetting(restored, /^model_provider\s*=\s*"aisubs-codex"\s*\n?/m);
|
|
106
|
+
restored = restored
|
|
107
|
+
.replace(/(?:^|\n)\[model_providers\.aisubs-codex\][\s\S]*?(?=\n\[|$)/, "")
|
|
108
|
+
.replace(/^AISUBS_API_KEY\s*=.*\n?/m, "");
|
|
109
|
+
await writeFile(path, restored, { mode: 0o600 });
|
|
110
|
+
return `Restored official Codex mode. Backup: ${backup}`;
|
|
111
|
+
}
|
|
74
112
|
export async function createSubscriptionAuthDashboardServer(options) {
|
|
75
113
|
const host = options.host ?? "127.0.0.1";
|
|
76
114
|
if (!["127.0.0.1", "::1", "localhost"].includes(host)) {
|
|
@@ -118,7 +156,7 @@ export async function createSubscriptionAuthDashboardServer(options) {
|
|
|
118
156
|
await reply.code(421).send({ error: "Invalid local host" });
|
|
119
157
|
return;
|
|
120
158
|
}
|
|
121
|
-
if (request.url.startsWith("/aisubs/")) {
|
|
159
|
+
if (request.url.startsWith("/aisubs/") || request.url.startsWith("/aisubs-codex/")) {
|
|
122
160
|
const startedAt = performance.now();
|
|
123
161
|
reply.raw.once("finish", () => {
|
|
124
162
|
const path = new URL(request.url, origin).pathname;
|
|
@@ -157,7 +195,7 @@ export async function createSubscriptionAuthDashboardServer(options) {
|
|
|
157
195
|
}
|
|
158
196
|
const bearerAuthenticated = requestApiKeys(request).some((value) => sameSecret(value, apiKey));
|
|
159
197
|
const cookieAuthenticated = sameSecret(cookie(request, "aisubs_session"), sessionToken);
|
|
160
|
-
const apiRoute = ["v1", "aisubs"].includes(routeSegments(url.pathname)[0] ?? "");
|
|
198
|
+
const apiRoute = ["v1", "aisubs", "aisubs-codex"].includes(routeSegments(url.pathname)[0] ?? "");
|
|
161
199
|
if (apiRoute && !bearerAuthenticated && !cookieAuthenticated) {
|
|
162
200
|
await reply.code(401).send({
|
|
163
201
|
error: {
|
|
@@ -192,6 +230,23 @@ export async function createSubscriptionAuthDashboardServer(options) {
|
|
|
192
230
|
await reply.send({ apiKey });
|
|
193
231
|
return;
|
|
194
232
|
}
|
|
233
|
+
if (request.method === "POST" && url.pathname === "/v1/codex/configure") {
|
|
234
|
+
const result = await runCodexCatalog({
|
|
235
|
+
...process.env,
|
|
236
|
+
AISUBS_API_KEY: apiKey,
|
|
237
|
+
AISUBS_URL: `http://${request.headers.host ?? `${urlHost(host)}:${options.port ?? 4319}`}`,
|
|
238
|
+
});
|
|
239
|
+
if (result.code !== 0) {
|
|
240
|
+
await reply.code(500).send({ error: result.output || "Codex configuration failed" });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
await reply.send({ ok: true, output: result.output });
|
|
244
|
+
return;
|
|
245
|
+
}
|
|
246
|
+
if (request.method === "POST" && url.pathname === "/v1/codex/restore-official") {
|
|
247
|
+
await reply.send({ ok: true, output: await restoreOfficialCodexConfig() });
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
195
250
|
if (request.method === "POST" && url.pathname === "/v1/api-key/regenerate") {
|
|
196
251
|
regeneratingApiKey ??= (options.regenerateApiKey
|
|
197
252
|
? options.regenerateApiKey()
|
package/dist/http.js
CHANGED
|
@@ -97,7 +97,7 @@ export async function sendWebResponse(reply, upstream) {
|
|
|
97
97
|
await reply.send();
|
|
98
98
|
return;
|
|
99
99
|
}
|
|
100
|
-
await reply.send(Readable.
|
|
100
|
+
await reply.send(Readable.from(upstream.body));
|
|
101
101
|
}
|
|
102
102
|
function jsonResponse(body, status = 200, headers = {}) {
|
|
103
103
|
return Response.json(body, {
|
|
@@ -132,16 +132,141 @@ function openAiModel(provider, model) {
|
|
|
132
132
|
},
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
|
+
function usableForCodex(model) {
|
|
136
|
+
return (model.endpoints ?? []).some((endpoint) => {
|
|
137
|
+
const normalized = endpoint.replace(/^\/?(?:v1\/)?/, "");
|
|
138
|
+
return (["responses", "chat/completions", "messages"].includes(normalized) ||
|
|
139
|
+
normalized.startsWith("models/"));
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
function codexResponsesBody(input, model) {
|
|
143
|
+
const body = { ...input, model };
|
|
144
|
+
if (Array.isArray(body.input)) {
|
|
145
|
+
body.input = body.input
|
|
146
|
+
.filter((item) => isRecord(item) &&
|
|
147
|
+
["message", "function_call", "function_call_output"].includes(String(item.type)))
|
|
148
|
+
.map((item) => {
|
|
149
|
+
if (item.type === "message") {
|
|
150
|
+
const content = Array.isArray(item.content)
|
|
151
|
+
? item.content
|
|
152
|
+
.filter(isRecord)
|
|
153
|
+
.map((part) => stringValue(part.text))
|
|
154
|
+
.filter((value) => Boolean(value))
|
|
155
|
+
.join("")
|
|
156
|
+
: item.content;
|
|
157
|
+
return { type: "message", role: stringValue(item.role) ?? "user", content };
|
|
158
|
+
}
|
|
159
|
+
return item;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
if (isRecord(body.reasoning) && body.reasoning.summary === "all_turns") {
|
|
163
|
+
// Codex can send `all_turns`, but Copilot's GPT-5 Responses endpoint only
|
|
164
|
+
// accepts `auto` (some model revisions also accept `current_turn`).
|
|
165
|
+
// `auto` is the common denominator across the connected model revisions.
|
|
166
|
+
body.reasoning = { ...body.reasoning, summary: "auto" };
|
|
167
|
+
}
|
|
168
|
+
if (!Array.isArray(body.tools) || body.tools.length === 0) {
|
|
169
|
+
// OpenAI Responses permits a default tool choice, but several upstream
|
|
170
|
+
// providers reject tool_choice unless an actual tools array is present.
|
|
171
|
+
delete body.tool_choice;
|
|
172
|
+
}
|
|
173
|
+
delete body.prompt_cache_retention;
|
|
174
|
+
delete body.include;
|
|
175
|
+
return body;
|
|
176
|
+
}
|
|
135
177
|
export async function handleSubscriptionAuthApi(auth, request, signal) {
|
|
136
178
|
const url = new URL(request.url, "http://aisubs.local");
|
|
137
179
|
const parts = routeSegments(url.pathname);
|
|
180
|
+
// Unified Codex-compatible router. Model ids use provider/model, while the
|
|
181
|
+
// account is selected from the first authenticated account exposing it.
|
|
182
|
+
if (parts[0] === "aisubs-codex" && parts[1] === "v1") {
|
|
183
|
+
if (request.method === "GET" && parts[2] === "models") {
|
|
184
|
+
const models = [];
|
|
185
|
+
const seen = new Set();
|
|
186
|
+
for (const provider of auth.listProviders()) {
|
|
187
|
+
if (provider.id === "chatgpt")
|
|
188
|
+
continue;
|
|
189
|
+
for (const account of await auth.listAccounts(provider.id)) {
|
|
190
|
+
const catalog = await auth.getModels(provider.id, account.accountKey).catch(() => null);
|
|
191
|
+
for (const model of catalog?.models ?? []) {
|
|
192
|
+
if (!usableForCodex(model))
|
|
193
|
+
continue;
|
|
194
|
+
const id = `${provider.id}/${model.id}`;
|
|
195
|
+
if (seen.has(id))
|
|
196
|
+
continue;
|
|
197
|
+
seen.add(id);
|
|
198
|
+
models.push({ ...openAiModel(provider.id, model), id });
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
return jsonResponse({ object: "list", data: models, models });
|
|
203
|
+
}
|
|
204
|
+
if (request.method === "POST" && parts[2] === "responses") {
|
|
205
|
+
const raw = jsonBody(request);
|
|
206
|
+
const input = isRecord(raw) ? raw : {};
|
|
207
|
+
const requested = stringValue(input.model);
|
|
208
|
+
if (!requested) {
|
|
209
|
+
return jsonResponse({ error: { message: "model must be provider/model", type: "invalid_request_error" } }, 400);
|
|
210
|
+
}
|
|
211
|
+
const slash = requested.indexOf("/");
|
|
212
|
+
if (slash <= 0 || slash === requested.length - 1) {
|
|
213
|
+
return jsonResponse({
|
|
214
|
+
error: {
|
|
215
|
+
message: `Model "${requested}" must include its provider, for example "copilot/${requested}". No provider fallback was performed.`,
|
|
216
|
+
type: "invalid_request_error",
|
|
217
|
+
code: "invalid_model_id",
|
|
218
|
+
},
|
|
219
|
+
}, 400);
|
|
220
|
+
}
|
|
221
|
+
const provider = requested.slice(0, slash);
|
|
222
|
+
const model = requested.slice(slash + 1);
|
|
223
|
+
if (!auth.listProviders().some((candidate) => candidate.id === provider)) {
|
|
224
|
+
return jsonResponse({
|
|
225
|
+
error: {
|
|
226
|
+
message: `Model not found: ${requested}`,
|
|
227
|
+
type: "invalid_request_error",
|
|
228
|
+
code: "model_not_found",
|
|
229
|
+
},
|
|
230
|
+
}, 404);
|
|
231
|
+
}
|
|
232
|
+
for (const account of await auth.listAccounts(provider)) {
|
|
233
|
+
const catalog = await auth.getModels(provider, account.accountKey).catch(() => null);
|
|
234
|
+
if (!catalog?.models.some((candidate) => candidate.id === model && usableForCodex(candidate)))
|
|
235
|
+
continue;
|
|
236
|
+
// Codex sends Responses-only history/metadata that subscription
|
|
237
|
+
// providers do not all understand. Keep the router's wire contract
|
|
238
|
+
// stable and pass each provider only portable input items.
|
|
239
|
+
const translated = codexResponsesBody(input, model);
|
|
240
|
+
const compatible = await proxyCompatible(auth, provider, account.accountKey, "responses", Buffer.from(JSON.stringify(translated)), requestHeaders(request), signal);
|
|
241
|
+
if (compatible)
|
|
242
|
+
return compatible;
|
|
243
|
+
return auth.proxy(provider, account.accountKey, "responses", {
|
|
244
|
+
method: "POST",
|
|
245
|
+
headers: requestHeaders(request),
|
|
246
|
+
body: JSON.stringify(translated),
|
|
247
|
+
signal,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return jsonResponse({
|
|
251
|
+
error: {
|
|
252
|
+
message: `Model not found: ${requested}`,
|
|
253
|
+
type: "invalid_request_error",
|
|
254
|
+
code: "model_not_found",
|
|
255
|
+
},
|
|
256
|
+
}, 404);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
138
259
|
const account = accountPath(parts);
|
|
139
260
|
if (account && request.method !== "OPTIONS") {
|
|
140
261
|
if (account.versioned && request.method === "GET" && account.path === "models") {
|
|
141
262
|
const catalog = await auth.getModels(account.provider, account.account);
|
|
263
|
+
const models = (catalog?.models ?? []).map((model) => openAiModel(account.provider, model));
|
|
142
264
|
return jsonResponse({
|
|
143
265
|
object: "list",
|
|
144
|
-
data:
|
|
266
|
+
data: models,
|
|
267
|
+
// Codex's custom-provider catalog reader expects `models`, while
|
|
268
|
+
// OpenAI-compatible clients expect `data`. Keep both shapes.
|
|
269
|
+
models,
|
|
145
270
|
});
|
|
146
271
|
}
|
|
147
272
|
if (account.versioned && request.method === "GET" && account.path.startsWith("models/")) {
|
|
@@ -170,7 +295,7 @@ export async function handleSubscriptionAuthApi(auth, request, signal) {
|
|
|
170
295
|
return auth.proxy(account.provider, account.account, path, {
|
|
171
296
|
method: request.method,
|
|
172
297
|
headers,
|
|
173
|
-
body: body.length ? body : undefined,
|
|
298
|
+
body: body.length ? new Uint8Array(body) : undefined,
|
|
174
299
|
signal,
|
|
175
300
|
});
|
|
176
301
|
}
|
|
@@ -336,7 +461,7 @@ export function createApiApp(options) {
|
|
|
336
461
|
const statusCode = isRecord(error) ? numberValue(error.statusCode) : undefined;
|
|
337
462
|
const status = statusCode && statusCode >= 400 ? statusCode : 400;
|
|
338
463
|
const code = isRecord(error) ? stringValue(error.code) : undefined;
|
|
339
|
-
const openAi = request.url.startsWith("/aisubs/");
|
|
464
|
+
const openAi = request.url.startsWith("/aisubs/") || request.url.startsWith("/aisubs-codex/");
|
|
340
465
|
await reply.code(status).send(openAi
|
|
341
466
|
? {
|
|
342
467
|
error: {
|
|
@@ -98,6 +98,32 @@ function normalizeModel(value) {
|
|
|
98
98
|
priority: numberValue(value.priority) ?? Number.MAX_SAFE_INTEGER,
|
|
99
99
|
};
|
|
100
100
|
}
|
|
101
|
+
async function normalizeChatGptRequest(request) {
|
|
102
|
+
if (request.method !== "POST" || !new URL(request.url).pathname.endsWith("/responses")) {
|
|
103
|
+
return request;
|
|
104
|
+
}
|
|
105
|
+
const raw = await request
|
|
106
|
+
.clone()
|
|
107
|
+
.json()
|
|
108
|
+
.catch(() => null);
|
|
109
|
+
if (!isRecord(raw))
|
|
110
|
+
return request;
|
|
111
|
+
const body = { ...raw };
|
|
112
|
+
delete body.prompt_cache_options;
|
|
113
|
+
delete body.prompt_cache_retention;
|
|
114
|
+
const stripBreakpoints = (value) => Array.isArray(value)
|
|
115
|
+
? value.map((item) => isRecord(item)
|
|
116
|
+
? Object.fromEntries(Object.entries(item).filter(([key]) => key !== "prompt_cache_breakpoint"))
|
|
117
|
+
: item)
|
|
118
|
+
: value;
|
|
119
|
+
body.input = stripBreakpoints(body.input);
|
|
120
|
+
body.tools = stripBreakpoints(body.tools);
|
|
121
|
+
return new Request(request, {
|
|
122
|
+
method: request.method,
|
|
123
|
+
body: JSON.stringify(body),
|
|
124
|
+
headers: { ...Object.fromEntries(request.headers), "content-type": "application/json" },
|
|
125
|
+
});
|
|
126
|
+
}
|
|
101
127
|
export function chatGptProvider(options = {}) {
|
|
102
128
|
const clientId = options.clientId ?? DEFAULT_CLIENT_ID;
|
|
103
129
|
const compatibilityVersion = options.compatibilityVersion ?? "0.144.2";
|
|
@@ -350,6 +376,7 @@ export function chatGptProvider(options = {}) {
|
|
|
350
376
|
throw new Error("ChatGPT refresh returned invalid JSON");
|
|
351
377
|
return credentialFromTokens(raw, credential);
|
|
352
378
|
},
|
|
379
|
+
normalizeRequest: normalizeChatGptRequest,
|
|
353
380
|
authorize(request, credential) {
|
|
354
381
|
requireAllowedHost(request, ["chatgpt.com"]);
|
|
355
382
|
const accountId = credential.account?.id;
|
|
@@ -383,6 +383,26 @@ export function copilotProvider(options = {}) {
|
|
|
383
383
|
const session = auto ? await routeAuto(raw, credential, request.signal) : null;
|
|
384
384
|
if (isResponses && raw.store === undefined)
|
|
385
385
|
raw.store = false;
|
|
386
|
+
if (isResponses && isRecord(raw.reasoning) && raw.reasoning.summary === "all_turns") {
|
|
387
|
+
// Codex's cross-turn value is not accepted by Copilot GPT-5 models.
|
|
388
|
+
raw.reasoning = { ...raw.reasoning, summary: "auto" };
|
|
389
|
+
}
|
|
390
|
+
if (isResponses && (!Array.isArray(raw.tools) || raw.tools.length === 0)) {
|
|
391
|
+
delete raw.tool_choice;
|
|
392
|
+
}
|
|
393
|
+
if (isResponses) {
|
|
394
|
+
delete raw.include;
|
|
395
|
+
delete raw.prompt_cache_retention;
|
|
396
|
+
if (Array.isArray(raw.input)) {
|
|
397
|
+
for (const item of raw.input) {
|
|
398
|
+
if (isRecord(item) &&
|
|
399
|
+
isRecord(item.reasoning) &&
|
|
400
|
+
item.reasoning.summary === "all_turns") {
|
|
401
|
+
item.reasoning = { ...item.reasoning, summary: "auto" };
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
}
|
|
386
406
|
if (isResponses && raw.store !== true && Array.isArray(raw.input)) {
|
|
387
407
|
raw.input = raw.input.flatMap((item) => {
|
|
388
408
|
if (!isRecord(item))
|
package/dist/providers/grok.js
CHANGED
|
@@ -122,8 +122,8 @@ export function grokProvider(options = {}) {
|
|
|
122
122
|
signal,
|
|
123
123
|
});
|
|
124
124
|
if (!response.ok) {
|
|
125
|
-
const raw =
|
|
126
|
-
throw new GrokTokenError(response.status, stringValue(raw.error));
|
|
125
|
+
const raw = await response.json().catch(() => null);
|
|
126
|
+
throw new GrokTokenError(response.status, isRecord(raw) ? stringValue(raw.error) : undefined);
|
|
127
127
|
}
|
|
128
128
|
return credentialFromTokens(await responseJson(response, "Grok token refresh"), credential);
|
|
129
129
|
},
|
package/dist/realtime.js
CHANGED
|
@@ -108,6 +108,8 @@ export function registerRealtimeProxy(app, auth, authenticate) {
|
|
|
108
108
|
headers: clientHeaders(request),
|
|
109
109
|
})
|
|
110
110
|
.then((authorized) => {
|
|
111
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
112
|
+
return;
|
|
111
113
|
const target = new URL(authorized.url);
|
|
112
114
|
target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
|
|
113
115
|
const protocols = request.headers["sec-websocket-protocol"]
|
package/dist/store.js
CHANGED
|
@@ -5,6 +5,24 @@ import { dirname, join, resolve } from "node:path";
|
|
|
5
5
|
import { abortableDelay, isRecord } from "./utils.js";
|
|
6
6
|
const LOCK_TIMEOUT_MS = 15_000;
|
|
7
7
|
const LOCK_STALE_MS = 120_000;
|
|
8
|
+
function isCredential(value) {
|
|
9
|
+
if (!isRecord(value) || typeof value.accessToken !== "string")
|
|
10
|
+
return false;
|
|
11
|
+
if (typeof value.expiresAt !== "number" || !Number.isFinite(value.expiresAt))
|
|
12
|
+
return false;
|
|
13
|
+
if (value.refreshToken != null && typeof value.refreshToken !== "string")
|
|
14
|
+
return false;
|
|
15
|
+
if (value.account != null &&
|
|
16
|
+
(!isRecord(value.account) ||
|
|
17
|
+
![value.account.id, value.account.label, value.account.email, value.account.plan].every((item) => item == null || typeof item === "string")))
|
|
18
|
+
return false;
|
|
19
|
+
return (value.metadata == null ||
|
|
20
|
+
(isRecord(value.metadata) &&
|
|
21
|
+
Object.values(value.metadata).every((item) => item == null ||
|
|
22
|
+
typeof item === "string" ||
|
|
23
|
+
typeof item === "number" ||
|
|
24
|
+
typeof item === "boolean")));
|
|
25
|
+
}
|
|
8
26
|
export function defaultAiSubsDataDir() {
|
|
9
27
|
const override = process.env.AISUBS_DATA_DIR?.trim();
|
|
10
28
|
return override ? resolve(override) : join(homedir(), ".aisubs");
|
|
@@ -61,7 +79,15 @@ export class FileApiKeyStore {
|
|
|
61
79
|
async function readEnvelope(file) {
|
|
62
80
|
try {
|
|
63
81
|
const parsed = JSON.parse(await readFile(file, "utf8"));
|
|
64
|
-
|
|
82
|
+
if (!isRecord(parsed))
|
|
83
|
+
throw new Error("Credential store must contain a JSON object");
|
|
84
|
+
const credentials = {};
|
|
85
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
86
|
+
if (!isCredential(value))
|
|
87
|
+
throw new Error(`Credential store entry ${key} is invalid`);
|
|
88
|
+
credentials[key] = value;
|
|
89
|
+
}
|
|
90
|
+
return credentials;
|
|
65
91
|
}
|
|
66
92
|
catch (error) {
|
|
67
93
|
if (error.code === "ENOENT")
|
package/dist/types.d.ts
CHANGED
|
@@ -143,6 +143,7 @@ export interface ProviderAdapter {
|
|
|
143
143
|
readonly proxyBaseUrl?: string | ((credential: OAuthCredential) => string | undefined);
|
|
144
144
|
/** Optional local OpenAI-compatible bridge for providers without a pass-through API. */
|
|
145
145
|
proxy?(request: Request, credential: OAuthCredential): Promise<Response>;
|
|
146
|
+
normalizeRequest?(request: Request): Request | Promise<Request>;
|
|
146
147
|
normalizeResponse?(request: Request, response: Response): Response | Promise<Response>;
|
|
147
148
|
startLogin(signal: AbortSignal, options?: Record<string, unknown>): Promise<ProviderLogin>;
|
|
148
149
|
refresh(credential: OAuthCredential, signal: AbortSignal): Promise<OAuthCredential>;
|
package/dist/usage.js
CHANGED
|
@@ -412,7 +412,7 @@ export function parseGrokUsage(raw, userRaw, settingsRaw) {
|
|
|
412
412
|
...(unified ? [{ label: "Usage pool", value: "Shared across Grok products" }] : []),
|
|
413
413
|
],
|
|
414
414
|
note: percentUsed == null
|
|
415
|
-
? "
|
|
415
|
+
? "xAI provides only the reset time for this account, not current usage or remaining allowance. Access may stop before the reset if the included allowance is exhausted."
|
|
416
416
|
: undefined,
|
|
417
417
|
};
|
|
418
418
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aisubs",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.2",
|
|
4
4
|
"description": "Connect AI provider accounts and use those subscriptions from any local tool or as api.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai",
|
|
@@ -38,6 +38,7 @@
|
|
|
38
38
|
"dashboard/public/aisubs-mark.svg",
|
|
39
39
|
"examples",
|
|
40
40
|
"public",
|
|
41
|
+
"scripts/codex-catalog.mjs",
|
|
41
42
|
"CHANGELOG.md",
|
|
42
43
|
"README.md"
|
|
43
44
|
],
|
|
@@ -84,6 +85,7 @@
|
|
|
84
85
|
},
|
|
85
86
|
"scripts": {
|
|
86
87
|
"dev": "node --run build && node scripts/dev.mjs",
|
|
88
|
+
"codex:catalog": "node scripts/codex-catalog.mjs",
|
|
87
89
|
"prepare": "node --run build",
|
|
88
90
|
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.json && tsc -p dashboard/tsconfig.json --noEmit && vite build --config dashboard/vite.config.ts",
|
|
89
91
|
"prepack": "node --run check",
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
3
|
+
import { homedir } from "node:os";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
|
|
6
|
+
const home = homedir();
|
|
7
|
+
const base = (process.env.AISUBS_URL ?? "http://127.0.0.1:4319").replace(/\/+$/, "");
|
|
8
|
+
const keyPath = join(home, ".aisubs", "api-key");
|
|
9
|
+
const key = (
|
|
10
|
+
process.env.AISUBS_API_KEY ?? (await readFile(keyPath, "utf8").catch(() => ""))
|
|
11
|
+
).trim();
|
|
12
|
+
const output = process.env.CODEX_CATALOG ?? join(home, ".codex", "aisubs-catalog.json");
|
|
13
|
+
const codexConfig = process.env.CODEX_CONFIG ?? join(home, ".codex", "config.toml");
|
|
14
|
+
const providers = (
|
|
15
|
+
process.env.AISUBS_PROVIDERS ?? "chatgpt,claude,copilot,grok,opencode-go,opencode-zen"
|
|
16
|
+
)
|
|
17
|
+
.split(",")
|
|
18
|
+
.map((value) => value.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
|
|
21
|
+
if (!key) throw new Error(`AISubs API key not found in ${keyPath}`);
|
|
22
|
+
|
|
23
|
+
const message = (error) => (error instanceof Error ? error.message : String(error));
|
|
24
|
+
|
|
25
|
+
const headers = { authorization: `Bearer ${key}`, accept: "application/json" };
|
|
26
|
+
async function get(path) {
|
|
27
|
+
const response = await fetch(`${base}${path}`, { headers });
|
|
28
|
+
const text = await response.text();
|
|
29
|
+
let body;
|
|
30
|
+
try {
|
|
31
|
+
body = JSON.parse(text);
|
|
32
|
+
} catch {
|
|
33
|
+
body = text;
|
|
34
|
+
}
|
|
35
|
+
if (!response.ok) {
|
|
36
|
+
const failure = body?.error;
|
|
37
|
+
const detail =
|
|
38
|
+
typeof body === "string"
|
|
39
|
+
? body.slice(0, 240)
|
|
40
|
+
: typeof failure === "string"
|
|
41
|
+
? failure
|
|
42
|
+
: (failure?.message ?? body?.message ?? response.status);
|
|
43
|
+
throw new Error(`${path}: ${detail}`);
|
|
44
|
+
}
|
|
45
|
+
return body;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
await get("/health");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
throw new Error(
|
|
52
|
+
`AISubs is not reachable at ${base}. Start it with "nub run dev" and run this command again. ${message(error)}`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const native = await readFile(join(home, ".codex", "models_cache.json"), "utf8")
|
|
57
|
+
.then(JSON.parse)
|
|
58
|
+
.catch(() => ({ models: [] }));
|
|
59
|
+
const template = native.models?.[0] ?? {
|
|
60
|
+
default_reasoning_level: "medium",
|
|
61
|
+
supported_reasoning_levels: ["low", "medium", "high"].map((effort) => ({ effort })),
|
|
62
|
+
shell_type: "shell_command",
|
|
63
|
+
visibility: "list",
|
|
64
|
+
supported_in_api: true,
|
|
65
|
+
};
|
|
66
|
+
const entries = new Map();
|
|
67
|
+
const failures = [];
|
|
68
|
+
const usable = (model) => {
|
|
69
|
+
const endpoints = model.capabilities?.endpoints ?? model.endpoints ?? [];
|
|
70
|
+
return endpoints.some((endpoint) => {
|
|
71
|
+
const normalized = String(endpoint).replace(/^\/?(?:v1\/)?/, "");
|
|
72
|
+
return (
|
|
73
|
+
["responses", "chat/completions", "messages"].includes(normalized) ||
|
|
74
|
+
normalized.startsWith("models/")
|
|
75
|
+
);
|
|
76
|
+
});
|
|
77
|
+
};
|
|
78
|
+
for (const provider of providers) {
|
|
79
|
+
let accounts;
|
|
80
|
+
try {
|
|
81
|
+
accounts = await get(`/v1/auth/${provider}/accounts`);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
failures.push(`${provider}: ${message(error)}`);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
for (const account of accounts.accounts ?? accounts) {
|
|
87
|
+
// The account-list API returns the route key as `accountKey`; `account`
|
|
88
|
+
// is the nested display/identity object.
|
|
89
|
+
const accountId = account.accountKey ?? account.account ?? account.name;
|
|
90
|
+
if (!accountId) continue;
|
|
91
|
+
let catalog;
|
|
92
|
+
try {
|
|
93
|
+
catalog = await get(`/aisubs/${provider}/${encodeURIComponent(accountId)}/v1/models`);
|
|
94
|
+
} catch (error) {
|
|
95
|
+
failures.push(`${provider}/${accountId}: ${message(error)}`);
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
for (const model of catalog.data ?? catalog.models ?? []) {
|
|
99
|
+
const id = model.id;
|
|
100
|
+
if (!id) continue;
|
|
101
|
+
if (!usable(model)) continue;
|
|
102
|
+
// GitHub currently advertises this legacy alias but rejects it at
|
|
103
|
+
// generation time (the backend asks for gpt-5-mini-2025-08-07).
|
|
104
|
+
// Omitting it prevents Codex from presenting a model that cannot run.
|
|
105
|
+
if (provider === "copilot" && id === "gpt-5-mini") continue;
|
|
106
|
+
// Keep OpenAI/ChatGPT's official catalog entries native. Re-emitting
|
|
107
|
+
// them as `chatgpt/<model>` makes Codex treat them as third-party IDs
|
|
108
|
+
// and reject them for ChatGPT-authenticated sessions.
|
|
109
|
+
if (provider === "chatgpt") continue;
|
|
110
|
+
const slug = `${provider}/${id}`;
|
|
111
|
+
if (entries.has(slug)) continue;
|
|
112
|
+
entries.set(slug, {
|
|
113
|
+
...template,
|
|
114
|
+
slug,
|
|
115
|
+
display_name: `${provider} / ${id}`,
|
|
116
|
+
description: `AISubs ${provider} account ${accountId}`,
|
|
117
|
+
visibility: "list",
|
|
118
|
+
supported_in_api: true,
|
|
119
|
+
priority: 10,
|
|
120
|
+
additional_speed_tiers: undefined,
|
|
121
|
+
service_tiers: undefined,
|
|
122
|
+
aisubs_provider: provider,
|
|
123
|
+
aisubs_account: accountId,
|
|
124
|
+
aisubs_model: id,
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const clean = [...entries.values()].map((entry) =>
|
|
131
|
+
Object.fromEntries(Object.entries(entry).filter(([, value]) => value !== undefined)),
|
|
132
|
+
);
|
|
133
|
+
if (!clean.length) {
|
|
134
|
+
const detail = failures.length ? `\n${failures.join("\n")}` : "";
|
|
135
|
+
throw new Error(
|
|
136
|
+
`No non-ChatGPT models were discovered; refusing to overwrite the existing catalog.${detail}`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
// Codex has one active provider per configuration. Keep this catalog scoped to
|
|
140
|
+
// AISubs models; native models are restored by the dashboard's Restore action.
|
|
141
|
+
// Mixing native IDs here makes Codex probe them through the AISubs provider.
|
|
142
|
+
await mkdir(dirname(output), { recursive: true });
|
|
143
|
+
await writeFile(output, `${JSON.stringify({ models: clean }, null, 2)}\n`, { mode: 0o600 });
|
|
144
|
+
await syncCodexConfig();
|
|
145
|
+
console.log(`Wrote ${clean.length} models to ${output}`);
|
|
146
|
+
if (failures.length) console.warn(`Some accounts were skipped:\n${failures.join("\n")}`);
|
|
147
|
+
|
|
148
|
+
function setRootSetting(config, name, value) {
|
|
149
|
+
const firstTable = config.search(/^\[/m);
|
|
150
|
+
const rootEnd = firstTable < 0 ? config.length : firstTable;
|
|
151
|
+
const root = config.slice(0, rootEnd);
|
|
152
|
+
const rest = config.slice(rootEnd);
|
|
153
|
+
const pattern = new RegExp(`^${name}\\s*=.*$`, "m");
|
|
154
|
+
if (pattern.test(root)) return `${root.replace(pattern, value)}${rest}`;
|
|
155
|
+
|
|
156
|
+
const updatedRoot = `${root.trimEnd()}${root.trim() ? "\n" : ""}${value}\n`;
|
|
157
|
+
return rest ? `${updatedRoot}\n${rest}` : updatedRoot;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function syncCodexConfig() {
|
|
161
|
+
await mkdir(dirname(codexConfig), { recursive: true });
|
|
162
|
+
let config = await readFile(codexConfig, "utf8").catch(() => "");
|
|
163
|
+
const previousKey = config.match(/^AISUBS_API_KEY\s*=\s*"([^"]+)"/m)?.[1];
|
|
164
|
+
const keyRotated = previousKey && previousKey !== key;
|
|
165
|
+
config = setRootSetting(
|
|
166
|
+
config,
|
|
167
|
+
"model_catalog_json",
|
|
168
|
+
`model_catalog_json = ${JSON.stringify(output)}`,
|
|
169
|
+
);
|
|
170
|
+
// Codex has one active model_provider per config. Native models are restored
|
|
171
|
+
// by switching back to the official provider/profile.
|
|
172
|
+
config = setRootSetting(config, "model_provider", 'model_provider = "aisubs-codex"');
|
|
173
|
+
const providerBlock = `[model_providers.aisubs-codex]\nname = "AISubs Codex Router"\nbase_url = "${base}/aisubs-codex/v1"\nwire_api = "responses"\nrequires_openai_auth = false\nenv_key = "AISUBS_API_KEY"\n`;
|
|
174
|
+
const providerPattern = /\[model_providers\.aisubs-codex\][\s\S]*?(?=\n\[|$)/;
|
|
175
|
+
config = providerPattern.test(config)
|
|
176
|
+
? config.replace(providerPattern, providerBlock.trimEnd())
|
|
177
|
+
: `${config.trimEnd()}\n\n${providerBlock}`;
|
|
178
|
+
|
|
179
|
+
const envLine = `AISUBS_API_KEY = ${JSON.stringify(key)}`;
|
|
180
|
+
if (/^\[shell_environment_policy\.set\]$/m.test(config)) {
|
|
181
|
+
const marker = "[shell_environment_policy.set]";
|
|
182
|
+
const start = config.indexOf(marker) + marker.length;
|
|
183
|
+
const next = config.indexOf("\n[", start);
|
|
184
|
+
const end = next < 0 ? config.length : next;
|
|
185
|
+
const section = config.slice(start, end);
|
|
186
|
+
config = /^AISUBS_API_KEY\s*=.*$/m.test(section)
|
|
187
|
+
? `${config.slice(0, start)}${section.replace(/^AISUBS_API_KEY\s*=.*$/m, envLine)}${config.slice(end)}`
|
|
188
|
+
: `${config.slice(0, end)}\n${envLine}${config.slice(end)}`;
|
|
189
|
+
} else {
|
|
190
|
+
config += `\n\n[shell_environment_policy.set]\n${envLine}\n`;
|
|
191
|
+
}
|
|
192
|
+
await writeFile(codexConfig, config, { mode: 0o600 });
|
|
193
|
+
console.log(`Updated ${codexConfig}`);
|
|
194
|
+
if (keyRotated) {
|
|
195
|
+
console.log(
|
|
196
|
+
`\nAISubs API key changed. Next steps:\n` +
|
|
197
|
+
`1. Restart the AISubs server (nub run dev).\n` +
|
|
198
|
+
`2. Restart Codex Desktop completely.\n` +
|
|
199
|
+
`3. Run this sync again if the model catalog is stale.\n` +
|
|
200
|
+
`4. Send a small test prompt before normal use.\n`,
|
|
201
|
+
);
|
|
202
|
+
}
|
|
203
|
+
}
|