ampcode-connector 0.1.3 → 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 +2 -2
- 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/constants.ts +22 -0
- package/src/index.ts +27 -1
- package/src/providers/antigravity.ts +1 -4
- package/src/providers/base.ts +5 -7
- package/src/providers/codex.ts +32 -3
- 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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ampcode-connector",
|
|
3
|
-
"version": "0.1.
|
|
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": {
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@biomejs/biome": "^2.4.0",
|
|
43
|
-
"@types/bun": "
|
|
43
|
+
"@types/bun": "^1.3.9"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
46
46
|
"typescript": "^5"
|
|
@@ -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/constants.ts
CHANGED
|
@@ -7,6 +7,28 @@ export const DEFAULT_ANTIGRAVITY_PROJECT = "rising-fact-p41fc";
|
|
|
7
7
|
|
|
8
8
|
export const ANTHROPIC_API_URL = "https://api.anthropic.com";
|
|
9
9
|
export const OPENAI_API_URL = "https://api.openai.com";
|
|
10
|
+
export const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
11
|
+
|
|
12
|
+
/** Codex-specific headers required by the ChatGPT backend. */
|
|
13
|
+
export const codexHeaders = {
|
|
14
|
+
BETA: "OpenAI-Beta",
|
|
15
|
+
ACCOUNT_ID: "chatgpt-account-id",
|
|
16
|
+
ORIGINATOR: "originator",
|
|
17
|
+
SESSION_ID: "session_id",
|
|
18
|
+
CONVERSATION_ID: "conversation_id",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export const codexHeaderValues = {
|
|
22
|
+
BETA_RESPONSES: "responses=experimental",
|
|
23
|
+
ORIGINATOR: "codex_cli_rs",
|
|
24
|
+
VERSION: "0.101.0",
|
|
25
|
+
USER_AGENT: `codex_cli_rs/0.101.0 (${process.platform} ${process.arch})`,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** Map /v1/responses → /codex/responses for the ChatGPT backend. */
|
|
29
|
+
export const codexPathMap: Record<string, string> = {
|
|
30
|
+
"/v1/responses": "/codex/responses",
|
|
31
|
+
} as const;
|
|
10
32
|
export const DEFAULT_AMP_UPSTREAM_URL = "https://ampcode.com";
|
|
11
33
|
|
|
12
34
|
export const ANTHROPIC_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
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
|
@@ -36,15 +36,16 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
|
36
36
|
});
|
|
37
37
|
|
|
38
38
|
const contentType = response.headers.get("Content-Type") ?? "application/json";
|
|
39
|
-
const isSSE = contentType.includes("text/event-stream") || opts.streaming;
|
|
40
|
-
if (isSSE) return sse.proxy(response, opts.rewrite);
|
|
41
39
|
|
|
42
40
|
if (!response.ok) {
|
|
43
41
|
const text = await response.text();
|
|
44
|
-
logger.error(`${opts.providerName} API error`, { error: text.slice(0, 200) });
|
|
42
|
+
logger.error(`${opts.providerName} API error (${response.status})`, { error: text.slice(0, 200) });
|
|
45
43
|
return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
|
|
46
44
|
}
|
|
47
45
|
|
|
46
|
+
const isSSE = contentType.includes("text/event-stream") || opts.streaming;
|
|
47
|
+
if (isSSE) return sse.proxy(response, opts.rewrite);
|
|
48
|
+
|
|
48
49
|
if (opts.rewrite) {
|
|
49
50
|
const text = await response.text();
|
|
50
51
|
return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
|
|
@@ -54,8 +55,5 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
export function denied(providerName: string): Response {
|
|
57
|
-
return
|
|
58
|
-
status: 401,
|
|
59
|
-
headers: { "Content-Type": "application/json" },
|
|
60
|
-
});
|
|
58
|
+
return Response.json({ error: `No ${providerName} OAuth token available. Run login first.` }, { status: 401 });
|
|
61
59
|
}
|
package/src/providers/codex.ts
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
|
-
/** Forwards requests to
|
|
1
|
+
/** Forwards requests to chatgpt.com/backend-api/codex with Codex CLI OAuth token.
|
|
2
|
+
*
|
|
3
|
+
* The ChatGPT backend requires specific headers (account-id from JWT, originator,
|
|
4
|
+
* OpenAI-Beta) and a different URL path (/codex/responses) than api.openai.com. */
|
|
2
5
|
|
|
3
6
|
import { codex as config } from "../auth/configs.ts";
|
|
4
7
|
import * as oauth from "../auth/oauth.ts";
|
|
5
8
|
import * as store from "../auth/store.ts";
|
|
6
|
-
import {
|
|
9
|
+
import { CODEX_BASE_URL, codexHeaders, codexHeaderValues, codexPathMap } from "../constants.ts";
|
|
10
|
+
import { fromBase64url } from "../utils/encoding.ts";
|
|
7
11
|
import type { Provider } from "./base.ts";
|
|
8
12
|
import { denied, forward } from "./base.ts";
|
|
9
13
|
|
|
@@ -20,8 +24,11 @@ export const provider: Provider = {
|
|
|
20
24
|
const accessToken = await oauth.token(config, account);
|
|
21
25
|
if (!accessToken) return denied("OpenAI Codex");
|
|
22
26
|
|
|
27
|
+
const accountId = getAccountId(accessToken, account);
|
|
28
|
+
const codexPath = codexPathMap[sub] ?? sub;
|
|
29
|
+
|
|
23
30
|
return forward({
|
|
24
|
-
url: `${
|
|
31
|
+
url: `${CODEX_BASE_URL}${codexPath}`,
|
|
25
32
|
body: body.forwardBody,
|
|
26
33
|
streaming: body.stream,
|
|
27
34
|
providerName: "OpenAI Codex",
|
|
@@ -30,7 +37,29 @@ export const provider: Provider = {
|
|
|
30
37
|
"Content-Type": "application/json",
|
|
31
38
|
Authorization: `Bearer ${accessToken}`,
|
|
32
39
|
Accept: body.stream ? "text/event-stream" : "application/json",
|
|
40
|
+
Connection: "Keep-Alive",
|
|
41
|
+
[codexHeaders.BETA]: codexHeaderValues.BETA_RESPONSES,
|
|
42
|
+
[codexHeaders.ORIGINATOR]: codexHeaderValues.ORIGINATOR,
|
|
43
|
+
"User-Agent": codexHeaderValues.USER_AGENT,
|
|
44
|
+
Version: codexHeaderValues.VERSION,
|
|
45
|
+
...(accountId ? { [codexHeaders.ACCOUNT_ID]: accountId } : {}),
|
|
33
46
|
},
|
|
34
47
|
});
|
|
35
48
|
},
|
|
36
49
|
};
|
|
50
|
+
|
|
51
|
+
/** Extract chatgpt_account_id from JWT, falling back to stored credentials. */
|
|
52
|
+
function getAccountId(accessToken: string, account: number): string | undefined {
|
|
53
|
+
const creds = store.get("codex", account);
|
|
54
|
+
if (creds?.accountId) return creds.accountId;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const parts = accessToken.split(".");
|
|
58
|
+
if (parts.length < 2 || !parts[1]) return undefined;
|
|
59
|
+
const payload = JSON.parse(new TextDecoder().decode(fromBase64url(parts[1]))) as Record<string, unknown>;
|
|
60
|
+
const auth = payload["https://api.openai.com/auth"] as Record<string, unknown> | undefined;
|
|
61
|
+
return (auth?.chatgpt_account_id as string) ?? undefined;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
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
|
}
|