aisubs 0.1.0 → 0.2.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.
@@ -6,8 +6,8 @@
6
6
  <meta name="color-scheme" content="light dark" />
7
7
  <meta name="theme-color" content="#181818" />
8
8
  <title>AI Subs</title>
9
- <script type="module" crossorigin src="/assets/index-BMoILzPw.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-DHqDdNVe.css">
9
+ <script type="module" crossorigin src="/assets/index-V-AoOW2F.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-DYe3pr2a.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
@@ -3,6 +3,7 @@ import type { SubscriptionAuth } from "./auth.js";
3
3
  export interface SubscriptionAuthDashboardOptions {
4
4
  auth: SubscriptionAuth;
5
5
  apiKey?: string;
6
+ regenerateApiKey?: () => Promise<string>;
6
7
  host?: string;
7
8
  port?: number;
8
9
  /** Maximum buffered proxy request body. Defaults to 10 MiB. */
package/dist/dashboard.js CHANGED
@@ -37,35 +37,62 @@ function secure(response) {
37
37
  response.setHeader("x-content-type-options", "nosniff");
38
38
  response.setHeader("x-frame-options", "DENY");
39
39
  }
40
+ function hostname(value) {
41
+ if (!value)
42
+ return undefined;
43
+ try {
44
+ return new URL(`http://${value}`).hostname.replace(/^\[|\]$/g, "");
45
+ }
46
+ catch {
47
+ return undefined;
48
+ }
49
+ }
40
50
  export async function createSubscriptionAuthDashboardServer(options) {
41
51
  const host = options.host ?? "127.0.0.1";
42
52
  if (!["127.0.0.1", "::1", "localhost"].includes(host)) {
43
53
  throw new Error("The AI Subs dashboard may only bind to localhost");
44
54
  }
45
55
  const origin = `http://${urlHost(host)}`;
46
- const apiKey = options.apiKey ?? randomBytes(32).toString("base64url");
47
- const bootstrapToken = randomBytes(32).toString("base64url");
56
+ let apiKey = options.apiKey ?? `aisubs_${randomBytes(32).toString("base64url")}`;
57
+ let regeneratingApiKey;
48
58
  const sessionToken = randomBytes(32).toString("base64url");
49
- let bootstrapAvailable = true;
59
+ const requestLogs = [];
60
+ const logStreams = new Set();
61
+ let requestId = 0;
50
62
  const server = createServer(async (request, response) => {
51
63
  secure(response);
52
64
  try {
65
+ if (hostname(request.headers.host) !== host) {
66
+ return sendJson(response, 421, { error: "Invalid local host" });
67
+ }
53
68
  const url = new URL(request.url ?? "/", origin);
54
- if (url.pathname === "/health")
55
- return sendJson(response, 200, { ok: true });
56
- if (request.method === "GET" && url.pathname === "/bootstrap") {
57
- if (!bootstrapAvailable ||
58
- !sameSecret(url.searchParams.get("token") ?? undefined, bootstrapToken)) {
59
- return sendJson(response, 401, { error: "Bootstrap link is invalid or has expired" });
60
- }
61
- bootstrapAvailable = false;
62
- response.writeHead(302, {
63
- location: "/",
64
- "cache-control": "no-store",
65
- "set-cookie": `aisubs_session=${encodeURIComponent(sessionToken)}; HttpOnly; SameSite=Strict; Path=/`,
69
+ if (url.pathname.startsWith("/aisubs/")) {
70
+ const startedAt = performance.now();
71
+ response.once("finish", () => {
72
+ const entry = {
73
+ id: ++requestId,
74
+ timestamp: Date.now(),
75
+ method: request.method ?? "GET",
76
+ path: url.pathname,
77
+ status: response.statusCode,
78
+ durationMs: Math.round(performance.now() - startedAt),
79
+ };
80
+ requestLogs.push(entry);
81
+ if (requestLogs.length > 200)
82
+ requestLogs.shift();
83
+ const event = `data: ${JSON.stringify(entry)}\n\n`;
84
+ for (const stream of logStreams)
85
+ stream.write(event);
86
+ });
87
+ }
88
+ if (url.pathname === "/health") {
89
+ return sendJson(response, 200, { ok: true }, {
90
+ "x-aisubs-service": "aisubs",
91
+ "x-aisubs-pid": String(process.pid),
66
92
  });
67
- response.end();
68
- return;
93
+ }
94
+ if (request.method === "GET" && url.pathname === "/") {
95
+ response.setHeader("set-cookie", `aisubs_session=${encodeURIComponent(sessionToken)}; HttpOnly; SameSite=Strict; Path=/`);
69
96
  }
70
97
  const bearer = request.headers.authorization?.startsWith("Bearer ")
71
98
  ? request.headers.authorization.slice(7)
@@ -75,16 +102,42 @@ export async function createSubscriptionAuthDashboardServer(options) {
75
102
  : request.headers["x-api-key"];
76
103
  const bearerAuthenticated = sameSecret(bearer, apiKey) || sameSecret(headerKey, apiKey);
77
104
  const cookieAuthenticated = sameSecret(cookie(request, "aisubs_session"), sessionToken);
78
- if (!bearerAuthenticated && !cookieAuthenticated) {
105
+ const apiRoute = ["v1", "aisubs"].includes(routeSegments(url.pathname)[0] ?? "");
106
+ if (apiRoute && !bearerAuthenticated && !cookieAuthenticated) {
79
107
  return sendJson(response, 401, { error: "Unauthorized" });
80
108
  }
81
- if (cookieAuthenticated &&
82
- !bearerAuthenticated &&
83
- !["GET", "HEAD", "OPTIONS"].includes(request.method ?? "GET") &&
84
- request.headers.origin !== `http://${request.headers.host}`) {
85
- return sendJson(response, 403, { error: "Cross-origin mutation blocked" });
109
+ if (request.method === "GET" && url.pathname === "/v1/logs/stream") {
110
+ response.writeHead(200, {
111
+ "content-type": "text/event-stream",
112
+ "cache-control": "no-store",
113
+ connection: "keep-alive",
114
+ });
115
+ response.flushHeaders();
116
+ for (const entry of requestLogs)
117
+ response.write(`data: ${JSON.stringify(entry)}\n\n`);
118
+ logStreams.add(response);
119
+ request.once("close", () => logStreams.delete(response));
120
+ return;
121
+ }
122
+ if (apiRoute && cookieAuthenticated && !bearerAuthenticated) {
123
+ if (!["GET", "HEAD", "OPTIONS"].includes(request.method ?? "GET") &&
124
+ request.headers.origin !== `http://${request.headers.host}`) {
125
+ return sendJson(response, 403, { error: "Cross-origin mutation blocked" });
126
+ }
127
+ if (request.method === "GET" && url.pathname === "/v1/api-key") {
128
+ return sendJson(response, 200, { apiKey });
129
+ }
130
+ if (request.method === "POST" && url.pathname === "/v1/api-key/regenerate") {
131
+ regeneratingApiKey ??= (options.regenerateApiKey
132
+ ? options.regenerateApiKey()
133
+ : Promise.resolve(`aisubs_${randomBytes(32).toString("base64url")}`)).finally(() => {
134
+ regeneratingApiKey = undefined;
135
+ });
136
+ apiKey = await regeneratingApiKey;
137
+ return sendJson(response, 200, { apiKey });
138
+ }
86
139
  }
87
- if (["v1", "aisubs"].includes(routeSegments(url.pathname)[0] ?? "")) {
140
+ if (apiRoute) {
88
141
  if (!(await handleSubscriptionAuthApi(options.auth, request, response, url, options.maxProxyBodyBytes))) {
89
142
  sendJson(response, 404, { error: "Not found" });
90
143
  }
@@ -131,9 +184,15 @@ export async function createSubscriptionAuthDashboardServer(options) {
131
184
  const url = `${origin}:${address.port}`;
132
185
  return {
133
186
  server,
134
- apiKey,
187
+ get apiKey() {
188
+ return apiKey;
189
+ },
135
190
  url,
136
- bootstrapUrl: `${url}/bootstrap?token=${encodeURIComponent(bootstrapToken)}`,
137
- close: () => new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
191
+ bootstrapUrl: url,
192
+ close: () => {
193
+ for (const stream of logStreams)
194
+ stream.end();
195
+ return new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())));
196
+ },
138
197
  };
139
198
  }
package/dist/http.js CHANGED
@@ -70,9 +70,133 @@ export function sendJson(response, status, body, headers = {}) {
70
70
  export function routeSegments(pathname) {
71
71
  return pathname.split("/").filter(Boolean).map(decodeURIComponent);
72
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");
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) };
117
+ }
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
+ : []);
132
+ });
133
+ return text.length ? text.join("") : undefined;
134
+ }
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
+ });
176
+ }
73
177
  export async function handleSubscriptionAuthApi(auth, request, response, url, maxProxyBodyBytes = MAX_PROXY_BODY_BYTES) {
74
178
  const parts = routeSegments(url.pathname);
75
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, {
191
+ object: "list",
192
+ data: (catalog?.models ?? []).map((model) => ({
193
+ id: model.id,
194
+ object: "model",
195
+ owned_by: parts[1],
196
+ })),
197
+ });
198
+ return true;
199
+ }
76
200
  const headers = new Headers();
77
201
  const privateHeaders = new Set([
78
202
  "authorization",
@@ -95,13 +219,26 @@ export async function handleSubscriptionAuthApi(auth, request, response, url, ma
95
219
  }
96
220
  }
97
221
  const body = await readProxyBody(request, maxProxyBodyBytes);
98
- const path = parts.slice(3).map(encodeURIComponent).join("/") + url.search;
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");
235
+ }
99
236
  const upstream = await auth.proxy(parts[1], parts[2], path, {
100
237
  method: request.method,
101
238
  headers,
102
- body: body.length ? body : undefined,
239
+ body: proxyBody.length ? proxyBody : undefined,
103
240
  });
104
- await sendUpstream(response, upstream);
241
+ await sendUpstream(response, chatCompletionModel ? await chatCompletionResponse(upstream, chatCompletionModel) : upstream);
105
242
  return true;
106
243
  }
