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/CHANGELOG.md +17 -0
- package/README.md +141 -46
- package/dist/auth.d.ts +2 -0
- package/dist/auth.js +16 -0
- package/dist/compatibility.d.ts +14 -0
- package/dist/compatibility.js +1521 -0
- package/dist/dashboard/assets/index-CEDww1hA.css +2 -0
- package/dist/dashboard/assets/{index-V-AoOW2F.js → index-DrnM3oWy.js} +34 -33
- package/dist/dashboard/index.html +2 -2
- package/dist/dashboard.d.ts +4 -2
- package/dist/dashboard.js +152 -86
- package/dist/http.d.ts +9 -6
- package/dist/http.js +254 -283
- package/dist/providers/chatgpt.js +2 -0
- package/dist/providers/claude.js +2 -0
- package/dist/providers/copilot.js +0 -2
- package/dist/realtime.d.ts +6 -0
- package/dist/realtime.js +163 -0
- package/examples/direct.mjs +31 -8
- package/examples/server.mjs +11 -1
- package/package.json +9 -1
- package/public/aisubs-dashboard.png +0 -0
- package/dist/dashboard/assets/index-DYe3pr2a.css +0 -2
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
|
-
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
|
-
|
|
30
|
-
const
|
|
31
|
-
|
|
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
|
|
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,143 @@ 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
|
-
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
|
-
|
|
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
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
136
|
-
if (!
|
|
137
|
-
return
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
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,
|
|
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
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
-
|
|
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
|
-
}
|
|
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
|
|
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");
|
|
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
|
-
|
|
170
|
+
return auth.proxy(account.provider, account.account, path, {
|
|
237
171
|
method: request.method,
|
|
238
172
|
headers,
|
|
239
|
-
body:
|
|
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
|
-
|
|
246
|
-
return true;
|
|
178
|
+
return jsonResponse({ providers: auth.listProviders() });
|
|
247
179
|
}
|
|
248
180
|
if (request.method === "GET" && parts.join("/") === "v1/auth") {
|
|
249
|
-
|
|
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
|
-
|
|
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
|
-
|
|
263
|
-
return true;
|
|
192
|
+
return jsonResponse({ accounts: await auth.listAccounts(provider) });
|
|
264
193
|
}
|
|
265
194
|
if (request.method === "GET" && parts[3] === "details") {
|
|
266
|
-
|
|
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 =
|
|
198
|
+
const body = jsonBody(request);
|
|
271
199
|
const attempt = await auth.signIn(provider, isRecord(body) ? body : undefined);
|
|
272
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
-
|
|
344
|
-
|
|
345
|
-
|
|
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
|
|
264
|
+
return null;
|
|
350
265
|
}
|
|
351
|
-
|
|
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
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
-
|
|
381
|
-
|
|
330
|
+
await reply.code(404).send({
|
|
331
|
+
error: { message: "Not found", type: "invalid_request_error", code: "not_found" },
|
|
332
|
+
});
|
|
333
|
+
},
|
|
382
334
|
});
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
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
|
-
|
|
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:
|
|
398
|
-
close: () =>
|
|
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
|
};
|