aisubs 0.2.0 → 0.3.1
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 +29 -0
- package/README.md +177 -46
- package/dist/account-key.js +1 -1
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +21 -4
- package/dist/compatibility.d.ts +14 -0
- package/dist/compatibility.js +1515 -0
- 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.d.ts +4 -2
- package/dist/dashboard.js +209 -88
- package/dist/http.d.ts +9 -6
- package/dist/http.js +374 -278
- package/dist/providers/chatgpt.js +2 -0
- package/dist/providers/claude.js +2 -0
- package/dist/providers/copilot.js +20 -2
- package/dist/providers/grok.js +2 -2
- package/dist/realtime.d.ts +6 -0
- package/dist/realtime.js +165 -0
- package/dist/store.js +27 -1
- package/dist/usage.js +1 -1
- package/examples/direct.mjs +31 -8
- package/examples/server.mjs +11 -1
- package/package.json +11 -1
- package/public/aisubs-dashboard.png +0 -0
- package/scripts/codex-catalog.mjs +203 -0
- package/dist/dashboard/assets/index-DYe3pr2a.css +0 -2
- package/dist/dashboard/assets/index-V-AoOW2F.js +0 -83
|
@@ -92,6 +92,8 @@ function normalizeModel(value) {
|
|
|
92
92
|
maxOutputTokens: numberValue(value.max_output_tokens),
|
|
93
93
|
reasoningEfforts: levels.length ? levels : stringArray(value.supported_reasoning_efforts),
|
|
94
94
|
inputModalities: stringArray(value.input_modalities),
|
|
95
|
+
endpoints: ["responses"],
|
|
96
|
+
supportsToolCall: value.supports_tool_calls === false || value.supports_tools === false ? false : true,
|
|
95
97
|
available: visibility !== "hide" && value.supported_in_api !== false,
|
|
96
98
|
priority: numberValue(value.priority) ?? Number.MAX_SAFE_INTEGER,
|
|
97
99
|
};
|
package/dist/providers/claude.js
CHANGED
|
@@ -273,7 +273,9 @@ export function claudeProvider(options = {}) {
|
|
|
273
273
|
description: stringValue(value.description),
|
|
274
274
|
contextWindow: numberValue(value.context_window),
|
|
275
275
|
maxOutputTokens: numberValue(value.max_output_tokens),
|
|
276
|
+
inputModalities: ["text", "image", "document"],
|
|
276
277
|
endpoints: ["messages"],
|
|
278
|
+
supportsToolCall: true,
|
|
277
279
|
available: true,
|
|
278
280
|
selectable: true,
|
|
279
281
|
},
|
|
@@ -122,8 +122,6 @@ function normalizeModel(value) {
|
|
|
122
122
|
const capabilities = isRecord(value.capabilities) ? value.capabilities : {};
|
|
123
123
|
const limits = isRecord(capabilities.limits) ? capabilities.limits : {};
|
|
124
124
|
const supports = isRecord(capabilities.supports) ? capabilities.supports : {};
|
|
125
|
-
if (supports.tool_calls === false)
|
|
126
|
-
return null;
|
|
127
125
|
const modalities = ["text"];
|
|
128
126
|
if (supports.vision === true || isRecord(limits.vision))
|
|
129
127
|
modalities.push("image");
|
|
@@ -385,6 +383,26 @@ export function copilotProvider(options = {}) {
|
|
|
385
383
|
const session = auto ? await routeAuto(raw, credential, request.signal) : null;
|
|
386
384
|
if (isResponses && raw.store === undefined)
|
|
387
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
|
+
}
|
|
388
406
|
if (isResponses && raw.store !== true && Array.isArray(raw.input)) {
|
|
389
407
|
raw.input = raw.input.flatMap((item) => {
|
|
390
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
|
},
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyRequest } from "fastify";
|
|
2
|
+
import type { SubscriptionAuth } from "./auth.js";
|
|
3
|
+
type RealtimeAuth = (request: FastifyRequest) => boolean | Promise<boolean>;
|
|
4
|
+
/** Register a native Realtime WebSocket tunnel for providers that expose one. */
|
|
5
|
+
export declare function registerRealtimeProxy(app: FastifyInstance, auth: SubscriptionAuth, authenticate: RealtimeAuth): void;
|
|
6
|
+
export {};
|
package/dist/realtime.js
ADDED
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { errorMessage } from "./utils.js";
|
|
3
|
+
function rawDataBytes(data) {
|
|
4
|
+
if (Array.isArray(data))
|
|
5
|
+
return data.reduce((total, item) => total + item.byteLength, 0);
|
|
6
|
+
return data.byteLength;
|
|
7
|
+
}
|
|
8
|
+
function closeSocket(socket, code, reason) {
|
|
9
|
+
if (code >= 1000 && code <= 4999 && ![1004, 1005, 1006, 1015].includes(code)) {
|
|
10
|
+
socket.close(code, reason);
|
|
11
|
+
}
|
|
12
|
+
else
|
|
13
|
+
socket.close();
|
|
14
|
+
}
|
|
15
|
+
function upstreamHeaders(request) {
|
|
16
|
+
const headers = {};
|
|
17
|
+
request.headers.forEach((value, name) => {
|
|
18
|
+
if (![
|
|
19
|
+
"connection",
|
|
20
|
+
"content-length",
|
|
21
|
+
"host",
|
|
22
|
+
"sec-websocket-extensions",
|
|
23
|
+
"sec-websocket-key",
|
|
24
|
+
"sec-websocket-protocol",
|
|
25
|
+
"sec-websocket-version",
|
|
26
|
+
"upgrade",
|
|
27
|
+
].includes(name)) {
|
|
28
|
+
headers[name] = value;
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
return headers;
|
|
32
|
+
}
|
|
33
|
+
function clientHeaders(request) {
|
|
34
|
+
const local = new Set([
|
|
35
|
+
"authorization",
|
|
36
|
+
"connection",
|
|
37
|
+
"cookie",
|
|
38
|
+
"host",
|
|
39
|
+
"origin",
|
|
40
|
+
"proxy-authorization",
|
|
41
|
+
"sec-websocket-extensions",
|
|
42
|
+
"sec-websocket-key",
|
|
43
|
+
"sec-websocket-protocol",
|
|
44
|
+
"sec-websocket-version",
|
|
45
|
+
"upgrade",
|
|
46
|
+
"x-api-key",
|
|
47
|
+
"x-goog-api-key",
|
|
48
|
+
]);
|
|
49
|
+
return Object.fromEntries(Object.entries(request.headers).flatMap(([name, value]) => value == null || local.has(name)
|
|
50
|
+
? []
|
|
51
|
+
: [[name, Array.isArray(value) ? value.join(", ") : String(value)]]));
|
|
52
|
+
}
|
|
53
|
+
/** Register a native Realtime WebSocket tunnel for providers that expose one. */
|
|
54
|
+
export function registerRealtimeProxy(app, auth, authenticate) {
|
|
55
|
+
app.route({
|
|
56
|
+
method: "GET",
|
|
57
|
+
url: "/aisubs/:provider/:account/v1/realtime",
|
|
58
|
+
async preValidation(request, reply) {
|
|
59
|
+
if (!(await authenticate(request))) {
|
|
60
|
+
await reply.code(401).send({
|
|
61
|
+
error: {
|
|
62
|
+
message: "Unauthorized",
|
|
63
|
+
type: "authentication_error",
|
|
64
|
+
code: "invalid_api_key",
|
|
65
|
+
},
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
},
|
|
69
|
+
async handler(_request, reply) {
|
|
70
|
+
await reply.code(426).send({
|
|
71
|
+
error: {
|
|
72
|
+
message: "Use a WebSocket connection for the Realtime endpoint",
|
|
73
|
+
type: "invalid_request_error",
|
|
74
|
+
code: "websocket_required",
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
},
|
|
78
|
+
wsHandler(socket, request) {
|
|
79
|
+
const params = request.params;
|
|
80
|
+
const url = new URL(request.url, "http://aisubs.local");
|
|
81
|
+
url.searchParams.delete("key");
|
|
82
|
+
const search = url.search;
|
|
83
|
+
const pending = [];
|
|
84
|
+
let pendingBytes = 0;
|
|
85
|
+
let upstream;
|
|
86
|
+
socket.on("message", (data, binary) => {
|
|
87
|
+
if (upstream?.readyState === WebSocket.OPEN) {
|
|
88
|
+
upstream.send(data, { binary });
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
const bytes = rawDataBytes(data);
|
|
92
|
+
pendingBytes += bytes;
|
|
93
|
+
if (pending.length >= 100 || pendingBytes > 1024 * 1024) {
|
|
94
|
+
socket.close(1009, "Realtime startup queue exceeded");
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
pending.push({ data, binary });
|
|
98
|
+
});
|
|
99
|
+
socket.once("close", (code, reason) => {
|
|
100
|
+
if (upstream?.readyState === WebSocket.OPEN)
|
|
101
|
+
closeSocket(upstream, code, reason);
|
|
102
|
+
else
|
|
103
|
+
upstream?.terminate();
|
|
104
|
+
});
|
|
105
|
+
void auth
|
|
106
|
+
.authorizeProxyRequest(params.provider, params.account, `realtime${search}`, {
|
|
107
|
+
method: "GET",
|
|
108
|
+
headers: clientHeaders(request),
|
|
109
|
+
})
|
|
110
|
+
.then((authorized) => {
|
|
111
|
+
if (socket.readyState !== WebSocket.OPEN)
|
|
112
|
+
return;
|
|
113
|
+
const target = new URL(authorized.url);
|
|
114
|
+
target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
|
|
115
|
+
const protocols = request.headers["sec-websocket-protocol"]
|
|
116
|
+
?.split(",")
|
|
117
|
+
.map((value) => value.trim())
|
|
118
|
+
.filter(Boolean);
|
|
119
|
+
upstream = protocols?.length
|
|
120
|
+
? new WebSocket(target, protocols, { headers: upstreamHeaders(authorized) })
|
|
121
|
+
: new WebSocket(target, { headers: upstreamHeaders(authorized) });
|
|
122
|
+
upstream.on("open", () => {
|
|
123
|
+
for (const item of pending)
|
|
124
|
+
upstream.send(item.data, { binary: item.binary });
|
|
125
|
+
pending.length = 0;
|
|
126
|
+
pendingBytes = 0;
|
|
127
|
+
});
|
|
128
|
+
upstream.on("message", (data, binary) => {
|
|
129
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
130
|
+
socket.send(data, { binary });
|
|
131
|
+
});
|
|
132
|
+
upstream.on("close", (code, reason) => {
|
|
133
|
+
if (socket.readyState === WebSocket.OPEN)
|
|
134
|
+
closeSocket(socket, code, reason);
|
|
135
|
+
});
|
|
136
|
+
upstream.on("error", (error) => {
|
|
137
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
138
|
+
socket.send(JSON.stringify({
|
|
139
|
+
type: "error",
|
|
140
|
+
error: {
|
|
141
|
+
type: "provider_error",
|
|
142
|
+
code: "realtime_connection_failed",
|
|
143
|
+
message: errorMessage(error),
|
|
144
|
+
},
|
|
145
|
+
}));
|
|
146
|
+
socket.close(1011, "Provider Realtime connection failed");
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
})
|
|
150
|
+
.catch((error) => {
|
|
151
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
152
|
+
socket.send(JSON.stringify({
|
|
153
|
+
type: "error",
|
|
154
|
+
error: {
|
|
155
|
+
type: "provider_error",
|
|
156
|
+
code: "realtime_connection_failed",
|
|
157
|
+
message: errorMessage(error),
|
|
158
|
+
},
|
|
159
|
+
}));
|
|
160
|
+
socket.close(1011, "Provider Realtime connection failed");
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
}
|
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/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/examples/direct.mjs
CHANGED
|
@@ -3,25 +3,49 @@ import { join } from "node:path";
|
|
|
3
3
|
import {
|
|
4
4
|
FileCredentialStore,
|
|
5
5
|
chatGptProvider,
|
|
6
|
+
claudeProvider,
|
|
6
7
|
copilotProvider,
|
|
7
8
|
createSubscriptionAuth,
|
|
8
9
|
grokProvider,
|
|
10
|
+
openCodeGoProvider,
|
|
11
|
+
openCodeZenProvider,
|
|
9
12
|
} from "aisubs";
|
|
10
13
|
|
|
11
14
|
const provider = process.argv[2] ?? "chatgpt";
|
|
12
15
|
const accountKey = process.argv[3] ?? "default";
|
|
13
|
-
|
|
14
|
-
|
|
16
|
+
const providers = [
|
|
17
|
+
chatGptProvider(),
|
|
18
|
+
claudeProvider(),
|
|
19
|
+
copilotProvider(),
|
|
20
|
+
grokProvider(),
|
|
21
|
+
openCodeGoProvider(),
|
|
22
|
+
openCodeZenProvider(),
|
|
23
|
+
];
|
|
24
|
+
if (!providers.some((candidate) => candidate.id === provider)) {
|
|
25
|
+
throw new Error(`Use one of: ${providers.map((candidate) => candidate.id).join(", ")}`);
|
|
15
26
|
}
|
|
16
27
|
|
|
17
28
|
const auth = createSubscriptionAuth({
|
|
18
29
|
store: new FileCredentialStore(join(homedir(), ".aisubs-demo", "credentials.json")),
|
|
19
|
-
providers
|
|
30
|
+
providers,
|
|
20
31
|
});
|
|
21
32
|
const account = auth.account(provider, accountKey);
|
|
22
33
|
|
|
23
34
|
if (!(await account.status()).authenticated) {
|
|
24
|
-
const
|
|
35
|
+
const apiKey =
|
|
36
|
+
provider === "opencode-go"
|
|
37
|
+
? process.env.OPENCODE_GO_API_KEY
|
|
38
|
+
: provider === "opencode-zen"
|
|
39
|
+
? process.env.OPENCODE_API_KEY
|
|
40
|
+
: undefined;
|
|
41
|
+
if (provider.startsWith("opencode-") && !apiKey) {
|
|
42
|
+
throw new Error(
|
|
43
|
+
provider === "opencode-go"
|
|
44
|
+
? "Set OPENCODE_GO_API_KEY before signing in"
|
|
45
|
+
: "Set OPENCODE_API_KEY before signing in",
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
const login = await account.signIn(apiKey ? { apiKey } : undefined);
|
|
25
49
|
if (login.prompt.mode === "browser") {
|
|
26
50
|
console.log(`Open ${login.prompt.authorizationUri}`);
|
|
27
51
|
} else if (login.prompt.mode === "device") {
|
|
@@ -32,7 +56,6 @@ if (!(await account.status()).authenticated) {
|
|
|
32
56
|
console.log("Signed in; refresh credentials were saved securely.");
|
|
33
57
|
}
|
|
34
58
|
|
|
35
|
-
// In-process request: no localhost server, port, CORS, or control API key.
|
|
36
|
-
const
|
|
37
|
-
console.log(
|
|
38
|
-
console.log((await response.text()).slice(0, 1000));
|
|
59
|
+
// In-process SDK request: no localhost server, port, CORS, or control API key.
|
|
60
|
+
const catalog = await account.getModels();
|
|
61
|
+
console.log(catalog?.models ?? []);
|
package/examples/server.mjs
CHANGED
|
@@ -4,9 +4,12 @@ import {
|
|
|
4
4
|
FileCredentialStore,
|
|
5
5
|
FileApiKeyStore,
|
|
6
6
|
chatGptProvider,
|
|
7
|
+
claudeProvider,
|
|
7
8
|
copilotProvider,
|
|
8
9
|
createSubscriptionAuth,
|
|
9
10
|
grokProvider,
|
|
11
|
+
openCodeGoProvider,
|
|
12
|
+
openCodeZenProvider,
|
|
10
13
|
} from "aisubs";
|
|
11
14
|
import { createSubscriptionAuthServer } from "aisubs/http";
|
|
12
15
|
|
|
@@ -16,7 +19,14 @@ const apiKey =
|
|
|
16
19
|
(await new FileApiKeyStore(join(directory, "api-key")).readOrCreate());
|
|
17
20
|
const auth = createSubscriptionAuth({
|
|
18
21
|
store: new FileCredentialStore(join(directory, "credentials.json")),
|
|
19
|
-
providers: [
|
|
22
|
+
providers: [
|
|
23
|
+
chatGptProvider(),
|
|
24
|
+
claudeProvider(),
|
|
25
|
+
copilotProvider(),
|
|
26
|
+
grokProvider(),
|
|
27
|
+
openCodeGoProvider(),
|
|
28
|
+
openCodeZenProvider(),
|
|
29
|
+
],
|
|
20
30
|
});
|
|
21
31
|
const server = await createSubscriptionAuthServer({ auth, apiKey, port: 4319 });
|
|
22
32
|
console.log(`AI Subs API: ${server.url}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "aisubs",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.1",
|
|
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,8 @@
|
|
|
38
38
|
"dashboard/public/aisubs-mark.svg",
|
|
39
39
|
"examples",
|
|
40
40
|
"public",
|
|
41
|
+
"scripts/codex-catalog.mjs",
|
|
42
|
+
"CHANGELOG.md",
|
|
41
43
|
"README.md"
|
|
42
44
|
],
|
|
43
45
|
"type": "module",
|
|
@@ -83,6 +85,7 @@
|
|
|
83
85
|
},
|
|
84
86
|
"scripts": {
|
|
85
87
|
"dev": "node --run build && node scripts/dev.mjs",
|
|
88
|
+
"codex:catalog": "node scripts/codex-catalog.mjs",
|
|
86
89
|
"prepare": "node --run build",
|
|
87
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",
|
|
88
91
|
"prepack": "node --run check",
|
|
@@ -93,11 +96,18 @@
|
|
|
93
96
|
"fmt:check": "oxfmt --check .",
|
|
94
97
|
"check": "node --run typecheck && node --run lint && node --run fmt:check && node --run test && node --run build"
|
|
95
98
|
},
|
|
99
|
+
"dependencies": {
|
|
100
|
+
"@fastify/cors": "^11.3.0",
|
|
101
|
+
"@fastify/websocket": "^11.3.0",
|
|
102
|
+
"fastify": "^5.12.0",
|
|
103
|
+
"ws": "^8.18.3"
|
|
104
|
+
},
|
|
96
105
|
"devDependencies": {
|
|
97
106
|
"@tailwindcss/vite": "^4.3.3",
|
|
98
107
|
"@types/node": "^26.2.0",
|
|
99
108
|
"@types/react": "^19.2.18",
|
|
100
109
|
"@types/react-dom": "^19.2.4",
|
|
110
|
+
"@types/ws": "^8.18.1",
|
|
101
111
|
"@vitejs/plugin-react": "^6.0.5",
|
|
102
112
|
"lucide-react": "^1.31.0",
|
|
103
113
|
"oxfmt": "^0.63.0",
|
|
Binary file
|
|
@@ -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
|
+
}
|