107
244
  if (request.method === "GET" && parts.join("/") === "v1/providers") {
package/dist/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  export { createSubscriptionAuth, DEFAULT_ACCOUNT, SubscriptionAuth, type SubscriptionAccount, type SubscriptionAuthOptions, } from "./auth.js";
2
- export { defaultAiSubsDataDir, FileCredentialStore, MemoryCredentialStore } from "./store.js";
2
+ export { defaultAiSubsDataDir, FileApiKeyStore, FileCredentialStore, MemoryCredentialStore, } from "./store.js";
3
3
  export { chatGptProvider, type ChatGptProviderOptions } from "./providers/chatgpt.js";
4
4
  export { claudeProvider, type ClaudeProviderOptions } from "./providers/claude.js";
5
5
  export { copilotProvider, type CopilotProviderOptions } from "./providers/copilot.js";
package/dist/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  export { createSubscriptionAuth, DEFAULT_ACCOUNT, SubscriptionAuth, } from "./auth.js";
2
- export { defaultAiSubsDataDir, FileCredentialStore, MemoryCredentialStore } from "./store.js";
2
+ export { defaultAiSubsDataDir, FileApiKeyStore, FileCredentialStore, MemoryCredentialStore, } from "./store.js";
3
3
  export { chatGptProvider } from "./providers/chatgpt.js";
4
4
  export { claudeProvider } from "./providers/claude.js";
5
5
  export { copilotProvider } from "./providers/copilot.js";
package/dist/store.d.ts CHANGED
@@ -1,5 +1,11 @@
1
1
  import type { CredentialStore, OAuthCredential, ProviderId } from "./types.js";
2
2
  export declare function defaultAiSubsDataDir(): string;
3
+ export declare class FileApiKeyStore {
4
+ readonly file: string;
5
+ constructor(file: string);
6
+ readOrCreate(): Promise<string>;
7
+ regenerate(): Promise<string>;
8
+ }
3
9
  export declare class FileCredentialStore implements CredentialStore {
4
10
  readonly file: string;
5
11
  constructor(file: string);
package/dist/store.js CHANGED
@@ -1,5 +1,5 @@
1
- import { createHash } from "node:crypto";
2
- import { chmod, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { chmod, link, mkdir, open, readFile, rename, rm, stat, writeFile } from "node:fs/promises";
3
3
  import { homedir } from "node:os";
4
4
  import { dirname, join, resolve } from "node:path";
5
5
  import { abortableDelay, isRecord } from "./utils.js";
@@ -9,6 +9,55 @@ export function defaultAiSubsDataDir() {
9
9
  const override = process.env.AISUBS_DATA_DIR?.trim();
10
10
  return override ? resolve(override) : join(homedir(), ".aisubs");
11
11
  }
12
+ export class FileApiKeyStore {
13
+ file;
14
+ constructor(file) {
15
+ this.file = file;
16
+ }
17
+ async readOrCreate() {
18
+ try {
19
+ const value = (await readFile(this.file, "utf8")).trim();
20
+ if (value)
21
+ return value;
22
+ }
23
+ catch (error) {
24
+ if (error.code !== "ENOENT")
25
+ throw error;
26
+ }
27
+ const value = `aisubs_${randomBytes(32).toString("base64url")}`;
28
+ const temp = `${this.file}.${process.pid}.${crypto.randomUUID()}.tmp`;
29
+ await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
30
+ try {
31
+ await writeFile(temp, `${value}\n`, { mode: 0o600 });
32
+ try {
33
+ await link(temp, this.file);
34
+ return value;
35
+ }
36
+ catch (error) {
37
+ if (error.code !== "EEXIST")
38
+ throw error;
39
+ const existing = (await readFile(this.file, "utf8")).trim();
40
+ return existing || this.regenerate();
41
+ }
42
+ }
43
+ finally {
44
+ await rm(temp, { force: true }).catch(() => { });
45
+ }
46
+ }
47
+ async regenerate() {
48
+ const value = `aisubs_${randomBytes(32).toString("base64url")}`;
49
+ const temp = `${this.file}.${process.pid}.${crypto.randomUUID()}.tmp`;
50
+ await mkdir(dirname(this.file), { recursive: true, mode: 0o700 });
51
+ try {
52
+ await writeFile(temp, `${value}\n`, { mode: 0o600 });
53
+ await rename(temp, this.file);
54
+ return value;
55
+ }
56
+ finally {
57
+ await rm(temp, { force: true }).catch(() => { });
58
+ }
59
+ }
60
+ }
12
61
  async function readEnvelope(file) {
13
62
  try {
14
63
  const parsed = JSON.parse(await readFile(file, "utf8"));
@@ -1,8 +1,8 @@
1
- import { randomBytes } from "node:crypto";
2
1
  import { homedir } from "node:os";
3
2
  import { join } from "node:path";
4
3
  import {
5
4
  FileCredentialStore,
5
+ FileApiKeyStore,
6
6
  chatGptProvider,
7
7
  copilotProvider,
8
8
  createSubscriptionAuth,
@@ -10,11 +10,13 @@ import {
10
10
  } from "aisubs";
11
11
  import { createSubscriptionAuthServer } from "aisubs/http";
12
12
 
13
- const apiKey = process.env.AISUBS_API_KEY ?? randomBytes(24).toString("hex");
13
+ const directory = join(homedir(), ".aisubs-demo");
14
+ const apiKey =
15
+ process.env.AISUBS_API_KEY ??
16
+ (await new FileApiKeyStore(join(directory, "api-key")).readOrCreate());
14
17
  const auth = createSubscriptionAuth({
15
- store: new FileCredentialStore(join(homedir(), ".aisubs-demo", "credentials.json")),
18
+ store: new FileCredentialStore(join(directory, "credentials.json")),
16
19
  providers: [chatGptProvider(), copilotProvider(), grokProvider()],
17
20
  });
18
21
  const server = await createSubscriptionAuthServer({ auth, apiKey, port: 4319 });
19
22
  console.log(`AI Subs API: ${server.url}`);
20
- console.log(`API key: ${apiKey}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aisubs",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Connect AI provider accounts and use those subscriptions from any local tool or as api.",
5
5
  "keywords": [
6
6
  "ai",
Binary file