ampcode-connector 0.1.0 → 0.1.1
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 +97 -0
- package/package.json +1 -1
- package/src/routing/affinity.ts +19 -11
- package/src/routing/router.ts +6 -6
- package/src/server/server.ts +24 -2
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
|
+

|
|
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
package/src/routing/affinity.ts
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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 [
|
|
54
|
-
if (now - entry.assignedAt > TTL_MS) map.delete(
|
|
61
|
+
for (const [k, entry] of map) {
|
|
62
|
+
if (now - entry.assignedAt > TTL_MS) map.delete(k);
|
|
55
63
|
}
|
|
56
64
|
}, CLEANUP_INTERVAL_MS);
|
package/src/routing/router.ts
CHANGED
|
@@ -44,9 +44,9 @@ export function routeRequest(
|
|
|
44
44
|
): RouteResult {
|
|
45
45
|
const modelStr = model ?? "unknown";
|
|
46
46
|
|
|
47
|
-
// Check thread affinity
|
|
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
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -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(
|
|
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 →
|
|
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;
|