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/dist/http.js CHANGED
@@ -1,39 +1,80 @@
1
- import { createServer } from "node:http";
2
- import { pipeline } from "node:stream/promises";
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 { errorMessage, isRecord, stringValue, urlHost } from "./utils.js";
5
- const MAX_BODY_BYTES = 1024 * 1024;
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 proxyBodyLimit(value = MAX_PROXY_BODY_BYTES) {
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
- async function readBody(request, maxBytes) {
14
- const contentLength = Number(request.headers["content-length"]);
15
- if (Number.isFinite(contentLength) && contentLength > maxBytes) {
16
- throw new Error("Request body is too large");
17
- }
18
- const chunks = [];
19
- let size = 0;
20
- for await (const chunk of request) {
21
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
22
- size += buffer.length;
23
- if (size > maxBytes)
24
- throw new Error("Request body is too large");
25
- chunks.push(buffer);
26
- }
27
- return Buffer.concat(chunks);
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
- export async function readJsonBody(request) {
30
- const body = await readBody(request, MAX_BODY_BYTES);
31
- if (body.length === 0)
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 upstreamHeaders(upstream) {
36
- const headers = { "cache-control": "no-store" };
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[name] = value;
88
+ headers.set(name, value);
48
89
  }
49
90
  });
50
91
  return headers;
51
92
  }
