aisubs 0.3.6 → 0.3.8

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-BbN84qld.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-BJDbjHnw.css">
9
+ <script type="module" crossorigin src="/assets/index-Vscu32Dz.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-CePTUw7Z.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/dist/dashboard.js CHANGED
@@ -2,14 +2,14 @@ import cors from "@fastify/cors";
2
2
  import websocket from "@fastify/websocket";
3
3
  import Fastify from "fastify";
4
4
  import { spawn } from "node:child_process";
5
- import { randomBytes, timingSafeEqual } from "node:crypto";
5
+ import { randomBytes } from "node:crypto";
6
6
  import { access, readFile, writeFile } from "node:fs/promises";
7
7
  import { homedir } from "node:os";
8
8
  import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
9
9
  import { fileURLToPath } from "node:url";
10
- import { clientAbortSignal, handleSubscriptionAuthApi, routeSegments, sendWebResponse, } from "./http.js";
10
+ import { clientAbortSignal, handleSubscriptionAuthApi, routeSegments, sendWebResponse, bodyLimit, requestApiKeys, sameSecret, } from "./http.js";
11
11
  import { registerRealtimeProxy } from "./realtime.js";
12
- import { errorMessage, isRecord, stringValue, urlHost } from "./utils.js";
12
+ import { errorMessage, isRecord, numberValue, stringValue, urlHost } from "./utils.js";
13
13
  const ASSET_DIRECTORY = join(dirname(fileURLToPath(import.meta.url)), "dashboard");
