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.
@@ -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-DrnM3oWy.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-CEDww1hA.css">
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.fromWeb(upstream.body));
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: (catalog?.models ?? []).map((model) => openAiModel(account.provider, model)),
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))
@@ -122,8 +122,8 @@ export function grokProvider(options = {}) {
122
122
  signal,
123
123
  });
124
124
  if (!response.ok) {
125
- const raw = (await response.json().catch(() => ({})));
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
- return isRecord(parsed) ? parsed : {};
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
- ? "For this Free account, xAI provides only the reset time, not current usage or remaining allowance. Access may stop before the reset if the included allowance is exhausted."
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.0",
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
+ }