52
- async function sendUpstream(response, upstream) {
53
- response.writeHead(upstream.status, upstreamHeaders(upstream));
54
- if (upstream.body)
55
- await pipeline(Readable.fromWeb(upstream.body), response);
56
- else
57
- response.end();
58
- }
59
- export async function readProxyBody(request, maxBytes = MAX_PROXY_BODY_BYTES) {
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
- export function sendJson(response, status, body, headers = {}) {
63
- response.writeHead(status, {
64
- "content-type": "application/json; charset=utf-8",
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
- export function routeSegments(pathname) {
71
- return pathname.split("/").filter(Boolean).map(decodeURIComponent);
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 chatCompletionRequest(body) {
74
- const raw = JSON.parse(body.toString("utf8"));
75
- if (!isRecord(raw) || !Array.isArray(raw.messages)) {
76
- throw new Error("Chat Completions requires a messages array");
77
- }
78
- const model = stringValue(raw.model);
79
- if (!model)
80
- throw new Error("Chat Completions requires a model");
81
- if (raw.stream === true) {
82
- throw new Error("ChatGPT Chat Completions compatibility does not support streaming");
83
- }
84
- const instructions = [];
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 responseText(raw) {
119
- if (!isRecord(raw))
120
- return undefined;
121
- const direct = stringValue(raw.output_text);
122
- if (direct != null)
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
- async function chatCompletionResponse(upstream, model) {
136
- if (!upstream.ok)
137
- return upstream;
138
- let completed;
139
- let content = "";
140
- const body = await upstream.text();
141
- if (body.startsWith("event:") || body.startsWith("data:")) {
142
- for (const line of body.split(/\r?\n/)) {
143
- if (!line.startsWith("data:"))
144
- continue;
145
- const data = line.slice(5).trim();
146
- if (!data || data === "[DONE]")
147
- continue;
148
- const event = JSON.parse(data);
149
- if (!isRecord(event))
150
- continue;
151
- if (event.type === "response.output_text.delta")
152
- content += stringValue(event.delta) ?? "";
153
- if (event.type === "response.completed")
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
- else
158
- completed = JSON.parse(body);
159
- content ||= responseText(completed) ?? "";
160
- const raw = isRecord(completed) ? completed : {};
161
- const usage = isRecord(raw.usage)
162
- ? {
163
- prompt_tokens: raw.usage.input_tokens,
164
- completion_tokens: raw.usage.output_tokens,
165
- total_tokens: raw.usage.total_tokens,
166
- }
167
- : undefined;
168
- return Response.json({
169
- id: stringValue(raw.id) ?? `chatcmpl_${crypto.randomUUID()}`,
170
- object: "chat.completion",
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, response, url, maxProxyBodyBytes = MAX_PROXY_BODY_BYTES) {
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
- if (parts[0] === "aisubs" && parts[1] && parts[2] && request.method !== "OPTIONS") {
180
- const upstreamParts = parts.slice(3);
181
- const versioned = upstreamParts[0] === "v1";
182
- if (versioned)
183
- upstreamParts.shift();
184
- if (versioned && upstreamParts[0] === "embeddings") {
185
- sendJson(response, 404, { error: "AISubs does not expose an embeddings API" });
186
- return true;
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
- if (versioned && request.method === "GET" && upstreamParts.join("/") === "models") {
189
- const catalog = await auth.getModels(parts[1], parts[2]);
190
- sendJson(response, 200, {
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: (catalog?.models ?? []).map((model) => ({
193
- id: model.id,
194
- object: "model",
195
- owned_by: parts[1],
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
- const headers = new Headers();
201
- const privateHeaders = new Set([
202
- "authorization",
203
- "connection",
204
- "content-length",
205
- "cookie",
206
- "host",
207
- "origin",
208
- "proxy-authenticate",
209
- "proxy-authorization",
210
- "referer",
211
- "te",
212
- "trailer",
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 body = await readProxyBody(request, maxProxyBodyBytes);
222
- let path = upstreamParts.map(encodeURIComponent).join("/") + url.search;
223
- let proxyBody = body;
224
- let chatCompletionModel;
225
- if (parts[1] === "chatgpt" &&
226
- versioned &&
227
- request.method === "POST" &&
228
- upstreamParts.join("/") === "chat/completions") {
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
- const upstream = await auth.proxy(parts[1], parts[2], path, {
295
+ return auth.proxy(account.provider, account.account, path, {
237
296
  method: request.method,
238
297
  headers,
239
- body: proxyBody.length ? proxyBody : undefined,
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
- sendJson(response, 200, { providers: auth.listProviders() });
246
- return true;
303
+ return jsonResponse({ providers: auth.listProviders() });
247
304
  }
248
305
  if (request.method === "GET" && parts.join("/") === "v1/auth") {
249
- sendJson(response, 200, { sessions: await auth.statuses() });
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
- sendJson(response, 200, await auth.status(provider, {
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
- sendJson(response, 200, { accounts: await auth.listAccounts(provider) });
263
- return true;
317
+ return jsonResponse({ accounts: await auth.listAccounts(provider) });
264
318
  }
265
319
  if (request.method === "GET" && parts[3] === "details") {
266
- sendJson(response, 200, await auth.credentialSummary(provider, url.searchParams.get("account") ?? undefined));
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 = await readJsonBody(request);
323
+ const body = jsonBody(request);
271
324
  const attempt = await auth.signIn(provider, isRecord(body) ? body : undefined);
272
- sendJson(response, 202, {
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
- sendJson(response, 200, { provider, accountKey, authenticated: false });
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
- if (attempt)
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
- if (auth.cancelLoginAttempt(parts[2]))
298
- sendJson(response, 200, { cancelled: true });
299
- else
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 = await readJsonBody(request);
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
- const upstream = await auth.fetch(parts[2], targetUrl, {
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
- if (usage)
336
- sendJson(response, 200, usage);
337
- else
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
- if (models)
344
- sendJson(response, 200, models);
345
- else
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 false;
389
+ return null;
350
390
  }
351
- export async function createSubscriptionAuthServer(options) {
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 maxProxyBodyBytes = proxyBodyLimit(options.maxProxyBodyBytes);
355
- const host = options.host ?? "127.0.0.1";
356
- if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") {
357
- throw new Error("The built-in auth server may only bind to localhost");
358
- }
359
- const origin = `http://${urlHost(host)}`;
360
- const server = createServer(async (request, response) => {
361
- try {
362
- const url = new URL(request.url ?? "/", origin);
363
- if (url.pathname === "/health")
364
- return sendJson(response, 200, { ok: true });
365
- const headerKey = Array.isArray(request.headers["x-api-key"])
366
- ? request.headers["x-api-key"][0]
367
- : request.headers["x-api-key"];
368
- if (request.headers.authorization !== `Bearer ${options.apiKey}` &&
369
- headerKey !== options.apiKey) {
370
- return sendJson(response, 401, { error: "Unauthorized" });
371
- }
372
- if (!(await handleSubscriptionAuthApi(options.auth, request, response, url, maxProxyBodyBytes))) {
373
- sendJson(response, 404, { error: "Not found" });
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
- catch (error) {
377
- if (response.headersSent)
378
- response.destroy();
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
- sendJson(response, 400, { error: errorMessage(error) });
381
- }
455
+ await reply.code(404).send({
456
+ error: { message: "Not found", type: "invalid_request_error", code: "not_found" },
457
+ });
458
+ },
382
459
  });
383
- await new Promise((resolve, reject) => {
384
- server.once("error", reject);
385
- server.listen(options.port ?? 0, host, () => {
386
- server.off("error", reject);
387
- resolve();
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
- const address = server.address();
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: `${origin}:${address.port}`,
398
- close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
493
+ url: `http://${urlHost(host)}:${address.port}`,
494
+ close: () => app.close(),
399
495
  };
400
496
  }