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
package/dist/http.js
CHANGED
|
@@ -1,39 +1,80 @@
|
|
|
1
|
-
import
|
|
2
|
-
import
|
|
1
|
+
import cors from "@fastify/cors";
|
|
2
|
+
import websocket from "@fastify/websocket";
|
|
3
|
+
import Fastify from "fastify";
|
|
4
|
+
import { timingSafeEqual } from "node:crypto";
|
|
3
5
|
import { Readable } from "node:stream";
|
|
4
|
-
import {
|
|
5
|
-
|
|
6
|
+
import { proxyCompatible } from "./compatibility.js";
|
|
7
|
+
import { registerRealtimeProxy } from "./realtime.js";
|
|
8
|
+
import { errorMessage, isRecord, numberValue, stringValue, urlHost } from "./utils.js";
|
|
6
9
|
const MAX_PROXY_BODY_BYTES = 10 * 1024 * 1024;
|
|
7
|
-
function
|
|
10
|
+
function bodyLimit(value = MAX_PROXY_BODY_BYTES) {
|
|
8
11
|
if (!Number.isSafeInteger(value) || value <= 0) {
|
|
9
12
|
throw new Error("maxProxyBodyBytes must be a positive safe integer");
|
|
10
13
|
}
|
|
11
14
|
return value;
|
|
12
15
|
}
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
}
|
|
27
|
-
|
|
16
|
+
function sameSecret(actual, expected) {
|
|
17
|
+
if (!actual)
|
|
18
|
+
return false;
|
|
19
|
+
const left = Buffer.from(actual);
|
|
20
|
+
const right = Buffer.from(expected);
|
|
21
|
+
return left.length === right.length && timingSafeEqual(left, right);
|
|
22
|
+
}
|
|
23
|
+
function requestApiKeys(request) {
|
|
24
|
+
const authorization = request.headers.authorization;
|
|
25
|
+
const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7) : undefined;
|
|
26
|
+
const header = (name) => {
|
|
27
|
+
const value = request.headers[name];
|
|
28
|
+
return Array.isArray(value) ? value[0] : value;
|
|
29
|
+
};
|
|
30
|
+
const queryKey = new URL(request.url, "http://aisubs.local").searchParams.get("key") ?? undefined;
|
|
31
|
+
return [bearer, header("x-api-key"), header("x-goog-api-key"), queryKey].filter((value) => value != null);
|
|
32
|
+
}
|
|
33
|
+
export function routeSegments(pathname) {
|
|
34
|
+
return pathname.split("/").filter(Boolean).map(decodeURIComponent);
|
|
35
|
+
}
|
|
36
|
+
function bodyBuffer(request) {
|
|
37
|
+
if (request.body == null)
|
|
38
|
+
return Buffer.alloc(0);
|
|
39
|
+
if (Buffer.isBuffer(request.body))
|
|
40
|
+
return request.body;
|
|
41
|
+
if (typeof request.body === "string")
|
|
42
|
+
return Buffer.from(request.body);
|
|
43
|
+
return Buffer.from(JSON.stringify(request.body));
|
|
28
44
|
}
|
|
29
|
-
|
|
30
|
-
const body =
|
|
31
|
-
if (body.length
|
|
45
|
+
function jsonBody(request) {
|
|
46
|
+
const body = bodyBuffer(request);
|
|
47
|
+
if (!body.length)
|
|
32
48
|
return {};
|
|
33
49
|
return JSON.parse(body.toString("utf8"));
|
|
34
50
|
}
|
|
35
|
-
function
|
|
36
|
-
const headers =
|
|
51
|
+
function requestHeaders(request) {
|
|
52
|
+
const headers = new Headers();
|
|
53
|
+
const privateHeaders = new Set([
|
|
54
|
+
"authorization",
|
|
55
|
+
"connection",
|
|
56
|
+
"content-length",
|
|
57
|
+
"cookie",
|
|
58
|
+
"host",
|
|
59
|
+
"origin",
|
|
60
|
+
"proxy-authenticate",
|
|
61
|
+
"proxy-authorization",
|
|
62
|
+
"referer",
|
|
63
|
+
"te",
|
|
64
|
+
"trailer",
|
|
65
|
+
"upgrade",
|
|
66
|
+
"x-api-key",
|
|
67
|
+
"x-goog-api-key",
|
|
68
|
+
]);
|
|
69
|
+
for (const [name, value] of Object.entries(request.headers)) {
|
|
70
|
+
if (value != null && !privateHeaders.has(name)) {
|
|
71
|
+
headers.set(name, Array.isArray(value) ? value.join(", ") : String(value));
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return headers;
|
|
75
|
+
}
|
|
76
|
+
function responseHeaders(upstream) {
|
|
77
|
+
const headers = new Headers({ "cache-control": "no-store" });
|
|
37
78
|
upstream.headers.forEach((value, name) => {
|
|
38
79
|
if (![
|
|
39
80
|
"cache-control",
|
|
@@ -44,264 +85,268 @@ function upstreamHeaders(upstream) {
|
|
|
44
85
|
"set-cookie",
|
|
45
86
|
"transfer-encoding",
|
|
46
87
|
].includes(name)) {
|
|
47
|
-
headers
|
|
88
|
+
headers.set(name, value);
|
|
48
89
|
}
|
|
49
90
|
});
|
|
50
91
|
return headers;
|
|
51
92
|
}
|
|
52
|
-
async function
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
return readBody(request, proxyBodyLimit(maxBytes));
|
|
93
|
+
export async function sendWebResponse(reply, upstream) {
|
|
94
|
+
reply.code(upstream.status);
|
|
95
|
+
responseHeaders(upstream).forEach((value, name) => reply.header(name, value));
|
|
96
|
+
if (!upstream.body) {
|
|
97
|
+
await reply.send();
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await reply.send(Readable.from(upstream.body));
|
|
61
101
|
}
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
"cache-control": "no-store",
|
|
66
|
-
...headers,
|
|
102
|
+
function jsonResponse(body, status = 200, headers = {}) {
|
|
103
|
+
return Response.json(body, {
|
|
104
|
+
status,
|
|
105
|
+
headers: { "cache-control": "no-store", ...Object.fromEntries(new Headers(headers)) },
|
|
67
106
|
});
|
|
68
|
-
response.end(JSON.stringify(body));
|
|
69
107
|
}
|
|
70
|
-
|
|
71
|
-
|
|
108
|
+
function accountPath(parts) {
|
|
109
|
+
if (parts[0] !== "aisubs" || !parts[1] || !parts[2])
|
|
110
|
+
return null;
|
|
111
|
+
const upstream = parts.slice(3);
|
|
112
|
+
const versioned = upstream[0] === "v1";
|
|
113
|
+
if (versioned)
|
|
114
|
+
upstream.shift();
|
|
115
|
+
return {
|
|
116
|
+
provider: parts[1],
|
|
117
|
+
account: parts[2],
|
|
118
|
+
path: upstream.map(encodeURIComponent).join("/"),
|
|
119
|
+
versioned,
|
|
120
|
+
};
|
|
72
121
|
}
|
|
73
|
-
function
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
const input = [];
|
|
86
|
-
for (const message of raw.messages) {
|
|
87
|
-
if (!isRecord(message))
|
|
88
|
-
throw new Error("Chat Completions messages must be objects");
|
|
89
|
-
const role = stringValue(message.role);
|
|
90
|
-
const content = stringValue(message.content);
|
|
91
|
-
if (!role || content == null) {
|
|
92
|
-
throw new Error("ChatGPT compatibility supports text-only messages");
|
|
93
|
-
}
|
|
94
|
-
if (role === "system" || role === "developer")
|
|
95
|
-
instructions.push(content);
|
|
96
|
-
else if (role === "user" || role === "assistant") {
|
|
97
|
-
input.push({
|
|
98
|
-
role,
|
|
99
|
-
content: [{ type: role === "assistant" ? "output_text" : "input_text", text: content }],
|
|
100
|
-
});
|
|
101
|
-
}
|
|
102
|
-
else
|
|
103
|
-
throw new Error(`Unsupported Chat Completions role: ${role}`);
|
|
104
|
-
}
|
|
105
|
-
const translated = { model, store: false, stream: true, input };
|
|
106
|
-
if (instructions.length)
|
|
107
|
-
translated.instructions = instructions.join("\n\n");
|
|
108
|
-
const maxOutputTokens = raw.max_completion_tokens ?? raw.max_tokens;
|
|
109
|
-
if (typeof maxOutputTokens === "number")
|
|
110
|
-
translated.max_output_tokens = maxOutputTokens;
|
|
111
|
-
if (isRecord(raw.response_format) &&
|
|
112
|
-
raw.response_format.type === "json_schema" &&
|
|
113
|
-
isRecord(raw.response_format.json_schema)) {
|
|
114
|
-
translated.text = { format: { type: "json_schema", ...raw.response_format.json_schema } };
|
|
115
|
-
}
|
|
116
|
-
return { model, body: JSON.stringify(translated) };
|
|
122
|
+
function openAiModel(provider, model) {
|
|
123
|
+
return {
|
|
124
|
+
id: model.id,
|
|
125
|
+
object: "model",
|
|
126
|
+
owned_by: provider,
|
|
127
|
+
capabilities: {
|
|
128
|
+
endpoints: model.endpoints ?? [],
|
|
129
|
+
input_modalities: model.inputModalities ?? ["text"],
|
|
130
|
+
reasoning_efforts: model.reasoningEfforts ?? [],
|
|
131
|
+
tools: model.supportsToolCall ?? false,
|
|
132
|
+
},
|
|
133
|
+
};
|
|
117
134
|
}
|
|
118
|
-
function
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
return direct;
|
|
124
|
-
if (!Array.isArray(raw.output))
|
|
125
|
-
return undefined;
|
|
126
|
-
const text = raw.output.flatMap((item) => {
|
|
127
|
-
if (!isRecord(item) || !Array.isArray(item.content))
|
|
128
|
-
return [];
|
|
129
|
-
return item.content.flatMap((part) => isRecord(part) && part.type === "output_text" && stringValue(part.text) != null
|
|
130
|
-
? [stringValue(part.text)]
|
|
131
|
-
: []);
|
|
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/"));
|
|
132
140
|
});
|
|
133
|
-
return text.length ? text.join("") : undefined;
|
|
134
141
|
}
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
completed = event.response;
|
|
155
|
-
}
|
|
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
|
+
});
|
|
156
161
|
}
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
created: typeof raw.created_at === "number" ? raw.created_at : Math.floor(Date.now() / 1000),
|
|
172
|
-
model: stringValue(raw.model) ?? model,
|
|
173
|
-
choices: [{ index: 0, message: { role: "assistant", content }, finish_reason: "stop" }],
|
|
174
|
-
...(usage ? { usage } : {}),
|
|
175
|
-
});
|
|
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
176
|
}
|
|
177
|
-
export async function handleSubscriptionAuthApi(auth, request,
|
|
177
|
+
export async function handleSubscriptionAuthApi(auth, request, signal) {
|
|
178
|
+
const url = new URL(request.url, "http://aisubs.local");
|
|
178
179
|
const parts = routeSegments(url.pathname);
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
if (
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
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);
|
|
187
257
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
258
|
+
}
|
|
259
|
+
const account = accountPath(parts);
|
|
260
|
+
if (account && request.method !== "OPTIONS") {
|
|
261
|
+
if (account.versioned && request.method === "GET" && account.path === "models") {
|
|
262
|
+
const catalog = await auth.getModels(account.provider, account.account);
|
|
263
|
+
const models = (catalog?.models ?? []).map((model) => openAiModel(account.provider, model));
|
|
264
|
+
return jsonResponse({
|
|
191
265
|
object: "list",
|
|
192
|
-
data:
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
})),
|
|
266
|
+
data: models,
|
|
267
|
+
// Codex's custom-provider catalog reader expects `models`, while
|
|
268
|
+
// OpenAI-compatible clients expect `data`. Keep both shapes.
|
|
269
|
+
models,
|
|
197
270
|
});
|
|
198
|
-
return true;
|
|
199
271
|
}
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
"upgrade",
|
|
214
|
-
"x-api-key",
|
|
215
|
-
]);
|
|
216
|
-
for (const [name, value] of Object.entries(request.headers)) {
|
|
217
|
-
if (value != null && !privateHeaders.has(name)) {
|
|
218
|
-
headers.set(name, Array.isArray(value) ? value.join(", ") : value);
|
|
219
|
-
}
|
|
272
|
+
if (account.versioned && request.method === "GET" && account.path.startsWith("models/")) {
|
|
273
|
+
const id = decodeURIComponent(account.path.slice("models/".length));
|
|
274
|
+
const catalog = await auth.getModels(account.provider, account.account);
|
|
275
|
+
const model = catalog?.models.find((candidate) => candidate.id === id);
|
|
276
|
+
return model
|
|
277
|
+
? jsonResponse(openAiModel(account.provider, model))
|
|
278
|
+
: jsonResponse({
|
|
279
|
+
error: {
|
|
280
|
+
message: `Model not found: ${id}`,
|
|
281
|
+
type: "invalid_request_error",
|
|
282
|
+
code: "model_not_found",
|
|
283
|
+
},
|
|
284
|
+
}, 404);
|
|
220
285
|
}
|
|
221
|
-
const
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
if (
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
const translated = chatCompletionRequest(body);
|
|
230
|
-
path = "responses";
|
|
231
|
-
proxyBody = Buffer.from(translated.body);
|
|
232
|
-
chatCompletionModel = translated.model;
|
|
233
|
-
headers.set("accept", "text/event-stream");
|
|
234
|
-
headers.set("content-type", "application/json");
|
|
286
|
+
const headers = requestHeaders(request);
|
|
287
|
+
const body = bodyBuffer(request);
|
|
288
|
+
url.searchParams.delete("key");
|
|
289
|
+
const path = `${account.path}${url.search}`;
|
|
290
|
+
if (account.versioned && request.method === "POST") {
|
|
291
|
+
const compatible = await proxyCompatible(auth, account.provider, account.account, path, body, headers, signal);
|
|
292
|
+
if (compatible)
|
|
293
|
+
return compatible;
|
|
235
294
|
}
|
|
236
|
-
|
|
295
|
+
return auth.proxy(account.provider, account.account, path, {
|
|
237
296
|
method: request.method,
|
|
238
297
|
headers,
|
|
239
|
-
body:
|
|
298
|
+
body: body.length ? new Uint8Array(body) : undefined,
|
|
299
|
+
signal,
|
|
240
300
|
});
|
|
241
|
-
await sendUpstream(response, chatCompletionModel ? await chatCompletionResponse(upstream, chatCompletionModel) : upstream);
|
|
242
|
-
return true;
|
|
243
301
|
}
|
|
244
302
|
if (request.method === "GET" && parts.join("/") === "v1/providers") {
|
|
245
|
-
|
|
246
|
-
return true;
|
|
303
|
+
return jsonResponse({ providers: auth.listProviders() });
|
|
247
304
|
}
|
|
248
305
|
if (request.method === "GET" && parts.join("/") === "v1/auth") {
|
|
249
|
-
|
|
250
|
-
return true;
|
|
306
|
+
return jsonResponse({ sessions: await auth.statuses() });
|
|
251
307
|
}
|
|
252
308
|
if (parts[0] === "v1" && parts[1] === "auth" && parts[2]) {
|
|
253
309
|
const provider = parts[2];
|
|
254
310
|
if (request.method === "GET" && parts.length === 3) {
|
|
255
|
-
|
|
311
|
+
return jsonResponse(await auth.status(provider, {
|
|
256
312
|
account: url.searchParams.get("account") ?? undefined,
|
|
257
313
|
validate: url.searchParams.get("validate") === "true",
|
|
258
314
|
}));
|
|
259
|
-
return true;
|
|
260
315
|
}
|
|
261
316
|
if (request.method === "GET" && parts[3] === "accounts") {
|
|
262
|
-
|
|
263
|
-
return true;
|
|
317
|
+
return jsonResponse({ accounts: await auth.listAccounts(provider) });
|
|
264
318
|
}
|
|
265
319
|
if (request.method === "GET" && parts[3] === "details") {
|
|
266
|
-
|
|
267
|
-
return true;
|
|
320
|
+
return jsonResponse(await auth.credentialSummary(provider, url.searchParams.get("account") ?? undefined));
|
|
268
321
|
}
|
|
269
322
|
if (request.method === "POST" && parts[3] === "login") {
|
|
270
|
-
const body =
|
|
323
|
+
const body = jsonBody(request);
|
|
271
324
|
const attempt = await auth.signIn(provider, isRecord(body) ? body : undefined);
|
|
272
|
-
|
|
325
|
+
return jsonResponse({
|
|
273
326
|
id: attempt.id,
|
|
274
327
|
provider: attempt.provider,
|
|
275
328
|
accountKey: attempt.accountKey,
|
|
276
329
|
state: attempt.state,
|
|
277
330
|
prompt: attempt.prompt,
|
|
278
|
-
});
|
|
279
|
-
return true;
|
|
331
|
+
}, 202);
|
|
280
332
|
}
|
|
281
333
|
if (request.method === "DELETE" && parts.length === 3) {
|
|
282
334
|
const accountKey = url.searchParams.get("account") ?? "default";
|
|
283
335
|
await auth.signOut(provider, accountKey);
|
|
284
|
-
|
|
285
|
-
return true;
|
|
336
|
+
return jsonResponse({ provider, accountKey, authenticated: false });
|
|
286
337
|
}
|
|
287
338
|
}
|
|
288
339
|
if (request.method === "GET" && parts[0] === "v1" && parts[1] === "logins" && parts[2]) {
|
|
289
340
|
const attempt = auth.getLoginAttempt(parts[2]);
|
|
290
|
-
|
|
291
|
-
sendJson(response, 200, attempt);
|
|
292
|
-
else
|
|
293
|
-
sendJson(response, 404, { error: "Login not found" });
|
|
294
|
-
return true;
|
|
341
|
+
return attempt ? jsonResponse(attempt) : jsonResponse({ error: "Login not found" }, 404);
|
|
295
342
|
}
|
|
296
343
|
if (request.method === "DELETE" && parts[0] === "v1" && parts[1] === "logins" && parts[2]) {
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
sendJson(response, 404, { error: "Pending login not found" });
|
|
301
|
-
return true;
|
|
344
|
+
return auth.cancelLoginAttempt(parts[2])
|
|
345
|
+
? jsonResponse({ cancelled: true })
|
|
346
|
+
: jsonResponse({ error: "Pending login not found" }, 404);
|
|
302
347
|
}
|
|
303
348
|
if (request.method === "POST" && parts[0] === "v1" && parts[1] === "fetch" && parts[2]) {
|
|
304
|
-
const body =
|
|
349
|
+
const body = jsonBody(request);
|
|
305
350
|
if (!isRecord(body))
|
|
306
351
|
throw new Error("Fetch body requires an absolute url");
|
|
307
352
|
const targetUrl = stringValue(body.url);
|
|
@@ -322,79 +367,130 @@ export async function handleSubscriptionAuthApi(auth, request, response, url, ma
|
|
|
322
367
|
if (!headers.has("content-type"))
|
|
323
368
|
headers.set("content-type", "application/json");
|
|
324
369
|
}
|
|
325
|
-
|
|
370
|
+
return auth.fetch(parts[2], targetUrl, {
|
|
326
371
|
method: stringValue(body.method) ?? (requestBody ? "POST" : "GET"),
|
|
327
372
|
headers,
|
|
328
373
|
body: requestBody,
|
|
374
|
+
signal,
|
|
329
375
|
}, stringValue(body.account));
|
|
330
|
-
await sendUpstream(response, upstream);
|
|
331
|
-
return true;
|
|
332
376
|
}
|
|
333
377
|
if (request.method === "GET" && parts[0] === "v1" && parts[1] === "usage" && parts[2]) {
|
|
334
|
-
const usage = await auth.getUsage(parts[2], url.searchParams.get("account") ?? undefined);
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
sendJson(response, 404, { error: "Usage is not supported by this provider" });
|
|
339
|
-
return true;
|
|
378
|
+
const usage = await auth.getUsage(parts[2], url.searchParams.get("account") ?? undefined, signal);
|
|
379
|
+
return usage
|
|
380
|
+
? jsonResponse(usage)
|
|
381
|
+
: jsonResponse({ error: "Usage is not supported by this provider" }, 404);
|
|
340
382
|
}
|
|
341
383
|
if (request.method === "GET" && parts[0] === "v1" && parts[1] === "models" && parts[2]) {
|
|
342
|
-
const models = await auth.getModels(parts[2], url.searchParams.get("account") ?? undefined);
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
sendJson(response, 404, { error: "Models are not supported by this provider" });
|
|
347
|
-
return true;
|
|
384
|
+
const models = await auth.getModels(parts[2], url.searchParams.get("account") ?? undefined, signal);
|
|
385
|
+
return models
|
|
386
|
+
? jsonResponse(models)
|
|
387
|
+
: jsonResponse({ error: "Models are not supported by this provider" }, 404);
|
|
348
388
|
}
|
|
349
|
-
return
|
|
389
|
+
return null;
|
|
350
390
|
}
|
|
351
|
-
|
|
391
|
+
/** Abort upstream work only when the client disconnects before the response finishes. */
|
|
392
|
+
export function clientAbortSignal(request, reply) {
|
|
393
|
+
const controller = new AbortController();
|
|
394
|
+
const abort = () => {
|
|
395
|
+
if (!reply.raw.writableFinished && !controller.signal.aborted)
|
|
396
|
+
controller.abort();
|
|
397
|
+
};
|
|
398
|
+
const cleanup = () => {
|
|
399
|
+
request.raw.off("aborted", abort);
|
|
400
|
+
reply.raw.off("close", abort);
|
|
401
|
+
};
|
|
402
|
+
request.raw.once("aborted", abort);
|
|
403
|
+
reply.raw.once("close", abort);
|
|
404
|
+
reply.raw.once("finish", cleanup);
|
|
405
|
+
if (request.raw.aborted || (reply.raw.destroyed && !reply.raw.writableFinished))
|
|
406
|
+
abort();
|
|
407
|
+
return controller.signal;
|
|
408
|
+
}
|
|
409
|
+
export function createApiApp(options) {
|
|
352
410
|
if (!options.apiKey)
|
|
353
411
|
throw new Error("A non-empty API key is required");
|
|
354
|
-
const
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
412
|
+
const app = Fastify({
|
|
413
|
+
bodyLimit: bodyLimit(options.maxProxyBodyBytes),
|
|
414
|
+
forceCloseConnections: true,
|
|
415
|
+
});
|
|
416
|
+
app.removeAllContentTypeParsers();
|
|
417
|
+
app.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) => done(null, body));
|
|
418
|
+
void app.register(cors, {
|
|
419
|
+
origin: true,
|
|
420
|
+
credentials: false,
|
|
421
|
+
methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
|
|
422
|
+
allowedHeaders: [
|
|
423
|
+
"authorization",
|
|
424
|
+
"content-type",
|
|
425
|
+
"x-api-key",
|
|
426
|
+
"x-goog-api-key",
|
|
427
|
+
"anthropic-version",
|
|
428
|
+
"anthropic-beta",
|
|
429
|
+
"openai-beta",
|
|
430
|
+
],
|
|
431
|
+
exposedHeaders: ["x-request-id", "retry-after"],
|
|
432
|
+
});
|
|
433
|
+
void app.register(websocket);
|
|
434
|
+
app.get("/health", async () => ({ ok: true }));
|
|
435
|
+
app.addHook("onRequest", async (request, reply) => {
|
|
436
|
+
if (request.url === "/health")
|
|
437
|
+
return;
|
|
438
|
+
if (!requestApiKeys(request).some((value) => sameSecret(value, options.apiKey))) {
|
|
439
|
+
await reply.code(401).send({
|
|
440
|
+
error: { message: "Unauthorized", type: "authentication_error", code: "invalid_api_key" },
|
|
441
|
+
});
|
|
375
442
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
443
|
+
});
|
|
444
|
+
void app.register(async (scope) => {
|
|
445
|
+
registerRealtimeProxy(scope, options.auth, (request) => requestApiKeys(request).some((value) => sameSecret(value, options.apiKey)));
|
|
446
|
+
});
|
|
447
|
+
app.route({
|
|
448
|
+
method: ["GET", "POST", "PUT", "PATCH", "DELETE"],
|
|
449
|
+
url: "/*",
|
|
450
|
+
async handler(request, reply) {
|
|
451
|
+
const response = await handleSubscriptionAuthApi(options.auth, request, clientAbortSignal(request, reply));
|
|
452
|
+
if (response)
|
|
453
|
+
await sendWebResponse(reply, response);
|
|
379
454
|
else
|
|
380
|
-
|
|
381
|
-
|
|
455
|
+
await reply.code(404).send({
|
|
456
|
+
error: { message: "Not found", type: "invalid_request_error", code: "not_found" },
|
|
457
|
+
});
|
|
458
|
+
},
|
|
382
459
|
});
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
460
|
+
app.setErrorHandler(async (error, request, reply) => {
|
|
461
|
+
const statusCode = isRecord(error) ? numberValue(error.statusCode) : undefined;
|
|
462
|
+
const status = statusCode && statusCode >= 400 ? statusCode : 400;
|
|
463
|
+
const code = isRecord(error) ? stringValue(error.code) : undefined;
|
|
464
|
+
const openAi = request.url.startsWith("/aisubs/") || request.url.startsWith("/aisubs-codex/");
|
|
465
|
+
await reply.code(status).send(openAi
|
|
466
|
+
? {
|
|
467
|
+
error: {
|
|
468
|
+
message: errorMessage(error),
|
|
469
|
+
type: "invalid_request_error",
|
|
470
|
+
code: code ?? "invalid_request_error",
|
|
471
|
+
},
|
|
472
|
+
}
|
|
473
|
+
: { error: errorMessage(error) });
|
|
389
474
|
});
|
|
390
|
-
|
|
475
|
+
return app;
|
|
476
|
+
}
|
|
477
|
+
export async function createSubscriptionAuthServer(options) {
|
|
478
|
+
const host = options.host ?? "127.0.0.1";
|
|
479
|
+
if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") {
|
|
480
|
+
throw new Error("The built-in auth server may only bind to localhost");
|
|
481
|
+
}
|
|
482
|
+
const app = createApiApp(options);
|
|
483
|
+
await app.listen({ port: options.port ?? 0, host });
|
|
484
|
+
const address = app.server.address();
|
|
391
485
|
if (!address || typeof address === "string") {
|
|
486
|
+
await app.close();
|
|
392
487
|
throw new Error("Unable to determine auth server port");
|
|
393
488
|
}
|
|
394
489
|
return {
|
|
395
|
-
server,
|
|
490
|
+
server: app.server,
|
|
491
|
+
app,
|
|
396
492
|
apiKey: options.apiKey,
|
|
397
|
-
url:
|
|
398
|
-
close: () =>
|
|
493
|
+
url: `http://${urlHost(host)}:${address.port}`,
|
|
494
|
+
close: () => app.close(),
|
|
399
495
|
};
|
|
400
496
|
}
|