aisubs 0.2.0 → 0.3.0

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);
28
22
  }
29
- export async function readJsonBody(request) {
30
- const body = await readBody(request, MAX_BODY_BYTES);
31
- if (body.length === 0)
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));
44
+ }
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,143 @@ 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));
61
- }
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,
67
- });
68
- response.end(JSON.stringify(body));
69
- }
70
- export function routeSegments(pathname) {
71
- return pathname.split("/").filter(Boolean).map(decodeURIComponent);
72
- }
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");
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;
83
99
  }
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) };
100
+ await reply.send(Readable.fromWeb(upstream.body));
117
101
  }
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
- : []);
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)) },
132
106
  });
133
- return text.length ? text.join("") : undefined;
134
107
  }
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
- }
156
- }
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
- });
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
+ };
121
+ }
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
+ };
176
134
  }
177
- export async function handleSubscriptionAuthApi(auth, request, response, url, maxProxyBodyBytes = MAX_PROXY_BODY_BYTES) {
135
+ export async function handleSubscriptionAuthApi(auth, request, signal) {
136
+ const url = new URL(request.url, "http://aisubs.local");
178
137
  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;
187
- }
188
- if (versioned && request.method === "GET" && upstreamParts.join("/") === "models") {
189
- const catalog = await auth.getModels(parts[1], parts[2]);
190
- sendJson(response, 200, {
138
+ const account = accountPath(parts);
139
+ if (account && request.method !== "OPTIONS") {
140
+ if (account.versioned && request.method === "GET" && account.path === "models") {
141
+ const catalog = await auth.getModels(account.provider, account.account);
142
+ return jsonResponse({
191
143
  object: "list",
192
- data: (catalog?.models ?? []).map((model) => ({
193
- id: model.id,
194
- object: "model",
195
- owned_by: parts[1],
196
- })),
144
+ data: (catalog?.models ?? []).map((model) => openAiModel(account.provider, model)),
197
145
  });
198
- return true;
199
146
  }
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
- }
147
+ if (account.versioned && request.method === "GET" && account.path.startsWith("models/")) {
148
+ const id = decodeURIComponent(account.path.slice("models/".length));
149
+ const catalog = await auth.getModels(account.provider, account.account);
150
+ const model = catalog?.models.find((candidate) => candidate.id === id);
151
+ return model
152
+ ? jsonResponse(openAiModel(account.provider, model))
153
+ : jsonResponse({
154
+ error: {
155
+ message: `Model not found: ${id}`,
156
+ type: "invalid_request_error",
157
+ code: "model_not_found",
158
+ },
159
+ }, 404);
220
160
  }
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");
161
+ const headers = requestHeaders(request);
162
+ const body = bodyBuffer(request);
163
+ url.searchParams.delete("key");
164
+ const path = `${account.path}${url.search}`;
165
+ if (account.versioned && request.method === "POST") {
166
+ const compatible = await proxyCompatible(auth, account.provider, account.account, path, body, headers, signal);
167
+ if (compatible)
168
+ return compatible;
235
169
  }
236
- const upstream = await auth.proxy(parts[1], parts[2], path, {
170
+ return auth.proxy(account.provider, account.account, path, {
237
171
  method: request.method,
238
172
  headers,
239
- body: proxyBody.length ? proxyBody : undefined,
173
+ body: body.length ? body : undefined,
174
+ signal,
240
175
  });
241
- await sendUpstream(response, chatCompletionModel ? await chatCompletionResponse(upstream, chatCompletionModel) : upstream);
242
- return true;
243
176
  }
244
177
  if (request.method === "GET" && parts.join("/") === "v1/providers") {
245
- sendJson(response, 200, { providers: auth.listProviders() });
246
- return true;
178
+ return jsonResponse({ providers: auth.listProviders() });
247
179
  }
248
180
  if (request.method === "GET" && parts.join("/") === "v1/auth") {
249
- sendJson(response, 200, { sessions: await auth.statuses() });
250
- return true;
181
+ return jsonResponse({ sessions: await auth.statuses() });
251
182
  }
252
183
  if (parts[0] === "v1" && parts[1] === "auth" && parts[2]) {
253
184
  const provider = parts[2];
254
185
  if (request.method === "GET" && parts.length === 3) {
255
- sendJson(response, 200, await auth.status(provider, {
186
+ return jsonResponse(await auth.status(provider, {
256
187
  account: url.searchParams.get("account") ?? undefined,
257
188
  validate: url.searchParams.get("validate") === "true",
258
189
  }));
259
- return true;
260
190
  }
261
191
  if (request.method === "GET" && parts[3] === "accounts") {
262
- sendJson(response, 200, { accounts: await auth.listAccounts(provider) });
263
- return true;
192
+ return jsonResponse({ accounts: await auth.listAccounts(provider) });
264
193
  }
265
194
  if (request.method === "GET" && parts[3] === "details") {
266
- sendJson(response, 200, await auth.credentialSummary(provider, url.searchParams.get("account") ?? undefined));
267
- return true;
195
+ return jsonResponse(await auth.credentialSummary(provider, url.searchParams.get("account") ?? undefined));
268
196
  }
269
197
  if (request.method === "POST" && parts[3] === "login") {
270
- const body = await readJsonBody(request);
198
+ const body = jsonBody(request);
271
199
  const attempt = await auth.signIn(provider, isRecord(body) ? body : undefined);
272
- sendJson(response, 202, {
200
+ return jsonResponse({
273
201
  id: attempt.id,
274
202
  provider: attempt.provider,
275
203
  accountKey: attempt.accountKey,
276
204
  state: attempt.state,
277
205
  prompt: attempt.prompt,
278
- });
279
- return true;
206
+ }, 202);
280
207
  }
281
208
  if (request.method === "DELETE" && parts.length === 3) {
282
209
  const accountKey = url.searchParams.get("account") ?? "default";
283
210
  await auth.signOut(provider, accountKey);
284
- sendJson(response, 200, { provider, accountKey, authenticated: false });
285
- return true;
211
+ return jsonResponse({ provider, accountKey, authenticated: false });
286
212
  }
287
213
  }
288
214
  if (request.method === "GET" && parts[0] === "v1" && parts[1] === "logins" && parts[2]) {
289
215
  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;
216
+ return attempt ? jsonResponse(attempt) : jsonResponse({ error: "Login not found" }, 404);
295
217
  }
296
218
  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;
219
+ return auth.cancelLoginAttempt(parts[2])
220
+ ? jsonResponse({ cancelled: true })
221
+ : jsonResponse({ error: "Pending login not found" }, 404);
302
222
  }
303
223
  if (request.method === "POST" && parts[0] === "v1" && parts[1] === "fetch" && parts[2]) {
304
- const body = await readJsonBody(request);
224
+ const body = jsonBody(request);
305
225
  if (!isRecord(body))
306
226
  throw new Error("Fetch body requires an absolute url");
307
227
  const targetUrl = stringValue(body.url);
@@ -322,79 +242,130 @@ export async function handleSubscriptionAuthApi(auth, request, response, url, ma
322
242
  if (!headers.has("content-type"))
323
243
  headers.set("content-type", "application/json");
324
244
  }
325
- const upstream = await auth.fetch(parts[2], targetUrl, {
245
+ return auth.fetch(parts[2], targetUrl, {
326
246
  method: stringValue(body.method) ?? (requestBody ? "POST" : "GET"),
327
247
  headers,
328
248
  body: requestBody,
249
+ signal,
329
250
  }, stringValue(body.account));
330
- await sendUpstream(response, upstream);
331
- return true;
332
251
  }
333
252
  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;
253
+ const usage = await auth.getUsage(parts[2], url.searchParams.get("account") ?? undefined, signal);
254
+ return usage
255
+ ? jsonResponse(usage)
256
+ : jsonResponse({ error: "Usage is not supported by this provider" }, 404);
340
257
  }
341
258
  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;
259
+ const models = await auth.getModels(parts[2], url.searchParams.get("account") ?? undefined, signal);
260
+ return models
261
+ ? jsonResponse(models)
262
+ : jsonResponse({ error: "Models are not supported by this provider" }, 404);
348
263
  }
349
- return false;
264
+ return null;
350
265
  }
351
- export async function createSubscriptionAuthServer(options) {
266
+ /** Abort upstream work only when the client disconnects before the response finishes. */
267
+ export function clientAbortSignal(request, reply) {
268
+ const controller = new AbortController();
269
+ const abort = () => {
270
+ if (!reply.raw.writableFinished && !controller.signal.aborted)
271
+ controller.abort();
272
+ };
273
+ const cleanup = () => {
274
+ request.raw.off("aborted", abort);
275
+ reply.raw.off("close", abort);
276
+ };
277
+ request.raw.once("aborted", abort);
278
+ reply.raw.once("close", abort);
279
+ reply.raw.once("finish", cleanup);
280
+ if (request.raw.aborted || (reply.raw.destroyed && !reply.raw.writableFinished))
281
+ abort();
282
+ return controller.signal;
283
+ }
284
+ export function createApiApp(options) {
352
285
  if (!options.apiKey)
353
286
  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
- }
287
+ const app = Fastify({
288
+ bodyLimit: bodyLimit(options.maxProxyBodyBytes),
289
+ forceCloseConnections: true,
290
+ });
291
+ app.removeAllContentTypeParsers();
292
+ app.addContentTypeParser("*", { parseAs: "buffer" }, (_request, body, done) => done(null, body));
293
+ void app.register(cors, {
294
+ origin: true,
295
+ credentials: false,
296
+ methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
297
+ allowedHeaders: [
298
+ "authorization",
299
+ "content-type",
300
+ "x-api-key",
301
+ "x-goog-api-key",
302
+ "anthropic-version",
303
+ "anthropic-beta",
304
+ "openai-beta",
305
+ ],
306
+ exposedHeaders: ["x-request-id", "retry-after"],
307
+ });
308
+ void app.register(websocket);
309
+ app.get("/health", async () => ({ ok: true }));
310
+ app.addHook("onRequest", async (request, reply) => {
311
+ if (request.url === "/health")
312
+ return;
313
+ if (!requestApiKeys(request).some((value) => sameSecret(value, options.apiKey))) {
314
+ await reply.code(401).send({
315
+ error: { message: "Unauthorized", type: "authentication_error", code: "invalid_api_key" },
316
+ });
375
317
  }
376
- catch (error) {
377
- if (response.headersSent)
378
- response.destroy();
318
+ });
319
+ void app.register(async (scope) => {
320
+ registerRealtimeProxy(scope, options.auth, (request) => requestApiKeys(request).some((value) => sameSecret(value, options.apiKey)));
321
+ });
322
+ app.route({
323
+ method: ["GET", "POST", "PUT", "PATCH", "DELETE"],
324
+ url: "/*",
325
+ async handler(request, reply) {
326
+ const response = await handleSubscriptionAuthApi(options.auth, request, clientAbortSignal(request, reply));
327
+ if (response)
328
+ await sendWebResponse(reply, response);
379
329
  else
380
- sendJson(response, 400, { error: errorMessage(error) });
381
- }
330
+ await reply.code(404).send({
331
+ error: { message: "Not found", type: "invalid_request_error", code: "not_found" },
332
+ });
333
+ },
382
334
  });
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
- });
335
+ app.setErrorHandler(async (error, request, reply) => {
336
+ const statusCode = isRecord(error) ? numberValue(error.statusCode) : undefined;
337
+ const status = statusCode && statusCode >= 400 ? statusCode : 400;
338
+ const code = isRecord(error) ? stringValue(error.code) : undefined;
339
+ const openAi = request.url.startsWith("/aisubs/");
340
+ await reply.code(status).send(openAi
341
+ ? {
342
+ error: {
343
+ message: errorMessage(error),
344
+ type: "invalid_request_error",
345
+ code: code ?? "invalid_request_error",
346
+ },
347
+ }
348
+ : { error: errorMessage(error) });
389
349
  });
390
- const address = server.address();
350
+ return app;
351
+ }
352
+ export async function createSubscriptionAuthServer(options) {
353
+ const host = options.host ?? "127.0.0.1";
354
+ if (host !== "127.0.0.1" && host !== "::1" && host !== "localhost") {
355
+ throw new Error("The built-in auth server may only bind to localhost");
356
+ }
357
+ const app = createApiApp(options);
358
+ await app.listen({ port: options.port ?? 0, host });
359
+ const address = app.server.address();
391
360
  if (!address || typeof address === "string") {
361
+ await app.close();
392
362
  throw new Error("Unable to determine auth server port");
393
363
  }
394
364
  return {
395
- server,
365
+ server: app.server,
366
+ app,
396
367
  apiKey: options.apiKey,
397
- url: `${origin}:${address.port}`,
398
- close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
368
+ url: `http://${urlHost(host)}:${address.port}`,
369
+ close: () => app.close(),
399
370
  };
400
371
  }
@@ -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
  };