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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampcode-connector",
3
- "version": "0.1.3",
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": "latest"
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: 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/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 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
  }
@@ -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 new Response(JSON.stringify({ error: `No ${providerName} OAuth token available. Run login first.` }), {
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
  }
@@ -1,9 +1,13 @@
1
- /** Forwards requests to api.openai.com with Codex CLI OAuth token. */
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 { OPENAI_API_URL } from "../constants.ts";
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: `${OPENAI_API_URL}${sub}`,
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
+ }
@@ -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
  }