aisubs 0.1.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.
@@ -0,0 +1,163 @@
1
+ import WebSocket from "ws";
2
+ import { errorMessage } from "./utils.js";
3
+ function rawDataBytes(data) {
4
+ if (Array.isArray(data))
5
+ return data.reduce((total, item) => total + item.byteLength, 0);
6
+ return data.byteLength;
7
+ }
8
+ function closeSocket(socket, code, reason) {
9
+ if (code >= 1000 && code <= 4999 && ![1004, 1005, 1006, 1015].includes(code)) {
10
+ socket.close(code, reason);
11
+ }
12
+ else
13
+ socket.close();
14
+ }
15
+ function upstreamHeaders(request) {
16
+ const headers = {};
17
+ request.headers.forEach((value, name) => {
18
+ if (![
19
+ "connection",
20
+ "content-length",
21
+ "host",
22
+ "sec-websocket-extensions",
23
+ "sec-websocket-key",
24
+ "sec-websocket-protocol",
25
+ "sec-websocket-version",
26
+ "upgrade",
27
+ ].includes(name)) {
28
+ headers[name] = value;
29
+ }
30
+ });
31
+ return headers;
32
+ }
33
+ function clientHeaders(request) {
34
+ const local = new Set([
35
+ "authorization",
36
+ "connection",
37
+ "cookie",
38
+ "host",
39
+ "origin",
40
+ "proxy-authorization",
41
+ "sec-websocket-extensions",
42
+ "sec-websocket-key",
43
+ "sec-websocket-protocol",
44
+ "sec-websocket-version",
45
+ "upgrade",
46
+ "x-api-key",
47
+ "x-goog-api-key",
48
+ ]);
49
+ return Object.fromEntries(Object.entries(request.headers).flatMap(([name, value]) => value == null || local.has(name)
50
+ ? []
51
+ : [[name, Array.isArray(value) ? value.join(", ") : String(value)]]));
52
+ }
53
+ /** Register a native Realtime WebSocket tunnel for providers that expose one. */
54
+ export function registerRealtimeProxy(app, auth, authenticate) {
55
+ app.route({
56
+ method: "GET",
57
+ url: "/aisubs/:provider/:account/v1/realtime",
58
+ async preValidation(request, reply) {
59
+ if (!(await authenticate(request))) {
60
+ await reply.code(401).send({
61
+ error: {
62
+ message: "Unauthorized",
63
+ type: "authentication_error",
64
+ code: "invalid_api_key",
65
+ },
66
+ });
67
+ }
68
+ },
69
+ async handler(_request, reply) {
70
+ await reply.code(426).send({
71
+ error: {
72
+ message: "Use a WebSocket connection for the Realtime endpoint",
73
+ type: "invalid_request_error",
74
+ code: "websocket_required",
75
+ },
76
+ });
77
+ },
78
+ wsHandler(socket, request) {
79
+ const params = request.params;
80
+ const url = new URL(request.url, "http://aisubs.local");
81
+ url.searchParams.delete("key");
82
+ const search = url.search;
83
+ const pending = [];
84
+ let pendingBytes = 0;
85
+ let upstream;
86
+ socket.on("message", (data, binary) => {
87
+ if (upstream?.readyState === WebSocket.OPEN) {
88
+ upstream.send(data, { binary });
89
+ return;
90
+ }
91
+ const bytes = rawDataBytes(data);
92
+ pendingBytes += bytes;
93
+ if (pending.length >= 100 || pendingBytes > 1024 * 1024) {
94
+ socket.close(1009, "Realtime startup queue exceeded");
95
+ return;
96
+ }
97
+ pending.push({ data, binary });
98
+ });
99
+ socket.once("close", (code, reason) => {
100
+ if (upstream?.readyState === WebSocket.OPEN)
101
+ closeSocket(upstream, code, reason);
102
+ else
103
+ upstream?.terminate();
104
+ });
105
+ void auth
106
+ .authorizeProxyRequest(params.provider, params.account, `realtime${search}`, {
107
+ method: "GET",
108
+ headers: clientHeaders(request),
109
+ })
110
+ .then((authorized) => {
111
+ const target = new URL(authorized.url);
112
+ target.protocol = target.protocol === "https:" ? "wss:" : "ws:";
113
+ const protocols = request.headers["sec-websocket-protocol"]
114
+ ?.split(",")
115
+ .map((value) => value.trim())
116
+ .filter(Boolean);
117
+ upstream = protocols?.length
118
+ ? new WebSocket(target, protocols, { headers: upstreamHeaders(authorized) })
119
+ : new WebSocket(target, { headers: upstreamHeaders(authorized) });
120
+ upstream.on("open", () => {
121
+ for (const item of pending)
122
+ upstream.send(item.data, { binary: item.binary });
123
+ pending.length = 0;
124
+ pendingBytes = 0;
125
+ });
126
+ upstream.on("message", (data, binary) => {
127
+ if (socket.readyState === WebSocket.OPEN)
128
+ socket.send(data, { binary });
129
+ });
130
+ upstream.on("close", (code, reason) => {
131
+ if (socket.readyState === WebSocket.OPEN)
132
+ closeSocket(socket, code, reason);
133
+ });
134
+ upstream.on("error", (error) => {
135
+ if (socket.readyState === WebSocket.OPEN) {
136
+ socket.send(JSON.stringify({
137
+ type: "error",
138
+ error: {
139
+ type: "provider_error",
140
+ code: "realtime_connection_failed",
141
+ message: errorMessage(error),
142
+ },
143
+ }));
144
+ socket.close(1011, "Provider Realtime connection failed");
145
+ }
146
+ });
147
+ })
148
+ .catch((error) => {
149
+ if (socket.readyState === WebSocket.OPEN) {
150
+ socket.send(JSON.stringify({
151
+ type: "error",
152
+ error: {
153
+ type: "provider_error",
154
+ code: "realtime_connection_failed",
155
+ message: errorMessage(error),
156
+ },
157
+ }));
158
+ socket.close(1011, "Provider Realtime connection failed");
159
+ }
160
+ });
161
+ },
162
+ });
163
+ }
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"));
@@ -3,25 +3,49 @@ import { join } from "node:path";
3
3
  import {
4
4
  FileCredentialStore,
5
5
  chatGptProvider,
6
+ claudeProvider,
6
7
  copilotProvider,
7
8
  createSubscriptionAuth,
8
9
  grokProvider,
10
+ openCodeGoProvider,
11
+ openCodeZenProvider,
9
12
  } from "aisubs";
