ampcode-connector 0.1.4 → 0.1.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampcode-connector",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -0,0 +1,44 @@
1
+ import { TOKEN_EXPIRY_BUFFER_MS } from "../constants.ts";
2
+ import { logger } from "../utils/logger.ts";
3
+ import * as configs from "./configs.ts";
4
+ import type { OAuthConfig } from "./oauth.ts";
5
+ import * as oauth from "./oauth.ts";
6
+ import { getAll, type ProviderName } from "./store.ts";
7
+
8
+ const REFRESH_INTERVAL_MS = 60_000;
9
+
10
+ const providerConfigs: Record<ProviderName, OAuthConfig> = {
11
+ anthropic: configs.anthropic,
12
+ codex: configs.codex,
13
+ google: configs.google,
14
+ };
15
+
16
+ let timer: Timer | null = null;
17
+
18
+ async function refreshAll(): Promise<void> {
19
+ const now = Date.now();
20
+
21
+ for (const [provider, config] of Object.entries(providerConfigs) as [ProviderName, OAuthConfig][]) {
22
+ for (const { account, credentials } of getAll(provider)) {
23
+ if (credentials.expiresAt - now > TOKEN_EXPIRY_BUFFER_MS) continue;
24
+
25
+ try {
26
+ logger.debug("Auto-refreshing token", { provider, account });
27
+ await oauth.token(config, account);
28
+ } catch (err) {
29
+ logger.error("Auto-refresh failed", { provider, account, error: String(err) });
30
+ }
31
+ }
32
+ }
33
+ }
34
+
35
+ export function startAutoRefresh(): void {
36
+ if (timer) return;
37
+ timer = setInterval(refreshAll, REFRESH_INTERVAL_MS);
38
+ }
39
+
40
+ export function stopAutoRefresh(): void {
41
+ if (!timer) return;
42
+ clearInterval(timer);
43
+ timer = null;
44
+ }
@@ -17,7 +17,7 @@ export async function waitForCallback(
17
17
  ): Promise<CallbackResult> {
18
18
  return new Promise((resolve, reject) => {
19
19
  let server: ReturnType<typeof Bun.serve> | null = null;
20
- let timeoutId: ReturnType<typeof setTimeout> | null = null;
20
+ let timeoutId: Timer | null = null;
21
21
 
22
22
  const cleanup = () => {
23
23
  if (timeoutId) clearTimeout(timeoutId);
@@ -41,7 +41,7 @@ function accountIdFromJWT(token: string): string | null {
41
41
  if (parts.length < 2 || !parts[1]) return null;
42
42
  const payload = JSON.parse(new TextDecoder().decode(fromBase64url(parts[1]))) as Raw;
43
43
  const auth = payload["https://api.openai.com/auth"] as Raw | undefined;
44
- return (auth?.["chatgpt_account_id"] as string) ?? null;
44
+ return (auth?.chatgpt_account_id as string) ?? null;
45
45
  } catch {
46
46
  return null;
47
47
  }
package/src/auth/oauth.ts CHANGED
@@ -39,8 +39,14 @@ export async function token(config: OAuthConfig, account = 0): Promise<string |
39
39
  try {
40
40
  return (await refresh(config, creds.refreshToken, account)).accessToken;
41
41
  } catch (err) {
42
- logger.error(`Token refresh failed for ${config.providerName}:${account}`, { error: String(err) });
43
- return null;
42
+ logger.warn(`Token refresh failed for ${config.providerName}:${account}, retrying...`, { error: String(err) });
43
+ try {
44
+ await Bun.sleep(1000);
45
+ return (await refresh(config, creds.refreshToken, account)).accessToken;
46
+ } catch (retryErr) {
47
+ logger.error(`Token refresh retry failed for ${config.providerName}:${account}`, { error: String(retryErr) });
48
+ return null;
49
+ }
44
50
  }
45
51
  }
46
52
 
@@ -54,9 +60,7 @@ export async function tokenFromAny(config: OAuthConfig): Promise<{ accessToken:
54
60
  try {
55
61
  const refreshed = await refresh(config, c.refreshToken, account);
56
62
  return { accessToken: refreshed.accessToken, account };
57
- } catch {
58
- continue;
59
- }
63
+ } catch {}
60
64
  }
61
65
 
62
66
  return null;
@@ -102,7 +106,7 @@ export async function login(config: OAuthConfig): Promise<Credentials> {
102
106
  redirect_uri: redirectUri,
103
107
  code_verifier: verifier,
104
108
  };
105
- if (config.sendStateInExchange) exchangeParams["state"] = callback.state;
109
+ if (config.sendStateInExchange) exchangeParams.state = callback.state;
106
110
 
107
111
  const raw = await exchange(config, exchangeParams);
108
112
  let credentials = parseTokenFields(raw, config);
@@ -156,7 +160,7 @@ async function refresh(config: OAuthConfig, refreshToken: string, account = 0):
156
160
 
157
161
  async function exchange(config: OAuthConfig, params: Record<string, string>): Promise<Record<string, unknown>> {
158
162
  const all: Record<string, string> = { client_id: config.clientId, ...params };
159
- if (config.clientSecret) all["client_secret"] = config.clientSecret;
163
+ if (config.clientSecret) all.client_secret = config.clientSecret;
160
164
 
161
165
  const isJson = config.bodyFormat === "json";
162
166
  const res = await fetch(config.tokenUrl, {
package/src/auth/store.ts CHANGED
@@ -2,7 +2,7 @@
2
2
  * Multi-account: composite key (provider, account).
3
3
  * Sync API via bun:sqlite — no cache needed at 0.4µs/read. */
4
4
 
5
- import { Database } from "bun:sqlite";
5
+ import { Database, type Statement } from "bun:sqlite";
6
6
  import { mkdirSync } from "node:fs";
7
7
  import { homedir } from "node:os";
8
8
  import { join } from "node:path";
@@ -21,68 +21,100 @@ export type ProviderName = "anthropic" | "codex" | "google";
21
21
  const DIR = join(homedir(), ".ampcode-connector");
22
22
  const DB_PATH = join(DIR, "credentials.db");
23
23
 
24
- mkdirSync(DIR, { recursive: true, mode: 0o700 });
25
-
26
- const db = new Database(DB_PATH, { strict: true });
27
- db.exec("PRAGMA journal_mode=WAL");
28
-
29
- db.exec(`
30
- CREATE TABLE IF NOT EXISTS credentials (
31
- provider TEXT NOT NULL,
32
- account INTEGER NOT NULL DEFAULT 0,
33
- data TEXT NOT NULL,
34
- PRIMARY KEY (provider, account)
35
- )
36
- `);
37
-
38
- const stmtGet = db.prepare<{ data: string }, [string, number]>(
39
- "SELECT data FROM credentials WHERE provider = ? AND account = ?",
40
- );
41
- const stmtGetAll = db.prepare<{ account: number; data: string }, [string]>(
42
- "SELECT account, data FROM credentials WHERE provider = ? ORDER BY account",
43
- );
44
- const stmtSet = db.prepare<void, [string, number, string]>(
45
- "INSERT OR REPLACE INTO credentials (provider, account, data) VALUES (?, ?, ?)",
46
- );
47
- const stmtDelOne = db.prepare<void, [string, number]>("DELETE FROM credentials WHERE provider = ? AND account = ?");
48
- const stmtDelAll = db.prepare<void, [string]>("DELETE FROM credentials WHERE provider = ?");
49
- const stmtMaxAccount = db.prepare<{ max_account: number | null }, [string]>(
50
- "SELECT MAX(account) as max_account FROM credentials WHERE provider = ?",
51
- );
52
- const stmtCount = db.prepare<{ cnt: number }, [string]>("SELECT COUNT(*) as cnt FROM credentials WHERE provider = ?");
24
+ interface DataRow {
25
+ data: string;
26
+ }
27
+ interface AccountDataRow {
28
+ account: number;
29
+ data: string;
30
+ }
31
+ interface MaxAccountRow {
32
+ max_account: number | null;
33
+ }
34
+ interface CountRow {
35
+ cnt: number;
36
+ }
37
+
38
+ interface Statements {
39
+ get: Statement<DataRow, [string, number]>;
40
+ getAll: Statement<AccountDataRow, [string]>;
41
+ set: Statement<void, [string, number, string]>;
42
+ delOne: Statement<void, [string, number]>;
43
+ delAll: Statement<void, [string]>;
44
+ maxAccount: Statement<MaxAccountRow, [string]>;
45
+ count: Statement<CountRow, [string]>;
46
+ }
47
+
48
+ let _db: Database | null = null;
49
+ let _stmts: Statements | null = null;
50
+
51
+ function init() {
52
+ if (_stmts) return _stmts;
53
+
54
+ mkdirSync(DIR, { recursive: true, mode: 0o700 });
55
+ _db = new Database(DB_PATH, { strict: true });
56
+ _db.exec("PRAGMA journal_mode=WAL");
57
+ _db.exec(`
58
+ CREATE TABLE IF NOT EXISTS credentials (
59
+ provider TEXT NOT NULL,
60
+ account INTEGER NOT NULL DEFAULT 0,
61
+ data TEXT NOT NULL,
62
+ PRIMARY KEY (provider, account)
63
+ )
64
+ `);
65
+
66
+ _stmts = {
67
+ get: _db.prepare<DataRow, [string, number]>("SELECT data FROM credentials WHERE provider = ? AND account = ?"),
68
+ getAll: _db.prepare<AccountDataRow, [string]>(
69
+ "SELECT account, data FROM credentials WHERE provider = ? ORDER BY account",
70
+ ),
71
+ set: _db.prepare<void, [string, number, string]>(
72
+ "INSERT OR REPLACE INTO credentials (provider, account, data) VALUES (?, ?, ?)",
73
+ ),
74
+ delOne: _db.prepare<void, [string, number]>("DELETE FROM credentials WHERE provider = ? AND account = ?"),
75
+ delAll: _db.prepare<void, [string]>("DELETE FROM credentials WHERE provider = ?"),
76
+ maxAccount: _db.prepare<MaxAccountRow, [string]>(
77
+ "SELECT MAX(account) as max_account FROM credentials WHERE provider = ?",
78
+ ),
79
+ count: _db.prepare<CountRow, [string]>("SELECT COUNT(*) as cnt FROM credentials WHERE provider = ?"),
80
+ };
81
+ return _stmts;
82
+ }
53
83
 
54
84
  export function get(provider: ProviderName, account = 0): Credentials | undefined {
55
- const row = stmtGet.get(provider, account);
85
+ const row = init().get.get(provider, account);
56
86
  if (!row) return undefined;
57
87
  return JSON.parse(row.data) as Credentials;
58
88
  }
59
89
 
60
90
  export function getAll(provider: ProviderName): { account: number; credentials: Credentials }[] {
61
- return stmtGetAll.all(provider).map((row) => ({
62
- account: row.account,
63
- credentials: JSON.parse(row.data) as Credentials,
64
- }));
91
+ return init()
92
+ .getAll.all(provider)
93
+ .map((row) => ({
94
+ account: row.account,
95
+ credentials: JSON.parse(row.data) as Credentials,
96
+ }));
65
97
  }
66
98
 
67
99
  export function save(provider: ProviderName, credentials: Credentials, account = 0): void {
68
- stmtSet.run(provider, account, JSON.stringify(credentials));
100
+ init().set.run(provider, account, JSON.stringify(credentials));
69
101
  }
70
102
 
71
103
  export function remove(provider: ProviderName, account?: number): void {
72
104
  if (account !== undefined) {
73
- stmtDelOne.run(provider, account);
105
+ init().delOne.run(provider, account);
74
106
  } else {
75
- stmtDelAll.run(provider);
107
+ init().delAll.run(provider);
76
108
  }
77
109
  }
78
110
 
79
111
  export function nextAccount(provider: ProviderName): number {
80
- const row = stmtMaxAccount.get(provider);
112
+ const row = init().maxAccount.get(provider);
81
113
  return (row?.max_account ?? -1) + 1;
82
114
  }
83
115
 
84
116
  export function count(provider: ProviderName): number {
85
- return stmtCount.get(provider)?.cnt ?? 0;
117
+ return init().count.get(provider)?.cnt ?? 0;
86
118
  }
87
119
 
88
120
  /** Find existing account by email or accountId match. */
package/src/cli/setup.ts CHANGED
@@ -16,7 +16,7 @@ const AMP_SETTINGS_PATHS = [
16
16
  ];
17
17
 
18
18
  function ampSettingsPaths(): string[] {
19
- const envPath = process.env["AMP_SETTINGS_FILE"];
19
+ const envPath = process.env.AMP_SETTINGS_FILE;
20
20
  return envPath ? [envPath] : AMP_SETTINGS_PATHS;
21
21
  }
22
22
 
@@ -31,11 +31,11 @@ function readJson(path: string): Record<string, unknown> {
31
31
 
32
32
  function writeJson(path: string, data: Record<string, unknown>): void {
33
33
  mkdirSync(dirname(path), { recursive: true });
34
- writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf-8");
34
+ writeFileSync(path, `${JSON.stringify(data, null, 2)}\n`, "utf-8");
35
35
  }
36
36
 
37
37
  function findAmpApiKey(proxyUrl: string): string | undefined {
38
- if (process.env["AMP_API_KEY"]) return process.env["AMP_API_KEY"];
38
+ if (process.env.AMP_API_KEY) return process.env.AMP_API_KEY;
39
39
 
40
40
  const secrets = readJson(AMP_SECRETS_PATH);
41
41
  const exact = secrets[`apiKey@${proxyUrl}`];
@@ -102,7 +102,7 @@ export async function setup(): Promise<void> {
102
102
 
103
103
  if (existingKey) {
104
104
  saveAmpApiKey(existingKey, proxyUrl);
105
- const preview = existingKey.slice(0, 10) + "...";
105
+ const preview = `${existingKey.slice(0, 10)}...`;
106
106
  line(`${s.green}ok${s.reset} Amp token found ${s.dim}${preview}${s.reset}`);
107
107
  } else {
108
108
  line();
package/src/cli/tui.ts CHANGED
@@ -27,7 +27,7 @@ let selected = 0;
27
27
  let items: Item[] = [];
28
28
  let message = "";
29
29
  let busy = false;
30
- let timer: ReturnType<typeof setInterval> | null = null;
30
+ let timer: Timer | null = null;
31
31
 
32
32
  export function dashboard(): void {
33
33
  if (!process.stdin.isTTY) throw new Error("Interactive dashboard requires a TTY.");
@@ -36,18 +36,18 @@ const SECRETS_PATH = join(homedir(), ".local", "share", "amp", "secrets.json");
36
36
  export async function loadConfig(): Promise<ProxyConfig> {
37
37
  const file = await readConfigFile();
38
38
  const apiKey = await resolveApiKey(file);
39
- const providers = asRecord(file?.["providers"]);
39
+ const providers = asRecord(file?.providers);
40
40
 
41
41
  return {
42
- port: asNumber(file?.["port"]) ?? DEFAULTS.port,
43
- ampUpstreamUrl: asString(file?.["ampUpstreamUrl"]) ?? DEFAULTS.ampUpstreamUrl,
42
+ port: asNumber(file?.port) ?? DEFAULTS.port,
43
+ ampUpstreamUrl: asString(file?.ampUpstreamUrl) ?? DEFAULTS.ampUpstreamUrl,
44
44
  ampApiKey: apiKey,
45
- exaApiKey: asString(file?.["exaApiKey"]) ?? process.env["EXA_API_KEY"],
46
- logLevel: asLogLevel(file?.["logLevel"]) ?? DEFAULTS.logLevel,
45
+ exaApiKey: asString(file?.exaApiKey) ?? process.env.EXA_API_KEY,
46
+ logLevel: asLogLevel(file?.logLevel) ?? DEFAULTS.logLevel,
47
47
  providers: {
48
- anthropic: asBool(providers?.["anthropic"]) ?? DEFAULTS.providers.anthropic,
49
- codex: asBool(providers?.["codex"]) ?? DEFAULTS.providers.codex,
50
- google: asBool(providers?.["google"]) ?? DEFAULTS.providers.google,
48
+ anthropic: asBool(providers?.anthropic) ?? DEFAULTS.providers.anthropic,
49
+ codex: asBool(providers?.codex) ?? DEFAULTS.providers.codex,
50
+ google: asBool(providers?.google) ?? DEFAULTS.providers.google,
51
51
  },
52
52
  };
53
53
  }
@@ -70,10 +70,10 @@ async function readConfigFile(): Promise<Record<string, unknown> | null> {
70
70
 
71
71
  /** Amp API key resolution: config file → AMP_API_KEY env → secrets.json */
72
72
  async function resolveApiKey(file: Record<string, unknown> | null): Promise<string | undefined> {
73
- const fromFile = asString(file?.["ampApiKey"]);
73
+ const fromFile = asString(file?.ampApiKey);
74
74
  if (fromFile) return fromFile;
75
75
 
76
- const fromEnv = process.env["AMP_API_KEY"];
76
+ const fromEnv = process.env.AMP_API_KEY;
77
77
  if (fromEnv) return fromEnv;
78
78
 
79
79
  return readSecretsFile();
@@ -101,7 +101,7 @@ function asRecord(v: unknown): Record<string, unknown> | undefined {
101
101
  }
102
102
 
103
103
  function asNumber(v: unknown): number | undefined {
104
- return typeof v === "number" && !isNaN(v) ? v : undefined;
104
+ return typeof v === "number" && !Number.isNaN(v) ? v : undefined;
105
105
  }
106
106
 
107
107
  function asString(v: unknown): string | undefined {
package/src/index.ts CHANGED
@@ -1,13 +1,15 @@
1
1
  #!/usr/bin/env bun
2
2
  /** ampcode-connector entry point. */
3
3
 
4
+ import { startAutoRefresh } from "./auth/auto-refresh.ts";
4
5
  import * as configs from "./auth/configs.ts";
5
6
  import type { OAuthConfig } from "./auth/oauth.ts";
6
7
  import * as oauth from "./auth/oauth.ts";
7
8
  import { line, s } from "./cli/ansi.ts";
8
9
  import { setup } from "./cli/setup.ts";
10
+ import * as status from "./cli/status.ts";
9
11
  import { dashboard } from "./cli/tui.ts";
10
- import { loadConfig } from "./config/config.ts";
12
+ import { loadConfig, type ProxyConfig } from "./config/config.ts";
11
13
  import { startServer } from "./server/server.ts";
12
14
  import { logger, setLogLevel } from "./utils/logger.ts";
13
15
 
@@ -42,12 +44,36 @@ async function main(): Promise<void> {
42
44
  const config = await loadConfig();
43
45
  setLogLevel(config.logLevel);
44
46
  startServer(config);
47
+ startAutoRefresh();
48
+ banner(config);
45
49
 
46
50
  // Non-blocking update check — runs in background after server starts
47
51
  const { checkForUpdates } = await import("./utils/update-check.ts");
48
52
  checkForUpdates();
49
53
  }
50
54
 
55
+ function banner(config: ProxyConfig): void {
56
+ const providers = status.all();
57
+ const upstream = config.ampUpstreamUrl.replace(/^https?:\/\//, "");
58
+
59
+ line();
60
+ line(` ${s.bold}ampcode-connector${s.reset}`);
61
+ line(` ${s.dim}http://localhost:${config.port}${s.reset}`);
62
+ line();
63
+
64
+ for (const p of providers) {
65
+ const count = p.accounts.length;
66
+ const label = p.label.padEnd(16);
67
+ const countStr = count > 0 ? `${count} account${count > 1 ? "s" : ""}` : "--";
68
+ const dot = count > 0 ? `${s.green}●${s.reset}` : `${s.dim}○${s.reset}`;
69
+ line(` ${label} ${countStr.padEnd(12)}${dot}`);
70
+ }
71
+
72
+ line();
73
+ line(` ${s.dim}upstream → ${upstream}${s.reset}`);
74
+ line();
75
+ }
76
+
51
77
  function usage(): void {
52
78
  line();
53
79
  line(`${s.bold}ampcode-connector${s.reset} ${s.dim}— proxy Amp CLI through local OAuth subscriptions${s.reset}`);
@@ -86,8 +86,5 @@ async function tryEndpoints(
86
86
  }
87
87
  }
88
88
 
89
- return new Response(JSON.stringify({ error: `All Antigravity endpoints failed: ${lastError?.message}` }), {
90
- status: 502,
91
- headers: { "Content-Type": "application/json" },
92
- });
89
+ return Response.json({ error: `All Antigravity endpoints failed: ${lastError?.message}` }, { status: 502 });
93
90
  }
@@ -55,8 +55,5 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
55
55
  }
56
56
 
57
57
  export function denied(providerName: string): Response {
58
- return new Response(JSON.stringify({ error: `No ${providerName} OAuth token available. Run login first.` }), {
59
- status: 401,
60
- headers: { "Content-Type": "application/json" },
61
- });
58
+ return Response.json({ error: `No ${providerName} OAuth token available. Run login first.` }, { status: 401 });
62
59
  }
@@ -58,7 +58,7 @@ function getAccountId(accessToken: string, account: number): string | undefined
58
58
  if (parts.length < 2 || !parts[1]) return undefined;
59
59
  const payload = JSON.parse(new TextDecoder().decode(fromBase64url(parts[1]))) as Record<string, unknown>;
60
60
  const auth = payload["https://api.openai.com/auth"] as Record<string, unknown> | undefined;
61
- return (auth?.["chatgpt_account_id"] as string) ?? undefined;
61
+ return (auth?.chatgpt_account_id as string) ?? undefined;
62
62
  } catch {
63
63
  return undefined;
64
64
  }
@@ -7,6 +7,9 @@
7
7
 
8
8
  import { modelFieldPaths } from "../constants.ts";
9
9
 
10
+ /** Pre-split field paths — avoids .split(".") on every SSE chunk. */
11
+ const MODEL_FIELD_PARTS = modelFieldPaths.map((p) => p.split("."));
12
+
10
13
  export function rewrite(originalModel: string): (data: string) => string {
11
14
  return (data: string) => {
12
15
  if (data === "[DONE]") return data;
@@ -15,10 +18,10 @@ export function rewrite(originalModel: string): (data: string) => string {
15
18
  const parsed = JSON.parse(data) as Record<string, unknown>;
16
19
  let modified = false;
17
20
 
18
- for (const path of modelFieldPaths) {
19
- const current = getField(parsed, path);
21
+ for (const parts of MODEL_FIELD_PARTS) {
22
+ const current = getField(parsed, parts);
20
23
  if (current !== undefined && current !== originalModel) {
21
- setField(parsed, path, originalModel);
24
+ setField(parsed, parts, originalModel);
22
25
  modified = true;
23
26
  }
24
27
  }
@@ -32,8 +35,7 @@ export function rewrite(originalModel: string): (data: string) => string {
32
35
  };
33
36
  }
34
37
 
35
- function getField(obj: Record<string, unknown>, path: string): unknown {
36
- const parts = path.split(".");
38
+ function getField(obj: Record<string, unknown>, parts: string[]): unknown {
37
39
  let current: unknown = obj;
38
40
  for (const part of parts) {
39
41
  if (current == null || typeof current !== "object") return undefined;
@@ -42,8 +44,7 @@ function getField(obj: Record<string, unknown>, path: string): unknown {
42
44
  return current;
43
45
  }
44
46
 
45
- function setField(obj: Record<string, unknown>, path: string, value: unknown): void {
46
- const parts = path.split(".");
47
+ function setField(obj: Record<string, unknown>, parts: string[], value: unknown): void {
47
48
  let current: unknown = obj;
48
49
  for (let i = 0; i < parts.length - 1; i++) {
49
50
  if (current == null || typeof current !== "object") return;
@@ -55,15 +56,15 @@ function setField(obj: Record<string, unknown>, path: string, value: unknown): v
55
56
  }
56
57
 
57
58
  function suppressThinking(data: Record<string, unknown>): boolean {
58
- const content = data["content"];
59
+ const content = data.content;
59
60
  if (!Array.isArray(content)) return false;
60
61
 
61
- const hasToolUse = content.some((b: Record<string, unknown>) => b["type"] === "tool_use");
62
+ const hasToolUse = content.some((b: Record<string, unknown>) => b.type === "tool_use");
62
63
  if (!hasToolUse) return false;
63
64
 
64
- const hasThinking = content.some((b: Record<string, unknown>) => b["type"] === "thinking");
65
+ const hasThinking = content.some((b: Record<string, unknown>) => b.type === "thinking");
65
66
  if (!hasThinking) return false;
66
67
 
67
- data["content"] = content.filter((b: Record<string, unknown>) => b["type"] !== "thinking");
68
+ data.content = content.filter((b: Record<string, unknown>) => b.type !== "thinking");
68
69
  return true;
69
70
  }
@@ -33,9 +33,6 @@ export async function forward(request: Request, ampUpstreamUrl: string, ampApiKe
33
33
  });
34
34
  } catch (err) {
35
35
  logger.error("Upstream proxy error", { error: String(err) });
36
- return new Response(JSON.stringify({ error: "Failed to connect to Amp upstream", details: String(err) }), {
37
- status: 502,
38
- headers: { "Content-Type": "application/json" },
39
- });
36
+ return Response.json({ error: "Failed to connect to Amp upstream", details: String(err) }, { status: 502 });
40
37
  }
41
38
  }
@@ -17,16 +17,35 @@ const TTL_MS = 2 * 3600_000;
17
17
  const CLEANUP_INTERVAL_MS = 10 * 60_000;
18
18
 
19
19
  const map = new Map<string, AffinityEntry>();
20
+ const counts = new Map<string, number>();
20
21
 
21
22
  function key(threadId: string, ampProvider: string): string {
22
23
  return `${threadId}\0${ampProvider}`;
23
24
  }
24
25
 
26
+ function countKey(pool: QuotaPool, account: number): string {
27
+ return `${pool}:${account}`;
28
+ }
29
+
30
+ function incCount(pool: QuotaPool, account: number): void {
31
+ const k = countKey(pool, account);
32
+ counts.set(k, (counts.get(k) ?? 0) + 1);
33
+ }
34
+
35
+ function decCount(pool: QuotaPool, account: number): void {
36
+ const k = countKey(pool, account);
37
+ const v = (counts.get(k) ?? 0) - 1;
38
+ if (v <= 0) counts.delete(k);
39
+ else counts.set(k, v);
40
+ }
41
+
25
42
  export function get(threadId: string, ampProvider: string): AffinityEntry | undefined {
26
- const entry = map.get(key(threadId, ampProvider));
43
+ const k = key(threadId, ampProvider);
44
+ const entry = map.get(k);
27
45
  if (!entry) return undefined;
28
46
  if (Date.now() - entry.assignedAt > TTL_MS) {
29
- map.delete(key(threadId, ampProvider));
47
+ map.delete(k);
48
+ decCount(entry.pool, entry.account);
30
49
  return undefined;
31
50
  }
32
51
  // Touch: keep affinity alive while thread is active
@@ -35,30 +54,46 @@ export function get(threadId: string, ampProvider: string): AffinityEntry | unde
35
54
  }
36
55
 
37
56
  export function set(threadId: string, ampProvider: string, pool: QuotaPool, account: number): void {
38
- map.set(key(threadId, ampProvider), { pool, account, assignedAt: Date.now() });
57
+ const k = key(threadId, ampProvider);
58
+ const existing = map.get(k);
59
+ if (existing) {
60
+ if (existing.pool !== pool || existing.account !== account) {
61
+ decCount(existing.pool, existing.account);
62
+ incCount(pool, account);
63
+ }
64
+ } else {
65
+ incCount(pool, account);
66
+ }
67
+ map.set(k, { pool, account, assignedAt: Date.now() });
39
68
  }
40
69
 
41
70
  /** Break affinity when account is exhausted — allow re-routing. */
42
71
  export function clear(threadId: string, ampProvider: string): void {
43
- map.delete(key(threadId, ampProvider));
72
+ const k = key(threadId, ampProvider);
73
+ const existing = map.get(k);
74
+ if (existing) {
75
+ decCount(existing.pool, existing.account);
76
+ map.delete(k);
77
+ }
44
78
  }
45
79
 
46
80
  /** Count active threads pinned to a specific (pool, account). */
47
81
  export function activeCount(pool: QuotaPool, account: number): number {
48
- const now = Date.now();
49
- let count = 0;
50
- for (const entry of map.values()) {
51
- if (entry.pool === pool && entry.account === account && now - entry.assignedAt <= TTL_MS) {
52
- count++;
53
- }
54
- }
55
- return count;
82
+ return counts.get(countKey(pool, account)) ?? 0;
56
83
  }
57
84
 
58
- /** Periodic cleanup of expired entries. */
59
- setInterval(() => {
60
- const now = Date.now();
61
- for (const [k, entry] of map) {
62
- if (now - entry.assignedAt > TTL_MS) map.delete(k);
63
- }
64
- }, CLEANUP_INTERVAL_MS);
85
+ let _cleanupTimer: Timer | null = null;
86
+
87
+ /** Start periodic cleanup of expired entries. Call once at server startup. */
88
+ export function startCleanup(): void {
89
+ if (_cleanupTimer) return;
90
+ _cleanupTimer = setInterval(() => {
91
+ const now = Date.now();
92
+ for (const [k, entry] of map) {
93
+ if (now - entry.assignedAt > TTL_MS) {
94
+ map.delete(k);
95
+ decCount(entry.pool, entry.account);
96
+ }
97
+ }
98
+ }, CLEANUP_INTERVAL_MS);
99
+ }
@@ -11,8 +11,6 @@ interface CooldownEntry {
11
11
  consecutive429: number;
12
12
  }
13
13
 
14
- /** Consecutive 429s within this window count toward exhaustion detection. */
15
- const CONSECUTIVE_WINDOW_MS = 2 * 60_000;
16
14
  /** When detected as exhausted, cooldown for this long. */
17
15
  const EXHAUSTED_COOLDOWN_MS = 2 * 3600_000;
18
16
  /** Retry-After threshold (seconds) above which we consider quota exhausted. */
@@ -29,20 +27,22 @@ function key(pool: QuotaPool, account: number): string {
29
27
  }
30
28
 
31
29
  export function isCoolingDown(pool: QuotaPool, account: number): boolean {
32
- const entry = entries.get(key(pool, account));
30
+ const k = key(pool, account);
31
+ const entry = entries.get(k);
33
32
  if (!entry) return false;
34
33
  if (Date.now() >= entry.until) {
35
- entries.delete(key(pool, account));
34
+ entries.delete(k);
36
35
  return false;
37
36
  }
38
37
  return true;
39
38
  }
40
39
 
41
40
  export function isExhausted(pool: QuotaPool, account: number): boolean {
42
- const entry = entries.get(key(pool, account));
41
+ const k = key(pool, account);
42
+ const entry = entries.get(k);
43
43
  if (!entry) return false;
44
44
  if (Date.now() >= entry.until) {
45
- entries.delete(key(pool, account));
45
+ entries.delete(k);
46
46
  return false;
47
47
  }
48
48
  return entry.exhausted;