ampcode-connector 0.1.0 → 0.1.2

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/README.md ADDED
@@ -0,0 +1,97 @@
1
+ # ampcode-connector
2
+
3
+ Stop burning AmpCode credits. Route [AmpCode](https://ampcode.com) through your **existing** Claude Code, Codex CLI & Gemini CLI subscriptions — for free.
4
+
5
+ ![demo](demo.gif)
6
+
7
+ ```
8
+ AmpCode → ampcode-connector → Claude Code (free)
9
+ → OpenAI Codex CLI (free)
10
+ → Gemini CLI (free)
11
+ → AmpCode upstream (paid, last resort)
12
+ ```
13
+
14
+ ## Supported Providers
15
+
16
+ | Provider | Models | How |
17
+ |----------|--------|-----|
18
+ | **Claude Code** | Opus 4, Sonnet 4, Haiku | Anthropic OAuth |
19
+ | **OpenAI Codex CLI** | GPT-5, o3, Codex | OpenAI OAuth |
20
+ | **Gemini CLI** | Gemini Pro, Flash | Google OAuth (dual: Gemini + Vertex pools) |
21
+
22
+ > **Multi-account** — log in multiple times per provider to multiply your quota.
23
+
24
+ ## Quick Start
25
+
26
+ Three commands. That's it.
27
+
28
+ ```bash
29
+ bunx ampcode-connector setup # point AmpCode → proxy
30
+ bunx ampcode-connector login # authenticate providers (browser OAuth)
31
+ bunx ampcode-connector # start proxy
32
+ ```
33
+
34
+ <details>
35
+ <summary>Or clone & run locally</summary>
36
+
37
+ ```bash
38
+ git clone https://github.com/nghyane/ampcode-connector.git
39
+ cd ampcode-connector
40
+ bun install
41
+
42
+ bun run setup
43
+ bun run login
44
+ bun start
45
+ ```
46
+
47
+ </details>
48
+
49
+ Requires [Bun](https://bun.sh) 1.3+.
50
+
51
+ ### Provider Login
52
+
53
+ ```bash
54
+ bunx ampcode-connector login # interactive TUI (↑↓ navigate, enter to login, d to disconnect)
55
+ bunx ampcode-connector login anthropic # Claude Code
56
+ bunx ampcode-connector login codex # OpenAI Codex CLI
57
+ bunx ampcode-connector login google # Gemini CLI + Antigravity
58
+ ```
59
+
60
+ Each login opens your browser. Log in multiple times to stack accounts.
61
+
62
+ ## How It Works
63
+
64
+ ```
65
+ Request in → local OAuth available? → yes → forward to provider API (free)
66
+ → no → forward to ampcode.com (paid)
67
+
68
+ On 429 → retry with different account/pool
69
+ On 401 → fallback to AmpCode upstream
70
+ ```
71
+
72
+ Non-AI routes (auth, threads, telemetry) pass through to `ampcode.com` transparently — the proxy is invisible to AmpCode.
73
+
74
+ ### Smart Routing
75
+
76
+ - **Thread affinity** — same thread sticks to the same account for consistency
77
+ - **Least-connections** — new threads go to the least-loaded account
78
+ - **Cooldown** — rate-limited accounts are temporarily skipped
79
+ - **Google cascade** — Gemini → Antigravity (separate quota pools, double the free tier)
80
+
81
+ ## Configuration
82
+
83
+ Edit [`config.yaml`](config.yaml) to change port, log level, or toggle providers.
84
+
85
+ Amp API key resolution: `config.yaml` → `AMP_API_KEY` env → `~/.local/share/amp/secrets.json`.
86
+
87
+ ## Development
88
+
89
+ ```bash
90
+ bun run dev # --watch mode
91
+ bun test # run tests
92
+ bun run check # lint + typecheck + test
93
+ ```
94
+
95
+ ## License
96
+
97
+ [MIT](LICENSE)
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "ampcode-connector",
3
- "version": "0.1.0",
4
- "description": "Proxy Amp CLI through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
3
+ "version": "0.1.2",
4
+ "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
7
7
  "type": "git",
package/src/index.ts CHANGED
@@ -42,6 +42,10 @@ async function main(): Promise<void> {
42
42
  const config = await loadConfig();
43
43
  setLogLevel(config.logLevel);
44
44
  startServer(config);
45
+
46
+ // Non-blocking update check — runs in background after server starts
47
+ const { checkForUpdates } = await import("./utils/update-check.ts");
48
+ checkForUpdates();
45
49
  }
46
50
 
47
51
  function usage(): void {
@@ -1,5 +1,7 @@
1
- /** Thread → (quotaPool, account) affinity map.
2
- * Ensures a thread sticks to the same account for session consistency. */
1
+ /** Thread+provider → (quotaPool, account) affinity map.
2
+ * Ensures a thread sticks to the same account per provider for session consistency.
3
+ * Key is composite (threadId, ampProvider) so a single thread can hold
4
+ * independent affinities for different providers (e.g. anthropic AND google). */
3
5
 
4
6
  import type { QuotaPool } from "./cooldown.ts";
5
7
 
@@ -16,23 +18,29 @@ const CLEANUP_INTERVAL_MS = 10 * 60_000;
16
18
 
17
19
  const map = new Map<string, AffinityEntry>();
18
20
 
19
- export function get(threadId: string): AffinityEntry | undefined {
20
- const entry = map.get(threadId);
21
+ function key(threadId: string, ampProvider: string): string {
22
+ return `${threadId}\0${ampProvider}`;
23
+ }
24
+
25
+ export function get(threadId: string, ampProvider: string): AffinityEntry | undefined {
26
+ const entry = map.get(key(threadId, ampProvider));
21
27
  if (!entry) return undefined;
22
28
  if (Date.now() - entry.assignedAt > TTL_MS) {
23
- map.delete(threadId);
29
+ map.delete(key(threadId, ampProvider));
24
30
  return undefined;
25
31
  }
32
+ // Touch: keep affinity alive while thread is active
33
+ entry.assignedAt = Date.now();
26
34
  return entry;
27
35
  }
28
36
 
29
- export function set(threadId: string, pool: QuotaPool, account: number): void {
30
- map.set(threadId, { pool, account, assignedAt: Date.now() });
37
+ export function set(threadId: string, ampProvider: string, pool: QuotaPool, account: number): void {
38
+ map.set(key(threadId, ampProvider), { pool, account, assignedAt: Date.now() });
31
39
  }
32
40
 
33
41
  /** Break affinity when account is exhausted — allow re-routing. */
34
- export function clear(threadId: string): void {
35
- map.delete(threadId);
42
+ export function clear(threadId: string, ampProvider: string): void {
43
+ map.delete(key(threadId, ampProvider));
36
44
  }
37
45
 
38
46
  /** Count active threads pinned to a specific (pool, account). */
@@ -50,7 +58,7 @@ export function activeCount(pool: QuotaPool, account: number): number {
50
58
  /** Periodic cleanup of expired entries. */
51
59
  setInterval(() => {
52
60
  const now = Date.now();
53
- for (const [threadId, entry] of map) {
54
- if (now - entry.assignedAt > TTL_MS) map.delete(threadId);
61
+ for (const [k, entry] of map) {
62
+ if (now - entry.assignedAt > TTL_MS) map.delete(k);
55
63
  }
56
64
  }, CLEANUP_INTERVAL_MS);
@@ -44,9 +44,9 @@ export function routeRequest(
44
44
  ): RouteResult {
45
45
  const modelStr = model ?? "unknown";
46
46
 
47
- // Check thread affinity first
47
+ // Check thread affinity (keyed by threadId + ampProvider)
48
48
  if (threadId) {
49
- const pinned = affinity.get(threadId);
49
+ const pinned = affinity.get(threadId, ampProvider);
50
50
  if (pinned && !cooldown.isExhausted(pinned.pool, pinned.account)) {
51
51
  const handler = providerForPool(pinned.pool);
52
52
  if (handler && handler.isAvailable(pinned.account)) {
@@ -58,7 +58,7 @@ export function routeRequest(
58
58
  }
59
59
  }
60
60
  // Affinity broken (exhausted / unavailable) — clear and re-route
61
- if (pinned) affinity.clear(threadId);
61
+ if (pinned) affinity.clear(threadId, ampProvider);
62
62
  }
63
63
 
64
64
  // Build candidate list
@@ -76,7 +76,7 @@ export function routeRequest(
76
76
  }
77
77
 
78
78
  // Pin thread affinity
79
- if (threadId) affinity.set(threadId, picked.pool, picked.account);
79
+ if (threadId) affinity.set(threadId, ampProvider, picked.pool, picked.account);
80
80
 
81
81
  logger.route(picked.provider.routeDecision, ampProvider, modelStr);
82
82
  return result(picked.provider, ampProvider, modelStr, picked.account, picked.pool);
@@ -96,7 +96,7 @@ export function rerouteAfter429(
96
96
 
97
97
  // If exhausted, break thread affinity
98
98
  if (threadId && cooldown.isExhausted(failedPool, failedAccount)) {
99
- affinity.clear(threadId);
99
+ affinity.clear(threadId, ampProvider);
100
100
  }
101
101
 
102
102
  const modelStr = model ?? "unknown";
@@ -105,7 +105,7 @@ export function rerouteAfter429(
105
105
 
106
106
  if (!picked) return null;
107
107
 
108
- if (threadId) affinity.set(threadId, picked.pool, picked.account);
108
+ if (threadId) affinity.set(threadId, ampProvider, picked.pool, picked.account);
109
109
  logger.route(picked.provider.routeDecision, ampProvider, modelStr);
110
110
  return result(picked.provider, ampProvider, modelStr, picked.account, picked.pool);
111
111
  }
@@ -10,6 +10,8 @@ import * as path from "../utils/path.ts";
10
10
 
11
11
  /** Max 429-reroute attempts before falling back to upstream. */
12
12
  const MAX_REROUTE_ATTEMPTS = 4;
13
+ /** Max seconds to wait-and-retry on the same account (preserves prompt cache). */
14
+ const CACHE_PRESERVE_WAIT_MAX_S = 10;
13
15
 
14
16
  export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
15
17
  const server = Bun.serve({
@@ -74,17 +76,37 @@ async function handleProvider(
74
76
  const model = path.model(body) ?? path.modelFromUrl(sub);
75
77
  let route = routeRequest(providerName, model, config, threadId);
76
78
 
77
- logger.info(`ROUTE ${route.decision} provider=${providerName} model=${model ?? "?"} account=${route.account} sub=${sub}`);
79
+ logger.info(
80
+ `ROUTE ${route.decision} provider=${providerName} model=${model ?? "?"} account=${route.account} sub=${sub}`,
81
+ );
78
82
 
79
83
  if (route.handler) {
80
84
  const rewrite = model ? rewriter.rewrite(model) : undefined;
81
85
  const response = await route.handler.forward(sub, body, req.headers, rewrite, route.account);
82
86
 
83
- // 429 → attempt reroute to different account/pool
87
+ // 429 → try to preserve prompt cache by waiting briefly on same account,
88
+ // then fall back to rerouting to a different account/pool.
84
89
  if (response.status === 429 && route.pool) {
85
90
  const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
86
91
  logger.warn(`429 from ${route.decision} account=${route.account}`, { retryAfter });
87
92
 
93
+ // Short burst: wait and retry same account to preserve prompt cache
94
+ if (retryAfter !== undefined && retryAfter <= CACHE_PRESERVE_WAIT_MAX_S) {
95
+ logger.debug(`Waiting ${retryAfter}s to preserve prompt cache on account=${route.account}`);
96
+ await Bun.sleep(retryAfter * 1000);
97
+ const retryResponse = await route.handler.forward(sub, body, req.headers, rewrite, route.account);
98
+ if (retryResponse.status !== 429 && retryResponse.status !== 401) {
99
+ recordSuccess(route.pool, route.account);
100
+ return retryResponse;
101
+ }
102
+ // Still failing — fall through to reroute
103
+ if (retryResponse.status === 429) {
104
+ const nextRetryAfter = parseRetryAfter(retryResponse.headers.get("retry-after"));
105
+ record429(route.pool, route.account, nextRetryAfter);
106
+ }
107
+ }
108
+
109
+ // Reroute to different account/pool (cache loss accepted)
88
110
  for (let attempt = 0; attempt < MAX_REROUTE_ATTEMPTS; attempt++) {
89
111
  const next = rerouteAfter429(providerName, model, config, route.pool, route.account, retryAfter, threadId);
90
112
  if (!next) break;
@@ -0,0 +1,46 @@
1
+ /** Check npm registry for newer versions on startup. */
2
+
3
+ import { s, line } from "../cli/ansi.ts";
4
+
5
+ const PACKAGE_NAME = "ampcode-connector";
6
+ const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
7
+ const TIMEOUT_MS = 3_000;
8
+
9
+ function currentVersion(): string {
10
+ // Read from package.json at import time
11
+ const pkg = require("../../package.json");
12
+ return pkg.version;
13
+ }
14
+
15
+ async function fetchLatestVersion(): Promise<string | null> {
16
+ try {
17
+ const res = await fetch(REGISTRY_URL, {
18
+ signal: AbortSignal.timeout(TIMEOUT_MS),
19
+ });
20
+ if (!res.ok) return null;
21
+ const data = (await res.json()) as { version: string };
22
+ return data.version;
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ function isNewer(latest: string, current: string): boolean {
29
+ const parse = (v: string) => v.split(".").map(Number);
30
+ const [la, lb, lc] = parse(latest);
31
+ const [ca, cb, cc] = parse(current);
32
+ return la > ca || (la === ca && lb > cb) || (la === ca && lb === cb && lc > cc);
33
+ }
34
+
35
+ /** Non-blocking update check — logs a notice if a newer version exists. */
36
+ export async function checkForUpdates(): Promise<void> {
37
+ const current = currentVersion();
38
+ const latest = await fetchLatestVersion();
39
+
40
+ if (!latest || !isNewer(latest, current)) return;
41
+
42
+ line();
43
+ line(`${s.yellow}⬆ Update available${s.reset} ${s.dim}${current}${s.reset} → ${s.green}${latest}${s.reset}`);
44
+ line(` Run ${s.cyan}bunx ampcode-connector@latest${s.reset} to update`);
45
+ line();
46
+ }