14
14
  const CONTENT_TYPES = {
15
15
  ".css": "text/css; charset=utf-8",
@@ -18,23 +18,6 @@ const CONTENT_TYPES = {
18
18
  ".svg": "image/svg+xml",
19
19
  ".woff2": "font/woff2",
20
20
  };
21
- function sameSecret(actual, expected) {
22
- if (!actual)
23
- return false;
24
- const left = Buffer.from(actual);
25
- const right = Buffer.from(expected);
26
- return left.length === right.length && timingSafeEqual(left, right);
27
- }
28
- function requestApiKeys(request) {
29
- const authorization = request.headers.authorization;
30
- const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7) : undefined;
31
- const header = (name) => {
32
- const value = request.headers[name];
33
- return Array.isArray(value) ? value[0] : value;
34
- };
35
- const queryKey = new URL(request.url, "http://aisubs.local").searchParams.get("key") ?? undefined;
36
- return [bearer, header("x-api-key"), header("x-goog-api-key"), queryKey].filter((value) => value != null);
37
- }
38
21
  function cookie(request, name) {
39
22
  for (const part of request.headers.cookie?.split(";") ?? []) {
40
23
  const [key, ...value] = part.trim().split("=");
@@ -123,7 +106,7 @@ export async function createSubscriptionAuthDashboardServer(options) {
123
106
  const logStreams = new Set();
124
107
  let requestId = 0;
125
108
  const app = Fastify({
126
- bodyLimit: options.maxProxyBodyBytes ?? 10 * 1024 * 1024,
109
+ bodyLimit: bodyLimit(options.maxProxyBodyBytes),
127
110
  forceCloseConnections: true,
128
111
  });
129
112
  app.removeAllContentTypeParsers();
@@ -147,7 +130,8 @@ export async function createSubscriptionAuthDashboardServer(options) {
147
130
  await app.register(async (scope) => {
148
131
  registerRealtimeProxy(scope, options.auth, (request) => {
149
132
  return (requestApiKeys(request).some((value) => sameSecret(value, apiKey)) ||
150
- sameSecret(cookie(request, "aisubs_session"), sessionToken));
133
+ (sameSecret(cookie(request, "aisubs_session"), sessionToken) &&
134
+ request.headers.origin === `http://${request.headers.host}`));
151
135
  });
152
136
  });
153
137
  app.addHook("onRequest", async (request, reply) => {
@@ -173,8 +157,10 @@ export async function createSubscriptionAuthDashboardServer(options) {
173
157
  if (requestLogs.length > 200)
174
158
  requestLogs.shift();
175
159
  const event = `data: ${JSON.stringify(entry)}\n\n`;
176
- for (const stream of logStreams)
177
- stream.write(event);
160
+ for (const stream of logStreams) {
161
+ if (!stream.write(event))
162
+ stream.destroy();
163
+ }
178
164
  });
179
165
  }
180
166
  });
@@ -217,7 +203,7 @@ export async function createSubscriptionAuthDashboardServer(options) {
217
203
  for (const entry of requestLogs)
218
204
  reply.raw.write(`data: ${JSON.stringify(entry)}\n\n`);
219
205
  logStreams.add(reply.raw);
220
- request.raw.once("close", () => logStreams.delete(reply.raw));
206
+ reply.raw.once("close", () => logStreams.delete(reply.raw));
221
207
  return;
222
208
  }
223
209
  if (apiRoute && cookieAuthenticated && !bearerAuthenticated) {
@@ -293,7 +279,10 @@ export async function createSubscriptionAuthDashboardServer(options) {
293
279
  });
294
280
  app.setErrorHandler(async (error, request, reply) => {
295
281
  requestErrors.set(request, errorMessage(error));
296
- await reply.code(400).send({ error: errorMessage(error) });
282
+ const status = isRecord(error) ? numberValue(error.statusCode) : undefined;
283
+ await reply
284
+ .code(status && status >= 400 && status <= 599 ? status : 400)
285
+ .send({ error: errorMessage(error) });
297
286
  });
298
287
  await app.listen({ port: options.port ?? 0, host });
299
288
  const address = app.server.address();
package/dist/http.d.ts CHANGED
@@ -1,6 +1,9 @@
1
1
  import { type FastifyInstance, type FastifyReply, type FastifyRequest } from "fastify";
2
2
  import type { Server } from "node:http";
3
3
  import type { SubscriptionAuth } from "./auth.js";
4
+ export declare function bodyLimit(value?: number): number;
5
+ export declare function sameSecret(actual: string | undefined, expected: string): boolean;
6
+ export declare function requestApiKeys(request: FastifyRequest): string[];
4
7
  export declare function routeSegments(pathname: string): string[];
5
8
  export declare function sendWebResponse(reply: FastifyReply, upstream: Response): Promise<void>;
6
9
  export declare function handleSubscriptionAuthApi(auth: SubscriptionAuth, request: FastifyRequest, signal?: AbortSignal): Promise<Response | null>;
package/dist/http.js CHANGED
@@ -6,21 +6,22 @@ import { Readable } from "node:stream";
6
6
  import { proxyCompatible } from "./compatibility.js";
7
7
  import { registerRealtimeProxy } from "./realtime.js";
8
8
  import { errorMessage, isRecord, numberValue, stringValue, urlHost } from "./utils.js";
9
+ import { proxyRequestHeaders } from "./proxy-headers.js";
9
10
  const MAX_PROXY_BODY_BYTES = 10 * 1024 * 1024;
10
- function bodyLimit(value = MAX_PROXY_BODY_BYTES) {
11
+ export function bodyLimit(value = MAX_PROXY_BODY_BYTES) {
11
12
  if (!Number.isSafeInteger(value) || value <= 0) {
12
13
  throw new Error("maxProxyBodyBytes must be a positive safe integer");
13
14
  }
14
15
  return value;
15
16
  }
16
- function sameSecret(actual, expected) {
17
+ export function sameSecret(actual, expected) {
17
18
  if (!actual)
18
19
  return false;
19
20
  const left = Buffer.from(actual);
20
21
  const right = Buffer.from(expected);
21
22
  return left.length === right.length && timingSafeEqual(left, right);
22
23
  }
23
- function requestApiKeys(request) {
24
+ export function requestApiKeys(request) {
24
25
  const authorization = request.headers.authorization;
25
26
  const bearer = authorization?.startsWith("Bearer ") ? authorization.slice(7) : undefined;
26
27
  const header = (name) => {
@@ -50,28 +51,12 @@ function jsonBody(request) {
50
51
  }
51
52
  function requestHeaders(request) {
52
53
  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
54
  for (const [name, value] of Object.entries(request.headers)) {
70
- if (value != null && !privateHeaders.has(name)) {
55
+ if (value != null) {
71
56
  headers.set(name, Array.isArray(value) ? value.join(", ") : String(value));
72
57
  }
73
58
  }
74
- return headers;
59
+ return proxyRequestHeaders(headers);
75
60
  }
76
61
  function responseHeaders(upstream) {
77
62
  const headers = new Headers({ "cache-control": "no-store" });
@@ -127,6 +112,7 @@ function openAiModel(provider, model) {
127
112
  capabilities: {
128
113
  endpoints: model.endpoints ?? [],
129
114
  input_modalities: model.inputModalities ?? ["text"],
115
+ output_modalities: model.outputModalities ?? ["text"],
130
116
  reasoning_efforts: model.reasoningEfforts ?? [],
131
117
  tools: model.supportsToolCall ?? false,
132
118
  },
@@ -144,17 +130,11 @@ function codexResponsesBody(input, model) {
144
130
  if (Array.isArray(body.input)) {
145
131
  body.input = body.input
146
132
  .filter((item) => isRecord(item) &&
147
- ["message", "function_call", "function_call_output"].includes(String(item.type)))
133
+ (item.type === undefined ||
134
+ ["message", "function_call", "function_call_output"].includes(String(item.type))))
148
135
  .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 };
136
+ if (item.type === "message" || item.type === undefined) {
137
+ return { type: "message", role: stringValue(item.role) ?? "user", content: item.content };
158
138
  }
159
139
  return item;
160
140
  });
@@ -394,10 +374,12 @@ export function clientAbortSignal(request, reply) {
394
374
  const abort = () => {
395
375
  if (!reply.raw.writableFinished && !controller.signal.aborted)
396
376
  controller.abort();
377
+ cleanup();
397
378
  };
398
379
  const cleanup = () => {
399
380
  request.raw.off("aborted", abort);
400
381
  reply.raw.off("close", abort);
382
+ reply.raw.off("finish", cleanup);
401
383
  };
402
384
  request.raw.once("aborted", abort);
403
385
  reply.raw.once("close", abort);
package/dist/index.d.ts CHANGED
@@ -1,9 +1,4 @@
1
1
  export { createSubscriptionAuth, DEFAULT_ACCOUNT, SubscriptionAuth, type SubscriptionAccount, type SubscriptionAuthOptions, } from "./auth.js";
2
- export { defaultAiSubsDataDir, FileApiKeyStore, FileCredentialStore, MemoryCredentialStore, } from "./store.js";
3
- export { chatGptProvider, type ChatGptProviderOptions } from "./providers/chatgpt.js";
4
- export { claudeProvider, type ClaudeProviderOptions } from "./providers/claude.js";
5
- export { copilotProvider, type CopilotProviderOptions } from "./providers/copilot.js";
6
- export { grokProvider, type GrokProviderOptions } from "./providers/grok.js";
7
- export { openCodeGoProvider, openCodeZenProvider } from "./providers/opencode.js";
2
+ export { MemoryCredentialStore } from "./memory-store.js";
8
3
  export { parseChatGptUsage, parseCopilotUsage, parseGrokUsage } from "./usage.js";
9
- export type { CredentialStore, CredentialSummary, BrowserLoginPrompt, DeviceLoginPrompt, ImmediateLoginPrompt, LoginAttempt, LoginMode, LoginPrompt, LoginState, OAuthCredential, ProviderAdapter, ProviderId, ProviderLogin, ProviderLoginField, ProviderModel, ProviderModels, ProviderSummary, ProviderUsage, ProviderUsageContext, ProviderUsageData, Session, SubscriptionAccountDetails, UsageFact, UsageMeter, UsageResetCredit, UsageResetCredits, } from "./types.js";
4
+ export type { CredentialStore, CoordinatedCredentialStore, CredentialSummary, BrowserLoginPrompt, DeviceLoginPrompt, ImmediateLoginPrompt, LoginAttempt, LoginMode, LoginPrompt, LoginState, OAuthCredential, VersionedCredential, ProviderAdapter, ProviderId, ProviderLogin, ProviderLoginField, ProviderModel, ProviderModels, ProviderSummary, ProviderUsage, ProviderUsageContext, ProviderUsageData, Session, SubscriptionAccountDetails, UsageFact, UsageMeter, UsageResetCredit, UsageResetCredits, } from "./types.js";
package/dist/index.js CHANGED
@@ -1,8 +1,3 @@
1
1
  export { createSubscriptionAuth, DEFAULT_ACCOUNT, SubscriptionAuth, } from "./auth.js";
2
- export { defaultAiSubsDataDir, FileApiKeyStore, FileCredentialStore, MemoryCredentialStore, } from "./store.js";
3
- export { chatGptProvider } from "./providers/chatgpt.js";
4
- export { claudeProvider } from "./providers/claude.js";
5
- export { copilotProvider } from "./providers/copilot.js";
6
- export { grokProvider } from "./providers/grok.js";
7
- export { openCodeGoProvider, openCodeZenProvider } from "./providers/opencode.js";
2
+ export { MemoryCredentialStore } from "./memory-store.js";
8
3
  export { parseChatGptUsage, parseCopilotUsage, parseGrokUsage } from "./usage.js";
@@ -0,0 +1,9 @@
1
+ import type { CredentialStore, OAuthCredential, ProviderId } from "./types.js";
2
+ export declare class MemoryCredentialStore implements CredentialStore {
3
+ private readonly values;
4
+ private readonly queues;
5
+ read(provider: ProviderId): Promise<OAuthCredential | null>;
6
+ listKeys(): Promise<string[]>;
7
+ modify(provider: ProviderId, update: (current: OAuthCredential | null) => OAuthCredential | null | Promise<OAuthCredential | null>): Promise<OAuthCredential | null>;
8
+ delete(provider: ProviderId): Promise<void>;
9
+ }
@@ -0,0 +1,34 @@
1
+ export class MemoryCredentialStore {
2
+ values = new Map();
3
+ queues = new Map();
4
+ async read(provider) {
5
+ return this.values.get(provider) ?? null;
6
+ }
7
+ async listKeys() {
8
+ return [...this.values.keys()];
9
+ }
10
+ async modify(provider, update) {
11
+ const previous = this.queues.get(provider) ?? Promise.resolve();
12
+ let result = null;
13
+ const current = previous.then(async () => {
14
+ result = await update(this.values.get(provider) ?? null);
15
+ if (result)
16
+ this.values.set(provider, result);
17
+ else
18
+ this.values.delete(provider);
19
+ });
20
+ const settled = current.catch(() => { });
21
+ this.queues.set(provider, settled);
22
+ try {
23
+ await current;
24
+ }
25
+ finally {
26
+ if (this.queues.get(provider) === settled)
27
+ this.queues.delete(provider);
28
+ }
29
+ return result;
30
+ }
31
+ async delete(provider) {
32
+ await this.modify(provider, () => null);
33
+ }
34
+ }
package/dist/node.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ export { defaultAiSubsDataDir } from "./store.js";
2
+ export { SqliteApiKeyStore, SqliteCredentialStore } from "./sqlite-store.js";
package/dist/node.js ADDED
@@ -0,0 +1,2 @@
1
+ export { defaultAiSubsDataDir } from "./store.js";
2
+ export { SqliteApiKeyStore, SqliteCredentialStore } from "./sqlite-store.js";
@@ -1,4 +1,5 @@
1
- import type { ProviderAdapter } from "../types.js";
1
+ import type { ProviderAdapter, ProviderModel } from "../types.js";
2
+ export declare function parseOpenAiImageModels(html: string): ProviderModel[];
2
3
  export interface ChatGptProviderOptions {
3
4
  clientId?: string;
4
5
  compatibilityVersion?: string;
@@ -12,6 +12,7 @@ const VERIFICATION_URL = `${ISSUER}/codex/device`;
12
12
  const USAGE_URL = "https://chatgpt.com/backend-api/wham/usage";
13
13
  const RESET_CREDITS_URL = "https://chatgpt.com/backend-api/wham/rate-limit-reset-credits";
14
14
  const MODELS_URL = "https://chatgpt.com/backend-api/codex/models";
15
+ const OPENAI_MODELS_URL = "https://developers.openai.com/api/docs/models";
15
16
  const EXPIRY_SKEW_MS = 5 * 60_000;
16
17
  const BROWSER_LOGIN_TIMEOUT_MS = 10 * 60_000;
17
18
  const BROWSER_CALLBACK_PORTS = [1455, 1457];
@@ -84,6 +85,7 @@ function normalizeModel(value) {
84
85
  })
85
86
  : [];
86
87
  const visibility = stringValue(value.visibility);
88
+ const outputModalities = stringArray(value.output_modalities);
87
89
  return {
88
90
  id,
89
91
  name: stringValue(value.display_name) ?? stringValue(value.name),
@@ -92,12 +94,65 @@ function normalizeModel(value) {
92
94
  maxOutputTokens: numberValue(value.max_output_tokens),
93
95
  reasoningEfforts: levels.length ? levels : stringArray(value.supported_reasoning_efforts),
94
96
  inputModalities: stringArray(value.input_modalities),
95
- endpoints: ["responses"],
97
+ outputModalities,
98
+ endpoints: outputModalities?.includes("image")
99
+ ? ["images/generations", "images/edits"]
100
+ : ["responses"],
96
101
  supportsToolCall: value.supports_tool_calls === false || value.supports_tools === false ? false : true,
97
102
  available: visibility !== "hide" && value.supported_in_api !== false,
98
103
  priority: numberValue(value.priority) ?? Number.MAX_SAFE_INTEGER,
99
104
  };
100
105
  }
106
+ function imageModelName(id) {
107
+ const [prefix, suffix] = id.startsWith("chatgpt-image-")
108
+ ? ["ChatGPT Image", id.slice("chatgpt-image-".length)]
109
+ : id.startsWith("dall-e-")
110
+ ? ["DALL-E", id.slice("dall-e-".length)]
111
+ : ["GPT Image", id.slice("gpt-image-".length)];
112
+ const label = suffix
113
+ .split("-")
114
+ .map((part) => (/^[a-z]/.test(part) ? part[0].toUpperCase() + part.slice(1) : part))
115
+ .join(" ");
116
+ return `${prefix} ${label}`.trim();
117
+ }
118
+ export function parseOpenAiImageModels(html) {
119
+ const ids = new Set();
120
+ const links = html.matchAll(/href=["'](?:https:\/\/developers\.openai\.com)?\/api\/docs\/models\/([^"'/?#]+)[^"']*["']/gi);
121
+ for (const link of links) {
122
+ const id = decodeURIComponent(link[1] ?? "").toLowerCase();
123
+ if (/^(?:gpt-image-|chatgpt-image-|dall-e-)/.test(id))
124
+ ids.add(id);
125
+ }
126
+ return [...ids].map(openAiImageModel);
127
+ }
128
+ function openAiImageModel(id) {
129
+ return {
130
+ id,
131
+ name: imageModelName(id),
132
+ inputModalities: ["text", "image"],
133
+ outputModalities: ["image"],
134
+ endpoints: ["images/generations", "images/edits"],
135
+ supportsToolCall: false,
136
+ available: true,
137
+ selectable: true,
138
+ };
139
+ }
140
+ async function discoverOpenAiImageModels(fetcher, signal) {
141
+ try {
142
+ const response = await fetcher(OPENAI_MODELS_URL, {
143
+ headers: { accept: "text/html" },
144
+ signal,
145
+ });
146
+ if (!response.ok)
147
+ return [];
148
+ return parseOpenAiImageModels(await response.text());
149
+ }
150
+ catch (error) {
151
+ if (signal.aborted)
152
+ throw error;
153
+ return [];
154
+ }
155
+ }
101
156
  async function normalizeChatGptRequest(request) {
102
157
  if (request.method !== "POST" || !new URL(request.url).pathname.endsWith("/responses")) {
103
158
  return request;
@@ -115,6 +170,7 @@ async function normalizeChatGptRequest(request) {
115
170
  headers.set("session-id", sessionId);
116
171
  // Preserve instruction placement and message boundaries. The native Codex
117
172
  // client sends top-level instructions too; moving them does not enable caching.
173
+ delete body.max_output_tokens;
118
174
  delete body.prompt_cache_options;
119
175
  delete body.prompt_cache_retention;
120
176
  const stripBreakpoints = (value) => Array.isArray(value)
@@ -459,12 +515,18 @@ export function chatGptProvider(options = {}) {
459
515
  const response = await fetch(url, { headers: { accept: "application/json" }, signal });
460
516
  const raw = await responseJson(response, "ChatGPT models");
461
517
  const models = Array.isArray(raw.models) ? raw.models : [];
462
- return models
518
+ const languageModels = models
463
519
  .map(normalizeModel)
464
520
  .filter((model) => Boolean(model))
465
521
  .filter((model) => model.available !== false)
466
522
  .sort((left, right) => left.priority - right.priority)
467
523
  .map(({ priority: _priority, ...model }) => model);
524
+ const listed = new Map(languageModels.map((model) => [model.id, model]));
525
+ for (const model of await discoverOpenAiImageModels(fetcher, signal)) {
526
+ if (!listed.has(model.id))
527
+ listed.set(model.id, model);
528
+ }
529
+ return [...listed.values()];
468
530
  },
469
531
  isPermanentRefreshError(error) {
470
532
  return (error instanceof ChatGptTokenError &&
@@ -1,4 +1,8 @@
1
1
  import type { ProviderAdapter, ProviderUsageData } from "../types.js";
2
+ export interface OpenCodeProviderOptions {
3
+ compatibilityVersion?: string;
4
+ fetch?: typeof globalThis.fetch;
5
+ }
2
6
  export declare function parseOpenCodeGoUsage(raw: unknown): ProviderUsageData | null;
3
- export declare function openCodeGoProvider(): ProviderAdapter;
4
- export declare function openCodeZenProvider(): ProviderAdapter;
7
+ export declare function openCodeGoProvider(options?: OpenCodeProviderOptions): ProviderAdapter;
8
+ export declare function openCodeZenProvider(options?: OpenCodeProviderOptions): ProviderAdapter;
@@ -1,6 +1,11 @@
1
+ import { randomBytes } from "node:crypto";
1
2
  import { bearerRequest, isRecord, numberValue, requireAllowedHost, responseJson, stringArray, stringValue, } from "../utils.js";
2
3
  const API_HOST = "opencode.ai";
3
4
  const API_KEY_LIFETIME_MS = 365 * 24 * 60 * 60_000;
5
+ const DEFAULT_COMPATIBILITY_VERSION = "1.18.31";
6
+ const COMPATIBILITY_VERSION_TTL_MS = 60 * 60_000;
7
+ const ID_PATTERN = /^(ses|msg)_[0-9a-f]{12}[0-9A-Za-z]{14}$/;
8
+ const ID_CHARS = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
4
9
  function openCodeUsageMeter(id, label, raw) {
5
10
  if (!isRecord(raw))
6
11
  return null;
@@ -33,18 +38,53 @@ export function parseOpenCodeGoUsage(raw) {
33
38
  note: "Provider-reported quota usage. API-equivalent costs shown in chats are estimates, not charges.",
34
39
  };
35
40
  }
36
- function documentedEndpoints(provider, model) {
37
- if (/^(gpt-|grok-)/.test(model))
38
- return ["responses"];
39
- if (provider === "opencode-go" && /^(minimax-|qwen)/.test(model))
40
- return ["messages"];
41
- if (provider === "opencode-zen" && /^(claude-|qwen)/.test(model))
42
- return ["messages"];
43
- if (provider === "opencode-zen" && model.startsWith("gemini-"))
44
- return [`models/${model}`];
45
- return ["chat/completions"];
46
- }
47
- function openCodeProvider(config) {
41
+ function openCodeProvider(config, options = {}) {
42
+ const fetcher = options.fetch ?? globalThis.fetch;
43
+ let versionRequest;
44
+ let versionExpiresAt = 0;
45
+ const compatibilityVersion = () => {
46
+ if (options.compatibilityVersion)
47
+ return Promise.resolve(options.compatibilityVersion);
48
+ if (!versionRequest || Date.now() >= versionExpiresAt) {
49
+ versionExpiresAt = Date.now() + COMPATIBILITY_VERSION_TTL_MS;
50
+ versionRequest = fetcher("https://api.github.com/repos/anomalyco/opencode/releases/latest", {
51
+ headers: { accept: "application/vnd.github+json", "user-agent": "aisubs" },
52
+ })
53
+ .then(async (response) => {
54
+ if (!response.ok)
55
+ throw new Error(`OpenCode version lookup failed: ${response.status}`);
56
+ const raw = await response.json();
57
+ return isRecord(raw) ? stringValue(raw.tag_name)?.replace(/^v/, "") : undefined;
58
+ })
59
+ .then((version) => version || DEFAULT_COMPATIBILITY_VERSION)
60
+ .catch(() => DEFAULT_COMPATIBILITY_VERSION);
61
+ }
62
+ return versionRequest;
63
+ };
64
+ const routingIds = new Map();
65
+ let lastTimestamp = 0;
66
+ let counter = 0;
67
+ const identifier = (prefix, source) => {
68
+ if (source && ID_PATTERN.test(source) && source.startsWith(`${prefix}_`))
69
+ return source;
70
+ const key = source ? `${prefix}:${source}` : undefined;
71
+ const cached = key ? routingIds.get(key) : undefined;
72
+ if (cached)
73
+ return cached;
74
+ const timestamp = Date.now();
75
+ counter = timestamp === lastTimestamp ? counter + 1 : 1;
76
+ lastTimestamp = timestamp;
77
+ const encoded = BigInt(timestamp) * 0x1000n + BigInt(counter);
78
+ const time = Buffer.alloc(6);
79
+ for (let index = 0; index < time.length; index += 1) {
80
+ time[index] = Number((encoded >> BigInt(40 - 8 * index)) & 0xffn);
81
+ }
82
+ const random = [...randomBytes(14)].map((byte) => ID_CHARS[byte % ID_CHARS.length]).join("");
83
+ const value = `${prefix}_${time.toString("hex")}${random}`;
84
+ if (key)
85
+ routingIds.set(key, value);
86
+ return value;
87
+ };
48
88
  const credential = (apiKey) => ({
49
89
  accessToken: apiKey,
50
90
  expiresAt: Date.now() + API_KEY_LIFETIME_MS,
@@ -79,9 +119,15 @@ function openCodeProvider(config) {
79
119
  async refresh(current) {
80
120
  return { ...current, expiresAt: Date.now() + API_KEY_LIFETIME_MS };
81
121
  },
82
- authorize(request, current) {
122
+ async authorize(request, current) {
83
123
  requireAllowedHost(request, [API_HOST]);
84
- return bearerRequest(request, current);
124
+ const client = request.headers.get("x-opencode-client")?.trim() || "aisubs";
125
+ return bearerRequest(request, current, {
126
+ "user-agent": `opencode/latest/${await compatibilityVersion()}/${client}`,
127
+ "x-opencode-client": client,
128
+ "x-opencode-session": identifier("ses", request.headers.get("x-opencode-session")),
129
+ "x-opencode-request": identifier("msg", request.headers.get("x-opencode-request")),
130
+ });
85
131
  },
86
132
  async getModels({ fetch, signal }) {
87
133
  const raw = await responseJson(await fetch(`${config.baseUrl}/models`, {
@@ -103,9 +149,7 @@ function openCodeProvider(config) {
103
149
  id,
104
150
  name: stringValue(value.name),
105
151
  description: stringValue(value.description),
106
- endpoints: stringArray(value.supported_endpoints) ??
107
- stringArray(value.endpoints) ??
108
- documentedEndpoints(config.id, id),
152
+ endpoints: stringArray(value.supported_endpoints) ?? stringArray(value.endpoints),
109
153
  available: true,
110
154
  selectable: true,
111
155
  },
@@ -129,19 +173,19 @@ function openCodeProvider(config) {
129
173
  },
130
174
  };
131
175
  }
132
- export function openCodeGoProvider() {
176
+ export function openCodeGoProvider(options = {}) {
133
177
  return openCodeProvider({
134
178
  id: "opencode-go",
135
179
  name: "OpenCode Go",
136
180
  description: "OpenCode Go subscription access with an OpenCode API key.",
137
181
  baseUrl: "https://opencode.ai/zen/go/v1",
138
- });
182
+ }, options);
139
183
  }
140
- export function openCodeZenProvider() {
184
+ export function openCodeZenProvider(options = {}) {
141
185
  return openCodeProvider({
142
186
  id: "opencode-zen",
143
187
  name: "OpenCode Zen",
144
188
  description: "OpenCode Zen pay-as-you-go access with an OpenCode API key.",
145
189
  baseUrl: "https://opencode.ai/zen/v1",
146
- });
190
+ }, options);
147
191
  }
@@ -0,0 +1,2 @@
1
+ /** Remove local credentials, routing metadata, and hop-by-hop headers before authorization. */
2
+ export declare function proxyRequestHeaders(input: HeadersInit): Headers;
@@ -0,0 +1,35 @@
1
+ /** Remove local credentials, routing metadata, and hop-by-hop headers before authorization. */
2
+ export function proxyRequestHeaders(input) {
3
+ const headers = new Headers(input);
4
+ const connectionHeaders = headers
5
+ .get("connection")
6
+ ?.split(",")
7
+ .map((name) => name.trim()) ?? [];
8
+ for (const name of [
9
+ ...connectionHeaders,
10
+ "authorization",
11
+ "connection",
12
+ "content-length",
13
+ "cookie",
14
+ "forwarded",
15
+ "host",
16
+ "keep-alive",
17
+ "origin",
18
+ "proxy-authenticate",
19
+ "proxy-authorization",
20
+ "referer",
21
+ "te",
22
+ "trailer",
23
+ "transfer-encoding",
24
+ "upgrade",
25
+ "x-api-key",
26
+ "x-goog-api-key",
27
+ ]) {
28
+ if (name)
29
+ headers.delete(name);
30
+ }
31
+ const routingHeaders = [...headers.keys()].filter((name) => name.startsWith("x-forwarded-") || name.startsWith("sec-websocket-"));
32
+ for (const name of routingHeaders)
33
+ headers.delete(name);
34
+ return headers;
35
+ }
package/dist/realtime.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import WebSocket from "ws";
2
2
  import { errorMessage } from "./utils.js";
3
+ import { proxyRequestHeaders } from "./proxy-headers.js";
3
4
  function rawDataBytes(data) {
4
5
  if (Array.isArray(data))
5
6
  return data.reduce((total, item) => total + item.byteLength, 0);
@@ -31,24 +32,8 @@ function upstreamHeaders(request) {
31
32
  return headers;
32
33
  }
33
34
  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)]]));
35
+ const headers = Object.fromEntries(Object.entries(request.headers).flatMap(([name, value]) => value == null ? [] : [[name, Array.isArray(value) ? value.join(", ") : String(value)]]));
36
+ return Object.fromEntries(proxyRequestHeaders(headers));
52
37
  }
53
38
  /** Register a native Realtime WebSocket tunnel for providers that expose one. */
54
39
  export function registerRealtimeProxy(app, auth, authenticate) {
@@ -116,9 +101,10 @@ export function registerRealtimeProxy(app, auth, authenticate) {
116
101
  ?.split(",")
117
102
  .map((value) => value.trim())
118
103
  .filter(Boolean);
104
+ const options = { headers: upstreamHeaders(authorized), handshakeTimeout: 30_000 };
119
105
  upstream = protocols?.length
120
- ? new WebSocket(target, protocols, { headers: upstreamHeaders(authorized) })
121
- : new WebSocket(target, { headers: upstreamHeaders(authorized) });
106
+ ? new WebSocket(target, protocols, options)
107
+ : new WebSocket(target, options);
122
108
  upstream.on("open", () => {
123
109
  for (const item of pending)
124
110
  upstream.send(item.data, { binary: item.binary });