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 +1 -1
- package/src/auth/auto-refresh.ts +44 -0
- package/src/auth/callback-server.ts +1 -1
- package/src/auth/discovery.ts +1 -1
- package/src/auth/oauth.ts +11 -7
- package/src/auth/store.ts +72 -40
- package/src/cli/setup.ts +4 -4
- package/src/cli/tui.ts +1 -1
- package/src/config/config.ts +11 -11
- package/src/index.ts +27 -1
- package/src/providers/antigravity.ts +1 -4
- package/src/providers/base.ts +1 -4
- package/src/providers/codex.ts +1 -1
- package/src/proxy/rewriter.ts +12 -11
- package/src/proxy/upstream.ts +1 -4
- package/src/routing/affinity.ts +54 -19
- package/src/routing/cooldown.ts +6 -6
- package/src/routing/retry.ts +83 -0
- package/src/routing/router.ts +49 -33
- package/src/server/body.ts +12 -11
- package/src/server/server.ts +79 -92
- package/src/tools/internal.ts +69 -46
- package/src/tools/web-read.ts +317 -0
- package/src/tools/web-search.ts +33 -26
- package/src/utils/code-assist.ts +1 -1
- package/src/utils/logger.ts +31 -2
- package/src/utils/path.ts +9 -4
- package/src/utils/stats.ts +69 -0
- package/src/utils/streaming.ts +10 -11
- package/tsconfig.json +3 -3
- package/src/tools/web-extract.ts +0 -137
package/package.json
CHANGED
|
@@ -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:
|
|
20
|
+
let timeoutId: Timer | null = null;
|
|
21
21
|
|
|
22
22
|
const cleanup = () => {
|
|
23
23
|
if (timeoutId) clearTimeout(timeoutId);
|
package/src/auth/discovery.ts
CHANGED
|
@@ -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?.
|
|
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.
|
|
43
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
)
|
|
52
|
-
|
|
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 =
|
|
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
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
105
|
+
init().delOne.run(provider, account);
|
|
74
106
|
} else {
|
|
75
|
-
|
|
107
|
+
init().delAll.run(provider);
|
|
76
108
|
}
|
|
77
109
|
}
|
|
78
110
|
|
|
79
111
|
export function nextAccount(provider: ProviderName): number {
|
|
80
|
-
const row =
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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:
|
|
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.");
|
package/src/config/config.ts
CHANGED
|
@@ -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?.
|
|
39
|
+
const providers = asRecord(file?.providers);
|
|
40
40
|
|
|
41
41
|
return {
|
|
42
|
-
port: asNumber(file?.
|
|
43
|
-
ampUpstreamUrl: asString(file?.
|
|
42
|
+
port: asNumber(file?.port) ?? DEFAULTS.port,
|
|
43
|
+
ampUpstreamUrl: asString(file?.ampUpstreamUrl) ?? DEFAULTS.ampUpstreamUrl,
|
|
44
44
|
ampApiKey: apiKey,
|
|
45
|
-
exaApiKey: asString(file?.
|
|
46
|
-
logLevel: asLogLevel(file?.
|
|
45
|
+
exaApiKey: asString(file?.exaApiKey) ?? process.env.EXA_API_KEY,
|
|
46
|
+
logLevel: asLogLevel(file?.logLevel) ?? DEFAULTS.logLevel,
|
|
47
47
|
providers: {
|
|
48
|
-
anthropic: asBool(providers?.
|
|
49
|
-
codex: asBool(providers?.
|
|
50
|
-
google: asBool(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,
|
|
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?.
|
|
73
|
+
const fromFile = asString(file?.ampApiKey);
|
|
74
74
|
if (fromFile) return fromFile;
|
|
75
75
|
|
|
76
|
-
const fromEnv = process.env
|
|
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
|
|
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
|
}
|
package/src/providers/base.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/providers/codex.ts
CHANGED
|
@@ -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?.
|
|
61
|
+
return (auth?.chatgpt_account_id as string) ?? undefined;
|
|
62
62
|
} catch {
|
|
63
63
|
return undefined;
|
|
64
64
|
}
|
package/src/proxy/rewriter.ts
CHANGED
|
@@ -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
|
|
19
|
-
const current = getField(parsed,
|
|
21
|
+
for (const parts of MODEL_FIELD_PARTS) {
|
|
22
|
+
const current = getField(parsed, parts);
|
|
20
23
|
if (current !== undefined && current !== originalModel) {
|
|
21
|
-
setField(parsed,
|
|
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>,
|
|
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>,
|
|
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
|
|
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
|
|
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
|
|
65
|
+
const hasThinking = content.some((b: Record<string, unknown>) => b.type === "thinking");
|
|
65
66
|
if (!hasThinking) return false;
|
|
66
67
|
|
|
67
|
-
data
|
|
68
|
+
data.content = content.filter((b: Record<string, unknown>) => b.type !== "thinking");
|
|
68
69
|
return true;
|
|
69
70
|
}
|
package/src/proxy/upstream.ts
CHANGED
|
@@ -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
|
|
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
|
}
|
package/src/routing/affinity.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
+
}
|
package/src/routing/cooldown.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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(
|
|
45
|
+
entries.delete(k);
|
|
46
46
|
return false;
|
|
47
47
|
}
|
|
48
48
|
return entry.exhausted;
|