ampcode-connector 0.1.6 → 0.1.7

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.6",
3
+ "version": "0.1.7",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/auth/store.ts CHANGED
@@ -18,8 +18,8 @@ export interface Credentials {
18
18
 
19
19
  export type ProviderName = "anthropic" | "codex" | "google";
20
20
 
21
- const DIR = join(homedir(), ".ampcode-connector");
22
- const DB_PATH = join(DIR, "credentials.db");
21
+ const DEFAULT_DIR = join(homedir(), ".ampcode-connector");
22
+ const DEFAULT_DB_PATH = join(DEFAULT_DIR, "credentials.db");
23
23
 
24
24
  interface DataRow {
25
25
  data: string;
@@ -47,12 +47,19 @@ interface Statements {
47
47
 
48
48
  let _db: Database | null = null;
49
49
  let _stmts: Statements | null = null;
50
+ let _dbPath = DEFAULT_DB_PATH;
51
+
52
+ /** Override the database path (must be called before any store operation). */
53
+ export function setDbPath(path: string): void {
54
+ _dbPath = path;
55
+ }
50
56
 
51
57
  function init() {
52
58
  if (_stmts) return _stmts;
53
59
 
54
- mkdirSync(DIR, { recursive: true, mode: 0o700 });
55
- _db = new Database(DB_PATH, { strict: true });
60
+ const dir = _dbPath.replace(/\/[^/]+$/, "");
61
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
62
+ _db = new Database(_dbPath, { strict: true });
56
63
  _db.exec("PRAGMA journal_mode=WAL");
57
64
  _db.exec("PRAGMA busy_timeout=5000");
58
65
  _db.exec(`
package/src/cli/ads.ts ADDED
@@ -0,0 +1,38 @@
1
+ /** Periodic GitHub star reminder — non-intrusive, shows in server logs. */
2
+
3
+ import { line, s } from "../cli/ansi.ts";
4
+
5
+ const REPO_URL = "https://github.com/nghyane/ampcode-connector";
6
+ const REQUEST_INTERVAL = 50;
7
+
8
+ let requestCount = 0;
9
+ let shown = false;
10
+
11
+ const messages = [
12
+ `${s.yellow}⭐${s.reset} Enjoying ampcode-connector? Star us on GitHub → ${s.cyan}${REPO_URL}${s.reset}`,
13
+ `${s.yellow}⭐${s.reset} Help others discover this tool — star on GitHub → ${s.cyan}${REPO_URL}${s.reset}`,
14
+ `${s.yellow}⭐${s.reset} ${s.dim}Your star helps keep this project alive!${s.reset} → ${s.cyan}${REPO_URL}${s.reset}`,
15
+ ];
16
+
17
+ function pick(): string {
18
+ return messages[Math.floor(Math.random() * messages.length)]!;
19
+ }
20
+
21
+ /** Show star prompt in the startup banner (once). */
22
+ export function bannerAd(): void {
23
+ line(` ${s.dim}⭐ Star us → ${REPO_URL}${s.reset}`);
24
+ }
25
+
26
+ /** Call after each proxied request. Shows a reminder every N requests. */
27
+ export function maybeShowAd(): void {
28
+ requestCount++;
29
+ if (requestCount % REQUEST_INTERVAL !== 0) return;
30
+
31
+ // Only show once per interval, don't spam
32
+ if (shown && requestCount < REQUEST_INTERVAL * 3) return;
33
+ shown = true;
34
+
35
+ line();
36
+ line(` ${pick()}`);
37
+ line();
38
+ }
package/src/constants.ts CHANGED
@@ -25,9 +25,11 @@ export const codexHeaderValues = {
25
25
  USER_AGENT: `codex_cli_rs/0.101.0 (${process.platform} ${process.arch})`,
26
26
  } as const;
27
27
 
28
- /** Map /v1/responses /codex/responses for the ChatGPT backend. */
28
+ /** Map Amp CLI paths ChatGPT backend paths.
29
+ * Both /v1/responses and /v1/chat/completions route to /codex/responses. */
29
30
  export const codexPathMap: Record<string, string> = {
30
31
  "/v1/responses": "/codex/responses",
32
+ "/v1/chat/completions": "/codex/responses",
31
33
  } as const;
32
34
  export const DEFAULT_AMP_UPSTREAM_URL = "https://ampcode.com";
33
35
 
package/src/index.ts CHANGED
@@ -5,6 +5,7 @@ import { startAutoRefresh } from "./auth/auto-refresh.ts";
5
5
  import * as configs from "./auth/configs.ts";
6
6
  import type { OAuthConfig } from "./auth/oauth.ts";
7
7
  import * as oauth from "./auth/oauth.ts";
8
+ import { bannerAd } from "./cli/ads.ts";
8
9
  import { line, s } from "./cli/ansi.ts";
9
10
  import { setup } from "./cli/setup.ts";
10
11
  import * as status from "./cli/status.ts";
@@ -72,6 +73,8 @@ function banner(config: ProxyConfig): void {
72
73
  line();
73
74
  line(` ${s.dim}upstream → ${upstream}${s.reset}`);
74
75
  line();
76
+ bannerAd();
77
+ line();
75
78
  }
76
79
 
77
80
  function usage(): void {
@@ -0,0 +1,247 @@
1
+ /** Transforms Responses API SSE events → Chat Completions SSE chunks.
2
+ *
3
+ * Codex backend returns Responses API format (response.output_text.delta, etc.)
4
+ * but Amp CLI expects Chat Completions format (chat.completion.chunk). */
5
+
6
+ import * as sse from "../utils/streaming.ts";
7
+
8
+ interface CompletionChunk {
9
+ id: string;
10
+ object: "chat.completion.chunk";
11
+ created: number;
12
+ model: string;
13
+ choices: Choice[];
14
+ usage?: Usage | null;
15
+ }
16
+
17
+ interface Choice {
18
+ index: number;
19
+ delta: Delta;
20
+ finish_reason: string | null;
21
+ }
22
+
23
+ interface Delta {
24
+ role?: string;
25
+ content?: string;
26
+ tool_calls?: ToolCallDelta[];
27
+ }
28
+
29
+ interface ToolCallDelta {
30
+ index: number;
31
+ id?: string;
32
+ type?: string;
33
+ function?: { name?: string; arguments?: string };
34
+ }
35
+
36
+ interface Usage {
37
+ prompt_tokens: number;
38
+ completion_tokens: number;
39
+ total_tokens: number;
40
+ prompt_tokens_details?: { cached_tokens: number };
41
+ }
42
+
43
+ interface TransformState {
44
+ responseId: string;
45
+ model: string;
46
+ created: number;
47
+ toolCallIndex: number;
48
+ /** Track active tool call IDs to assign sequential indices. */
49
+ toolCallIds: Map<string, number>;
50
+ }
51
+
52
+ /** Create a stateful SSE transformer: Responses API → Chat Completions. */
53
+ export function createResponseTransformer(ampModel: string): (data: string) => string {
54
+ const state: TransformState = {
55
+ responseId: "",
56
+ model: ampModel,
57
+ created: Math.floor(Date.now() / 1000),
58
+ toolCallIndex: 0,
59
+ toolCallIds: new Map(),
60
+ };
61
+
62
+ return (data: string): string => {
63
+ if (data === "[DONE]") return data;
64
+
65
+ let parsed: Record<string, unknown>;
66
+ try {
67
+ parsed = JSON.parse(data) as Record<string, unknown>;
68
+ } catch {
69
+ return data;
70
+ }
71
+
72
+ const eventType = parsed.type as string | undefined;
73
+ if (!eventType) return data;
74
+
75
+ // Extract response metadata on creation
76
+ if (eventType === "response.created") {
77
+ const resp = parsed.response as Record<string, unknown>;
78
+ state.responseId = (resp?.id as string) ?? state.responseId;
79
+ state.model = ampModel;
80
+ state.created = (resp?.created_at as number) ?? state.created;
81
+ // Don't emit a chunk for response.created
82
+ return "";
83
+ }
84
+
85
+ switch (eventType) {
86
+ // Assistant message started — emit role
87
+ case "response.output_item.added": {
88
+ const item = parsed.item as Record<string, unknown>;
89
+ if (item?.type === "message" && item.role === "assistant") {
90
+ return serialize(state, { role: "assistant", content: "" });
91
+ }
92
+ if (item?.type === "function_call") {
93
+ const callId = item.call_id as string;
94
+ const name = item.name as string;
95
+ const idx = state.toolCallIndex++;
96
+ state.toolCallIds.set(callId, idx);
97
+ return serialize(state, {
98
+ tool_calls: [{ index: idx, id: callId, type: "function", function: { name, arguments: "" } }],
99
+ });
100
+ }
101
+ return "";
102
+ }
103
+
104
+ // Text content delta
105
+ case "response.output_text.delta": {
106
+ const delta = parsed.delta as string;
107
+ if (delta) return serialize(state, { content: delta });
108
+ return "";
109
+ }
110
+
111
+ // Function call arguments delta
112
+ case "response.function_call_arguments.delta": {
113
+ const delta = parsed.delta as string;
114
+ const callId = parsed.call_id as string | undefined;
115
+ if (delta) {
116
+ const idx = callId ? (state.toolCallIds.get(callId) ?? 0) : 0;
117
+ return serialize(state, { tool_calls: [{ index: idx, function: { arguments: delta } }] });
118
+ }
119
+ return "";
120
+ }
121
+
122
+ // Response completed — emit finish_reason + usage
123
+ case "response.completed": {
124
+ const resp = parsed.response as Record<string, unknown>;
125
+ const usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
126
+ const hasToolCalls = state.toolCallIndex > 0;
127
+ const finishReason = hasToolCalls ? "tool_calls" : "stop";
128
+ return serializeFinish(state, finishReason, usage);
129
+ }
130
+
131
+ // Reasoning/thinking delta — emit as content (Amp shows thinking)
132
+ case "response.reasoning_summary_text.delta": {
133
+ const delta = parsed.delta as string;
134
+ if (delta) return serialize(state, { content: delta });
135
+ return "";
136
+ }
137
+
138
+ // Events we can skip
139
+ case "response.in_progress":
140
+ case "response.output_item.done":
141
+ case "response.content_part.added":
142
+ case "response.content_part.done":
143
+ case "response.output_text.done":
144
+ case "response.function_call_arguments.done":
145
+ case "response.reasoning_summary_part.added":
146
+ case "response.reasoning_summary_part.done":
147
+ return "";
148
+
149
+ default:
150
+ return "";
151
+ }
152
+ };
153
+ }
154
+
155
+ function serialize(state: TransformState, delta: Delta): string {
156
+ const chunk: CompletionChunk = {
157
+ id: `chatcmpl-${state.responseId}`,
158
+ object: "chat.completion.chunk",
159
+ created: state.created,
160
+ model: state.model,
161
+ choices: [{ index: 0, delta, finish_reason: null }],
162
+ };
163
+ return JSON.stringify(chunk);
164
+ }
165
+
166
+ function serializeFinish(state: TransformState, finishReason: string, usage?: Usage): string {
167
+ const chunk: CompletionChunk = {
168
+ id: `chatcmpl-${state.responseId}`,
169
+ object: "chat.completion.chunk",
170
+ created: state.created,
171
+ model: state.model,
172
+ choices: [{ index: 0, delta: {}, finish_reason: finishReason }],
173
+ ...(usage ? { usage } : {}),
174
+ };
175
+ return JSON.stringify(chunk);
176
+ }
177
+
178
+ function extractUsage(raw: Record<string, unknown> | undefined): Usage | undefined {
179
+ if (!raw) return undefined;
180
+ const input = (raw.input_tokens as number) ?? 0;
181
+ const output = (raw.output_tokens as number) ?? 0;
182
+ const cached = (raw.input_tokens_details as Record<string, unknown>)?.cached_tokens as number | undefined;
183
+ return {
184
+ prompt_tokens: input,
185
+ completion_tokens: output,
186
+ total_tokens: input + output,
187
+ ...(cached !== undefined ? { prompt_tokens_details: { cached_tokens: cached } } : {}),
188
+ };
189
+ }
190
+
191
+ /** Wrap a Codex SSE response with the Responses → Chat Completions transformer.
192
+ * Strips Responses API event names so output looks like standard Chat Completions SSE. */
193
+ export function transformCodexResponse(response: Response, ampModel: string): Response {
194
+ if (!response.body) return response;
195
+
196
+ const transformer = createResponseTransformer(ampModel);
197
+ const body = transformStream(response.body, transformer);
198
+
199
+ return new Response(body, {
200
+ status: response.status,
201
+ headers: {
202
+ "Content-Type": "text/event-stream",
203
+ "Cache-Control": "no-cache",
204
+ Connection: "keep-alive",
205
+ },
206
+ });
207
+ }
208
+
209
+ /** Custom SSE transform that strips event names (Chat Completions doesn't use them). */
210
+ function transformStream(source: ReadableStream<Uint8Array>, fn: (data: string) => string): ReadableStream<Uint8Array> {
211
+ const decoder = new TextDecoder();
212
+ const textEncoder = new TextEncoder();
213
+ let buffer = "";
214
+
215
+ return source.pipeThrough(
216
+ new TransformStream<Uint8Array, Uint8Array>({
217
+ transform(raw, controller) {
218
+ buffer += decoder.decode(raw, { stream: true }).replaceAll("\r\n", "\n");
219
+ const boundary = buffer.lastIndexOf("\n\n");
220
+ if (boundary === -1) return;
221
+
222
+ const complete = buffer.slice(0, boundary + 2);
223
+ buffer = buffer.slice(boundary + 2);
224
+
225
+ for (const chunk of sse.parse(complete)) {
226
+ const transformed = fn(chunk.data);
227
+ if (transformed) {
228
+ // Emit without event name — standard Chat Completions format
229
+ controller.enqueue(textEncoder.encode(`data: ${transformed}\n\n`));
230
+ }
231
+ }
232
+ },
233
+ flush(controller) {
234
+ if (buffer.trim()) {
235
+ for (const chunk of sse.parse(buffer)) {
236
+ const transformed = fn(chunk.data);
237
+ if (transformed) {
238
+ controller.enqueue(textEncoder.encode(`data: ${transformed}\n\n`));
239
+ }
240
+ }
241
+ }
242
+ // Emit [DONE] marker
243
+ controller.enqueue(textEncoder.encode("data: [DONE]\n\n"));
244
+ },
245
+ }),
246
+ );
247
+ }
@@ -1,7 +1,8 @@
1
1
  /** Forwards requests to chatgpt.com/backend-api/codex with Codex CLI OAuth token.
2
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. */
3
+ * The ChatGPT backend only accepts the Responses API format (input[] + instructions),
4
+ * but Amp CLI sends Chat Completions format (messages[]). This module transforms
5
+ * the request body before forwarding. */
5
6
 
6
7
  import { codex as config } from "../auth/configs.ts";
7
8
  import * as oauth from "../auth/oauth.ts";
@@ -10,6 +11,9 @@ import { CODEX_BASE_URL, codexHeaders, codexHeaderValues, codexPathMap } from ".
10
11
  import { fromBase64url } from "../utils/encoding.ts";
11
12
  import type { Provider } from "./base.ts";
12
13
  import { denied, forward } from "./base.ts";
14
+ import { transformCodexResponse } from "./codex-sse.ts";
15
+
16
+ const DEFAULT_INSTRUCTIONS = "You are an expert coding assistant.";
13
17
 
14
18
  export const provider: Provider = {
15
19
  name: "OpenAI Codex",
@@ -26,13 +30,16 @@ export const provider: Provider = {
26
30
 
27
31
  const accountId = getAccountId(accessToken, account);
28
32
  const codexPath = codexPathMap[sub] ?? sub;
33
+ const { body: codexBody, needsResponseTransform } = transformForCodex(body.forwardBody);
34
+ const ampModel = body.ampModel ?? "gpt-5.2";
29
35
 
30
- return forward({
36
+ const response = await forward({
31
37
  url: `${CODEX_BASE_URL}${codexPath}`,
32
- body: body.forwardBody,
38
+ body: codexBody,
33
39
  streaming: body.stream,
34
40
  providerName: "OpenAI Codex",
35
- rewrite,
41
+ // Skip generic rewrite when we need full response transform
42
+ rewrite: needsResponseTransform ? undefined : rewrite,
36
43
  headers: {
37
44
  "Content-Type": "application/json",
38
45
  Authorization: `Bearer ${accessToken}`,
@@ -45,9 +52,205 @@ export const provider: Provider = {
45
52
  ...(accountId ? { [codexHeaders.ACCOUNT_ID]: accountId } : {}),
46
53
  },
47
54
  });
55
+
56
+ // Transform Responses API SSE → Chat Completions SSE when original was messages[] format
57
+ if (needsResponseTransform && response.ok) {
58
+ return transformCodexResponse(response, ampModel);
59
+ }
60
+ return response;
48
61
  },
49
62
  };
50
63
 
64
+ // ---------------------------------------------------------------------------
65
+ // Body transformation: Chat Completions → Responses API
66
+ // ---------------------------------------------------------------------------
67
+
68
+ interface ChatMessage {
69
+ role: string;
70
+ content: unknown;
71
+ tool_calls?: ToolCallItem[];
72
+ tool_call_id?: string;
73
+ name?: string;
74
+ }
75
+
76
+ interface ToolCallItem {
77
+ id: string;
78
+ type: string;
79
+ function: { name: string; arguments: string };
80
+ }
81
+
82
+ function transformForCodex(rawBody: string): { body: string; needsResponseTransform: boolean } {
83
+ if (!rawBody) return { body: rawBody, needsResponseTransform: false };
84
+
85
+ let parsed: Record<string, unknown>;
86
+ try {
87
+ parsed = JSON.parse(rawBody) as Record<string, unknown>;
88
+ } catch {
89
+ return { body: rawBody, needsResponseTransform: false };
90
+ }
91
+
92
+ // Convert Chat Completions messages[] → Responses API input[]
93
+ let needsResponseTransform = false;
94
+ if (Array.isArray(parsed.messages) && !parsed.input) {
95
+ const { instructions, input } = convertMessages(parsed.messages as ChatMessage[]);
96
+ parsed.input = input;
97
+ parsed.instructions = parsed.instructions ?? instructions ?? DEFAULT_INSTRUCTIONS;
98
+ delete parsed.messages;
99
+ needsResponseTransform = true;
100
+ }
101
+
102
+ // Already Responses API format — ensure instructions exists
103
+ if (!parsed.instructions) {
104
+ parsed.instructions = extractInstructionsFromInput(parsed) ?? DEFAULT_INSTRUCTIONS;
105
+ }
106
+
107
+ // Codex backend requirements
108
+ parsed.store = false;
109
+ parsed.stream = true;
110
+
111
+ // Strip id fields from input items
112
+ if (Array.isArray(parsed.input)) {
113
+ stripInputIds(parsed.input as Record<string, unknown>[]);
114
+ }
115
+
116
+ // Remove fields the Codex backend doesn't accept
117
+ delete parsed.max_tokens;
118
+ delete parsed.max_completion_tokens;
119
+ // Chat Completions fields not in Responses API
120
+ delete parsed.frequency_penalty;
121
+ delete parsed.logprobs;
122
+ delete parsed.top_logprobs;
123
+ delete parsed.n;
124
+ delete parsed.presence_penalty;
125
+ delete parsed.seed;
126
+ delete parsed.stop;
127
+ delete parsed.logit_bias;
128
+ delete parsed.response_format;
129
+
130
+ return { body: JSON.stringify(parsed), needsResponseTransform };
131
+ }
132
+
133
+ /** Convert Chat Completions messages[] → Responses API input[] + instructions. */
134
+ function convertMessages(messages: ChatMessage[]): { instructions: string | null; input: unknown[] } {
135
+ let instructions: string | null = null;
136
+ const input: unknown[] = [];
137
+
138
+ for (const msg of messages) {
139
+ switch (msg.role) {
140
+ case "system":
141
+ case "developer": {
142
+ // First system message → instructions; additional ones → developer input items
143
+ const text = textOf(msg.content);
144
+ if (!instructions) {
145
+ instructions = text;
146
+ } else if (text) {
147
+ input.push({ role: "developer", content: [{ type: "input_text", text }] });
148
+ }
149
+ break;
150
+ }
151
+
152
+ case "user":
153
+ input.push({ role: "user", content: convertUserContent(msg.content) });
154
+ break;
155
+
156
+ case "assistant": {
157
+ // Text content → message output item
158
+ const text = textOf(msg.content);
159
+ if (text) {
160
+ input.push({
161
+ type: "message",
162
+ role: "assistant",
163
+ content: [{ type: "output_text", text, annotations: [] }],
164
+ status: "completed",
165
+ });
166
+ }
167
+ // Tool calls → function_call items
168
+ if (msg.tool_calls) {
169
+ for (const tc of msg.tool_calls) {
170
+ input.push({
171
+ type: "function_call",
172
+ call_id: tc.id,
173
+ name: tc.function.name,
174
+ arguments: tc.function.arguments,
175
+ });
176
+ }
177
+ }
178
+ break;
179
+ }
180
+
181
+ case "tool":
182
+ // Tool result → function_call_output
183
+ input.push({
184
+ type: "function_call_output",
185
+ call_id: msg.tool_call_id,
186
+ output: textOf(msg.content) ?? "",
187
+ });
188
+ break;
189
+ }
190
+ }
191
+
192
+ return { instructions, input };
193
+ }
194
+
195
+ /** Convert user message content to Responses API format. */
196
+ function convertUserContent(content: unknown): unknown[] {
197
+ if (typeof content === "string") {
198
+ return [{ type: "input_text", text: content }];
199
+ }
200
+ if (Array.isArray(content)) {
201
+ return content.map((part: Record<string, unknown>) => {
202
+ if (part.type === "text") {
203
+ return { type: "input_text", text: part.text };
204
+ }
205
+ if (part.type === "image_url") {
206
+ const imageUrl = part.image_url as Record<string, unknown>;
207
+ return { type: "input_image", image_url: imageUrl.url, detail: imageUrl.detail ?? "auto" };
208
+ }
209
+ return part;
210
+ });
211
+ }
212
+ return [{ type: "input_text", text: String(content) }];
213
+ }
214
+
215
+ /** Extract text from content (string or array). */
216
+ function textOf(content: unknown): string | null {
217
+ if (typeof content === "string") return content;
218
+ if (Array.isArray(content)) {
219
+ const texts = content
220
+ .filter((c: Record<string, unknown>) => c.type === "text" || c.type === "input_text")
221
+ .map((c: Record<string, unknown>) => c.text as string);
222
+ return texts.length > 0 ? texts.join("\n") : null;
223
+ }
224
+ return null;
225
+ }
226
+
227
+ /** Extract instructions from system/developer messages already in input[]. */
228
+ function extractInstructionsFromInput(parsed: Record<string, unknown>): string | null {
229
+ const input = parsed.input;
230
+ if (!Array.isArray(input)) return null;
231
+
232
+ for (let i = 0; i < input.length; i++) {
233
+ const item = input[i] as Record<string, unknown>;
234
+ if (item.role === "system" || item.role === "developer") {
235
+ const text = textOf(item.content);
236
+ if (text) {
237
+ input.splice(i, 1);
238
+ return text;
239
+ }
240
+ }
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /** Strip `id` fields from input items — Codex backend rejects them. */
246
+ function stripInputIds(items: Record<string, unknown>[]): void {
247
+ for (const item of items) {
248
+ if ("id" in item) {
249
+ delete item.id;
250
+ }
251
+ }
252
+ }
253
+
51
254
  /** Extract chatgpt_account_id from JWT, falling back to stored credentials. */
52
255
  function getAccountId(accessToken: string, account: number): string | undefined {
53
256
  const creds = store.get("codex", account);
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { QuotaPool } from "./cooldown.ts";
7
7
 
8
- interface AffinityEntry {
8
+ export interface AffinityEntry {
9
9
  pool: QuotaPool;
10
10
  account: number;
11
11
  assignedAt: number;
@@ -16,84 +16,106 @@ const TTL_MS = 2 * 3600_000;
16
16
  /** Cleanup stale entries every 10 minutes. */
17
17
  const CLEANUP_INTERVAL_MS = 10 * 60_000;
18
18
 
19
- const map = new Map<string, AffinityEntry>();
20
- const counts = new Map<string, number>();
19
+ export class AffinityStore {
20
+ private map = new Map<string, AffinityEntry>();
21
+ private counts = new Map<string, number>();
22
+ private cleanupTimer: Timer | null = null;
21
23
 
22
- function key(threadId: string, ampProvider: string): string {
23
- return `${threadId}\0${ampProvider}`;
24
- }
24
+ private key(threadId: string, ampProvider: string): string {
25
+ return `${threadId}\0${ampProvider}`;
26
+ }
25
27
 
26
- function countKey(pool: QuotaPool, account: number): string {
27
- return `${pool}:${account}`;
28
- }
28
+ private countKey(pool: QuotaPool, account: number): string {
29
+ return `${pool}:${account}`;
30
+ }
29
31
 
30
- function incCount(pool: QuotaPool, account: number): void {
31
- const k = countKey(pool, account);
32
- counts.set(k, (counts.get(k) ?? 0) + 1);
33
- }
32
+ private incCount(pool: QuotaPool, account: number): void {
33
+ const k = this.countKey(pool, account);
34
+ this.counts.set(k, (this.counts.get(k) ?? 0) + 1);
35
+ }
34
36
 
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
- }
37
+ private decCount(pool: QuotaPool, account: number): void {
38
+ const k = this.countKey(pool, account);
39
+ const v = (this.counts.get(k) ?? 0) - 1;
40
+ if (v <= 0) this.counts.delete(k);
41
+ else this.counts.set(k, v);
42
+ }
41
43
 
42
- export function get(threadId: string, ampProvider: string): AffinityEntry | undefined {
43
- const k = key(threadId, ampProvider);
44
- const entry = map.get(k);
45
- if (!entry) return undefined;
46
- if (Date.now() - entry.assignedAt > TTL_MS) {
47
- map.delete(k);
48
- decCount(entry.pool, entry.account);
49
- return undefined;
44
+ private removeExpired(k: string, entry: AffinityEntry): void {
45
+ this.map.delete(k);
46
+ this.decCount(entry.pool, entry.account);
50
47
  }
51
- // Touch: keep affinity alive while thread is active
52
- entry.assignedAt = Date.now();
53
- return entry;
54
- }
55
48
 
56
- export function set(threadId: string, ampProvider: string, pool: QuotaPool, account: number): void {
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);
49
+ /** Read affinity without side effects. Returns undefined if expired or missing. */
50
+ peek(threadId: string, ampProvider: string): AffinityEntry | undefined {
51
+ const k = this.key(threadId, ampProvider);
52
+ const entry = this.map.get(k);
53
+ if (!entry) return undefined;
54
+ if (Date.now() - entry.assignedAt > TTL_MS) {
55
+ this.removeExpired(k, entry);
56
+ return undefined;
63
57
  }
64
- } else {
65
- incCount(pool, account);
58
+ return entry;
66
59
  }
67
- map.set(k, { pool, account, assignedAt: Date.now() });
68
- }
69
60
 
70
- /** Break affinity when account is exhausted — allow re-routing. */
71
- export function clear(threadId: string, ampProvider: string): void {
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);
61
+ /** Read affinity and touch (extend TTL). */
62
+ get(threadId: string, ampProvider: string): AffinityEntry | undefined {
63
+ const entry = this.peek(threadId, ampProvider);
64
+ if (entry) entry.assignedAt = Date.now();
65
+ return entry;
77
66
  }
78
- }
79
67
 
80
- /** Count active threads pinned to a specific (pool, account). */
81
- export function activeCount(pool: QuotaPool, account: number): number {
82
- return counts.get(countKey(pool, account)) ?? 0;
83
- }
68
+ set(threadId: string, ampProvider: string, pool: QuotaPool, account: number): void {
69
+ const k = this.key(threadId, ampProvider);
70
+ const existing = this.map.get(k);
71
+ if (existing) {
72
+ if (existing.pool !== pool || existing.account !== account) {
73
+ this.decCount(existing.pool, existing.account);
74
+ this.incCount(pool, account);
75
+ }
76
+ } else {
77
+ this.incCount(pool, account);
78
+ }
79
+ this.map.set(k, { pool, account, assignedAt: Date.now() });
80
+ }
81
+
82
+ /** Break affinity when account is exhausted — allow re-routing. */
83
+ clear(threadId: string, ampProvider: string): void {
84
+ const k = this.key(threadId, ampProvider);
85
+ const existing = this.map.get(k);
86
+ if (existing) {
87
+ this.decCount(existing.pool, existing.account);
88
+ this.map.delete(k);
89
+ }
90
+ }
84
91
 
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);
92
+ /** Count active threads pinned to a specific (pool, account). */
93
+ activeCount(pool: QuotaPool, account: number): number {
94
+ return this.counts.get(this.countKey(pool, account)) ?? 0;
95
+ }
96
+
97
+ /** Start periodic cleanup of expired entries. Call once at server startup. */
98
+ startCleanup(): void {
99
+ if (this.cleanupTimer) return;
100
+ this.cleanupTimer = setInterval(() => {
101
+ const now = Date.now();
102
+ for (const [k, entry] of this.map) {
103
+ if (now - entry.assignedAt > TTL_MS) {
104
+ this.removeExpired(k, entry);
105
+ }
96
106
  }
107
+ }, CLEANUP_INTERVAL_MS);
108
+ }
109
+
110
+ reset(): void {
111
+ this.map.clear();
112
+ this.counts.clear();
113
+ if (this.cleanupTimer) {
114
+ clearInterval(this.cleanupTimer);
115
+ this.cleanupTimer = null;
97
116
  }
98
- }, CLEANUP_INTERVAL_MS);
117
+ }
99
118
  }
119
+
120
+ /** Singleton instance for production use. */
121
+ export const affinity = new AffinityStore();
@@ -20,63 +20,63 @@ const EXHAUSTED_CONSECUTIVE = 3;
20
20
  /** Default burst cooldown when no Retry-After header. */
21
21
  const DEFAULT_BURST_S = 30;
22
22
 
23
- const entries = new Map<string, CooldownEntry>();
23
+ export class CooldownTracker {
24
+ private entries = new Map<string, CooldownEntry>();
24
25
 
25
- function key(pool: QuotaPool, account: number): string {
26
- return `${pool}:${account}`;
27
- }
26
+ private key(pool: QuotaPool, account: number): string {
27
+ return `${pool}:${account}`;
28
+ }
28
29
 
29
- export function isCoolingDown(pool: QuotaPool, account: number): boolean {
30
- const k = key(pool, account);
31
- const entry = entries.get(k);
32
- if (!entry) return false;
33
- if (Date.now() >= entry.until) {
34
- entries.delete(k);
35
- return false;
30
+ private getEntry(pool: QuotaPool, account: number): CooldownEntry | undefined {
31
+ const k = this.key(pool, account);
32
+ const entry = this.entries.get(k);
33
+ if (!entry) return undefined;
34
+ if (Date.now() >= entry.until) {
35
+ this.entries.delete(k);
36
+ return undefined;
37
+ }
38
+ return entry;
36
39
  }
37
- return true;
38
- }
39
40
 
40
- export function isExhausted(pool: QuotaPool, account: number): boolean {
41
- const k = key(pool, account);
42
- const entry = entries.get(k);
43
- if (!entry) return false;
44
- if (Date.now() >= entry.until) {
45
- entries.delete(k);
46
- return false;
41
+ isCoolingDown(pool: QuotaPool, account: number): boolean {
42
+ return this.getEntry(pool, account) !== undefined;
47
43
  }
48
- return entry.exhausted;
49
- }
50
44
 
51
- export function record429(pool: QuotaPool, account: number, retryAfterSeconds?: number): void {
52
- const k = key(pool, account);
53
- const entry = entries.get(k) ?? { until: 0, exhausted: false, consecutive429: 0 };
45
+ isExhausted(pool: QuotaPool, account: number): boolean {
46
+ return this.getEntry(pool, account)?.exhausted ?? false;
47
+ }
48
+
49
+ record429(pool: QuotaPool, account: number, retryAfterSeconds?: number): void {
50
+ const k = this.key(pool, account);
51
+ const entry = this.entries.get(k) ?? { until: 0, exhausted: false, consecutive429: 0 };
52
+
53
+ entry.consecutive429++;
54
+ const retryAfter = retryAfterSeconds ?? DEFAULT_BURST_S;
54
55
 
55
- entry.consecutive429++;
56
- const retryAfter = retryAfterSeconds ?? DEFAULT_BURST_S;
56
+ if (retryAfter > EXHAUSTED_THRESHOLD_S || entry.consecutive429 >= EXHAUSTED_CONSECUTIVE) {
57
+ entry.exhausted = true;
58
+ entry.until = Date.now() + EXHAUSTED_COOLDOWN_MS;
59
+ logger.warn(`Quota exhausted: ${k}`, { cooldownMinutes: EXHAUSTED_COOLDOWN_MS / 60_000 });
60
+ } else {
61
+ entry.until = Date.now() + retryAfter * 1000;
62
+ logger.debug(`Burst cooldown: ${k}`, { retryAfterSeconds: retryAfter });
63
+ }
57
64
 
58
- if (retryAfter > EXHAUSTED_THRESHOLD_S || entry.consecutive429 >= EXHAUSTED_CONSECUTIVE) {
59
- entry.exhausted = true;
60
- entry.until = Date.now() + EXHAUSTED_COOLDOWN_MS;
61
- logger.warn(`Quota exhausted: ${k}`, { cooldownMinutes: EXHAUSTED_COOLDOWN_MS / 60_000 });
62
- } else {
63
- entry.until = Date.now() + retryAfter * 1000;
64
- logger.debug(`Burst cooldown: ${k}`, { retryAfterSeconds: retryAfter });
65
+ this.entries.set(k, entry);
65
66
  }
66
67
 
67
- entries.set(k, entry);
68
- }
68
+ recordSuccess(pool: QuotaPool, account: number): void {
69
+ this.entries.delete(this.key(pool, account));
70
+ }
69
71
 
70
- export function recordSuccess(pool: QuotaPool, account: number): void {
71
- const k = key(pool, account);
72
- const entry = entries.get(k);
73
- if (entry) {
74
- entry.consecutive429 = 0;
75
- entry.exhausted = false;
76
- entries.delete(k);
72
+ reset(): void {
73
+ this.entries.clear();
77
74
  }
78
75
  }
79
76
 
77
+ /** Singleton instance for production use. */
78
+ export const cooldown = new CooldownTracker();
79
+
80
80
  /** Parse Retry-After header (seconds or HTTP-date). */
81
81
  export function parseRetryAfter(header: string | null): number | undefined {
82
82
  if (!header) return undefined;
@@ -3,7 +3,7 @@
3
3
  import type { ProxyConfig } from "../config/config.ts";
4
4
  import type { ParsedBody } from "../server/body.ts";
5
5
  import { logger } from "../utils/logger.ts";
6
- import { parseRetryAfter, record429 } from "./cooldown.ts";
6
+ import { cooldown, parseRetryAfter } from "./cooldown.ts";
7
7
  import { type RouteResult, recordSuccess, rerouteAfter429 } from "./router.ts";
8
8
 
9
9
  /** Max 429-reroute attempts before falling back to upstream. */
@@ -33,7 +33,7 @@ export async function tryWithCachePreserve(
33
33
  }
34
34
  if (response.status === 429) {
35
35
  const nextRetryAfter = parseRetryAfter(response.headers.get("retry-after"));
36
- record429(route.pool!, route.account, nextRetryAfter);
36
+ cooldown.record429(route.pool!, route.account, nextRetryAfter);
37
37
  }
38
38
  return null;
39
39
  }
@@ -66,7 +66,7 @@ export async function tryReroute(
66
66
 
67
67
  if (response.status === 429 && next.pool) {
68
68
  const nextRetryAfter = parseRetryAfter(response.headers.get("retry-after"));
69
- record429(next.pool, next.account, nextRetryAfter);
69
+ cooldown.record429(next.pool, next.account, nextRetryAfter);
70
70
  currentPool = next.pool;
71
71
  currentAccount = next.account;
72
72
  continue;
@@ -17,9 +17,8 @@ import type { Provider } from "../providers/base.ts";
17
17
  import { provider as codex } from "../providers/codex.ts";
18
18
  import { provider as gemini } from "../providers/gemini.ts";
19
19
  import { logger, type RouteDecision } from "../utils/logger.ts";
20
- import * as affinity from "./affinity.ts";
21
- import type { QuotaPool } from "./cooldown.ts";
22
- import * as cooldown from "./cooldown.ts";
20
+ import { affinity } from "./affinity.ts";
21
+ import { cooldown, type QuotaPool } from "./cooldown.ts";
23
22
 
24
23
  interface ProviderEntry {
25
24
  provider: Provider;
@@ -1,15 +1,16 @@
1
1
  /** HTTP server — routes provider requests through local OAuth or Amp upstream. */
2
2
 
3
+ import { maybeShowAd } from "../cli/ads.ts";
3
4
  import type { ProxyConfig } from "../config/config.ts";
4
5
  import * as rewriter from "../proxy/rewriter.ts";
5
6
  import * as upstream from "../proxy/upstream.ts";
6
- import { startCleanup } from "../routing/affinity.ts";
7
+ import { affinity } from "../routing/affinity.ts";
7
8
  import { tryReroute, tryWithCachePreserve } from "../routing/retry.ts";
8
9
  import { recordSuccess, routeRequest } from "../routing/router.ts";
9
10
  import { handleInternal, isLocalMethod } from "../tools/internal.ts";
10
11
  import { logger } from "../utils/logger.ts";
11
12
  import * as path from "../utils/path.ts";
12
- import { record, snapshot } from "../utils/stats.ts";
13
+ import { stats } from "../utils/stats.ts";
13
14
  import { type ParsedBody, parseBody } from "./body.ts";
14
15
 
15
16
  export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
@@ -34,7 +35,7 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
34
35
  },
35
36
  });
36
37
 
37
- startCleanup();
38
+ affinity.startCleanup();
38
39
  logger.info(`ampcode-connector listening on http://localhost:${config.port}`);
39
40
 
40
41
  const shutdown = () => {
@@ -129,7 +130,7 @@ async function handleProvider(
129
130
  response = await fallbackUpstream(req, body, config);
130
131
  }
131
132
 
132
- record({
133
+ stats.record({
133
134
  timestamp: new Date().toISOString(),
134
135
  route: route.decision,
135
136
  provider: providerName,
@@ -138,6 +139,8 @@ async function handleProvider(
138
139
  durationMs: Date.now() - startTime,
139
140
  });
140
141
 
142
+ maybeShowAd();
143
+
141
144
  return response;
142
145
  }
143
146
 
@@ -158,6 +161,6 @@ function healthCheck(config: ProxyConfig): Response {
158
161
  port: config.port,
159
162
  upstream: config.ampUpstreamUrl,
160
163
  providers: config.providers,
161
- stats: snapshot(),
164
+ stats: stats.snapshot(),
162
165
  });
163
166
  }
@@ -11,22 +11,6 @@ export interface RequestEntry {
11
11
  durationMs: number;
12
12
  }
13
13
 
14
- const MAX_ENTRIES = 1000;
15
- const buffer: RequestEntry[] = [];
16
- let writeIndex = 0;
17
- let totalCount = 0;
18
- const startedAt = Date.now();
19
-
20
- export function record(entry: RequestEntry): void {
21
- if (buffer.length < MAX_ENTRIES) {
22
- buffer.push(entry);
23
- } else {
24
- buffer[writeIndex] = entry;
25
- }
26
- writeIndex = (writeIndex + 1) % MAX_ENTRIES;
27
- totalCount++;
28
- }
29
-
30
14
  export interface StatsSnapshot {
31
15
  totalRequests: number;
32
16
  requestsByRoute: Partial<Record<RouteDecision, number>>;
@@ -35,35 +19,66 @@ export interface StatsSnapshot {
35
19
  uptimeMs: number;
36
20
  }
37
21
 
38
- export function snapshot(): StatsSnapshot {
39
- const requestsByRoute: Partial<Record<RouteDecision, number>> = {};
40
- let count429 = 0;
41
- let totalDuration = 0;
22
+ export class StatsRecorder {
23
+ private readonly maxEntries: number;
24
+ private buffer: RequestEntry[] = [];
25
+ private writeIndex = 0;
26
+ private totalCount = 0;
27
+ private readonly startedAt = Date.now();
42
28
 
43
- for (const entry of buffer) {
44
- requestsByRoute[entry.route] = (requestsByRoute[entry.route] ?? 0) + 1;
45
- if (entry.statusCode === 429) count429++;
46
- totalDuration += entry.durationMs;
29
+ constructor(maxEntries = 1000) {
30
+ this.maxEntries = maxEntries;
47
31
  }
48
32
 
49
- return {
50
- totalRequests: totalCount,
51
- requestsByRoute,
52
- count429,
53
- averageDurationMs: buffer.length > 0 ? totalDuration / buffer.length : 0,
54
- uptimeMs: Date.now() - startedAt,
55
- };
56
- }
33
+ record(entry: RequestEntry): void {
34
+ if (this.buffer.length < this.maxEntries) {
35
+ this.buffer.push(entry);
36
+ } else {
37
+ this.buffer[this.writeIndex] = entry;
38
+ }
39
+ this.writeIndex = (this.writeIndex + 1) % this.maxEntries;
40
+ this.totalCount++;
41
+ }
42
+
43
+ snapshot(): StatsSnapshot {
44
+ const requestsByRoute: Partial<Record<RouteDecision, number>> = {};
45
+ let count429 = 0;
46
+ let totalDuration = 0;
57
47
 
58
- export function recentRequests(n: number): RequestEntry[] {
59
- const count = Math.min(n, buffer.length);
60
- if (count === 0) return [];
48
+ for (const entry of this.buffer) {
49
+ requestsByRoute[entry.route] = (requestsByRoute[entry.route] ?? 0) + 1;
50
+ if (entry.statusCode === 429) count429++;
51
+ totalDuration += entry.durationMs;
52
+ }
61
53
 
62
- const result: RequestEntry[] = [];
63
- let idx = (writeIndex - count + buffer.length) % buffer.length;
64
- for (let i = 0; i < count; i++) {
65
- result.push(buffer[idx]!);
66
- idx = (idx + 1) % buffer.length;
54
+ return {
55
+ totalRequests: this.totalCount,
56
+ requestsByRoute,
57
+ count429,
58
+ averageDurationMs: this.buffer.length > 0 ? totalDuration / this.buffer.length : 0,
59
+ uptimeMs: Date.now() - this.startedAt,
60
+ };
61
+ }
62
+
63
+ recentRequests(n: number): RequestEntry[] {
64
+ const count = Math.min(n, this.buffer.length);
65
+ if (count === 0) return [];
66
+
67
+ const result: RequestEntry[] = [];
68
+ let idx = (this.writeIndex - count + this.buffer.length) % this.buffer.length;
69
+ for (let i = 0; i < count; i++) {
70
+ result.push(this.buffer[idx]!);
71
+ idx = (idx + 1) % this.buffer.length;
72
+ }
73
+ return result;
74
+ }
75
+
76
+ reset(): void {
77
+ this.buffer = [];
78
+ this.writeIndex = 0;
79
+ this.totalCount = 0;
67
80
  }
68
- return result;
69
81
  }
82
+
83
+ /** Singleton instance for production use. */
84
+ export const stats = new StatsRecorder();