10
13
 
11
14
  const provider = process.argv[2] ?? "chatgpt";
12
15
  const accountKey = process.argv[3] ?? "default";
13
- if (!["chatgpt", "copilot", "grok"].includes(provider)) {
14
- throw new Error("Use chatgpt, copilot, or grok");
16
+ const providers = [
17
+ chatGptProvider(),
18
+ claudeProvider(),
19
+ copilotProvider(),
20
+ grokProvider(),
21
+ openCodeGoProvider(),
22
+ openCodeZenProvider(),
23
+ ];
24
+ if (!providers.some((candidate) => candidate.id === provider)) {
25
+ throw new Error(`Use one of: ${providers.map((candidate) => candidate.id).join(", ")}`);
15
26
  }
16
27
 
17
28
  const auth = createSubscriptionAuth({
18
29
  store: new FileCredentialStore(join(homedir(), ".aisubs-demo", "credentials.json")),
19
- providers: [chatGptProvider(), copilotProvider(), grokProvider()],
30
+ providers,
20
31
  });
21
32
  const account = auth.account(provider, accountKey);
22
33
 
23
34
  if (!(await account.status()).authenticated) {
24
- const login = await account.signIn();
35
+ const apiKey =
36
+ provider === "opencode-go"
37
+ ? process.env.OPENCODE_GO_API_KEY
38
+ : provider === "opencode-zen"
39
+ ? process.env.OPENCODE_API_KEY
40
+ : undefined;
41
+ if (provider.startsWith("opencode-") && !apiKey) {
42
+ throw new Error(
43
+ provider === "opencode-go"
44
+ ? "Set OPENCODE_GO_API_KEY before signing in"
45
+ : "Set OPENCODE_API_KEY before signing in",
46
+ );
47
+ }
48
+ const login = await account.signIn(apiKey ? { apiKey } : undefined);
25
49
  if (login.prompt.mode === "browser") {
26
50
  console.log(`Open ${login.prompt.authorizationUri}`);
27
51
  } else if (login.prompt.mode === "device") {
@@ -32,7 +56,6 @@ if (!(await account.status()).authenticated) {
32
56
  console.log("Signed in; refresh credentials were saved securely.");
33
57
  }
34
58
 
35
- // In-process request: no localhost server, port, CORS, or control API key.
36
- const response = await account.proxy("models");
37
- console.log(`Provider response: ${response.status}`);
38
- console.log((await response.text()).slice(0, 1000));
59
+ // In-process SDK request: no localhost server, port, CORS, or control API key.
60
+ const catalog = await account.getModels();
61
+ console.log(catalog?.models ?? []);
@@ -1,20 +1,32 @@
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
+ claudeProvider,
7
8
  copilotProvider,
8
9
  createSubscriptionAuth,
9
10
  grokProvider,
11
+ openCodeGoProvider,
12
+ openCodeZenProvider,
10
13
  } from "aisubs";
11
14
  import { createSubscriptionAuthServer } from "aisubs/http";
12
15
 
13
- const apiKey = process.env.AISUBS_API_KEY ?? randomBytes(24).toString("hex");
16
+ const directory = join(homedir(), ".aisubs-demo");
17
+ const apiKey =
18
+ process.env.AISUBS_API_KEY ??
19
+ (await new FileApiKeyStore(join(directory, "api-key")).readOrCreate());
14
20
  const auth = createSubscriptionAuth({
15
- store: new FileCredentialStore(join(homedir(), ".aisubs-demo", "credentials.json")),
16
- providers: [chatGptProvider(), copilotProvider(), grokProvider()],
21
+ store: new FileCredentialStore(join(directory, "credentials.json")),
22
+ providers: [
23
+ chatGptProvider(),
24
+ claudeProvider(),
25
+ copilotProvider(),
26
+ grokProvider(),
27
+ openCodeGoProvider(),
28
+ openCodeZenProvider(),
29
+ ],
17
30
  });
18
31
  const server = await createSubscriptionAuthServer({ auth, apiKey, port: 4319 });
19
32
  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.3.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",
@@ -38,6 +38,7 @@
38
38
  "dashboard/public/aisubs-mark.svg",
39
39
  "examples",
40
40
  "public",
41
+ "CHANGELOG.md",
41
42
  "README.md"
42
43
  ],
43
44
  "type": "module",
@@ -93,11 +94,18 @@
93
94
  "fmt:check": "oxfmt --check .",
94
95
  "check": "node --run typecheck && node --run lint && node --run fmt:check && node --run test && node --run build"
95
96
  },
97
+ "dependencies": {
98
+ "@fastify/cors": "^11.3.0",
99
+ "@fastify/websocket": "^11.3.0",
100
+ "fastify": "^5.12.0",
101
+ "ws": "^8.18.3"
102
+ },
96
103
  "devDependencies": {
97
104
  "@tailwindcss/vite": "^4.3.3",
98
105
  "@types/node": "^26.2.0",
99
106
  "@types/react": "^19.2.18",
100
107
  "@types/react-dom": "^19.2.4",
108
+ "@types/ws": "^8.18.1",
101
109
  "@vitejs/plugin-react": "^6.0.5",
102
110
  "lucide-react": "^1.31.0",
103
111
  "oxfmt": "^0.63.0",
Binary file