ampcode-connector 0.1.6 → 0.1.8

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.8",
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 {
@@ -28,30 +28,59 @@ interface ForwardOptions {
28
28
  rewrite?: (data: string) => string;
29
29
  }
30
30
 
31
+ const RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);
32
+ const MAX_RETRIES = 3;
33
+ const RETRY_DELAY_MS = 500;
34
+
31
35
  export async function forward(opts: ForwardOptions): Promise<Response> {
32
- const response = await fetch(opts.url, {
33
- method: "POST",
34
- headers: opts.headers,
35
- body: opts.body,
36
- });
37
-
38
- const contentType = response.headers.get("Content-Type") ?? "application/json";
39
-
40
- if (!response.ok) {
41
- const text = await response.text();
42
- logger.error(`${opts.providerName} API error (${response.status})`, { error: text.slice(0, 200) });
43
- return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
44
- }
36
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
37
+ let response: Response;
38
+ try {
39
+ response = await fetch(opts.url, {
40
+ method: "POST",
41
+ headers: opts.headers,
42
+ body: opts.body,
43
+ });
44
+ } catch (err) {
45
+ if (attempt < MAX_RETRIES) {
46
+ logger.debug(`${opts.providerName} fetch error, retry ${attempt + 1}/${MAX_RETRIES}`, {
47
+ error: String(err),
48
+ });
49
+ await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
50
+ continue;
51
+ }
52
+ throw err;
53
+ }
54
+
55
+ // Retry on server errors (429 handled at routing layer)
56
+ if (RETRYABLE_STATUS.has(response.status) && attempt < MAX_RETRIES) {
57
+ await response.text(); // consume body
58
+ logger.debug(`${opts.providerName} returned ${response.status}, retry ${attempt + 1}/${MAX_RETRIES}`);
59
+ await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
60
+ continue;
61
+ }
62
+
63
+ const contentType = response.headers.get("Content-Type") ?? "application/json";
64
+
65
+ if (!response.ok) {
66
+ const text = await response.text();
67
+ logger.error(`${opts.providerName} API error (${response.status})`, { error: text.slice(0, 200) });
68
+ return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
69
+ }
70
+
71
+ const isSSE = contentType.includes("text/event-stream") || opts.streaming;
72
+ if (isSSE) return sse.proxy(response, opts.rewrite);
45
73
 
46
- const isSSE = contentType.includes("text/event-stream") || opts.streaming;
47
- if (isSSE) return sse.proxy(response, opts.rewrite);
74
+ if (opts.rewrite) {
75
+ const text = await response.text();
76
+ return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
77
+ }
48
78
 
49
- if (opts.rewrite) {
50
- const text = await response.text();
51
- return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
79
+ return new Response(response.body, { status: response.status, headers: { "Content-Type": contentType } });
52
80
  }
53
81
 
54
- return new Response(response.body, { status: response.status, headers: { "Content-Type": contentType } });
82
+ // Unreachable, but TypeScript needs it
83
+ throw new Error(`${opts.providerName}: all retries exhausted`);
55
84
  }
56
85
 
57
86
  export function denied(providerName: string): Response {
@@ -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",
@@ -20,19 +24,23 @@ export const provider: Provider = {
20
24
 
21
25
  accountCount: () => oauth.accountCount(config),
22
26
 
23
- async forward(sub, body, _originalHeaders, rewrite, account = 0) {
27
+ async forward(sub, body, originalHeaders, rewrite, account = 0) {
24
28
  const accessToken = await oauth.token(config, account);
25
29
  if (!accessToken) return denied("OpenAI Codex");
26
30
 
27
31
  const accountId = getAccountId(accessToken, account);
28
32
  const codexPath = codexPathMap[sub] ?? sub;
33
+ const promptCacheKey = originalHeaders.get("x-amp-thread-id") ?? undefined;
34
+ const { body: codexBody, needsResponseTransform } = transformForCodex(body.forwardBody, promptCacheKey);
35
+ const ampModel = body.ampModel ?? "gpt-5.2";
29
36
 
30
- return forward({
37
+ const response = await forward({
31
38
  url: `${CODEX_BASE_URL}${codexPath}`,
32
- body: body.forwardBody,
39
+ body: codexBody,
33
40
  streaming: body.stream,
34
41
  providerName: "OpenAI Codex",
35
- rewrite,
42
+ // Skip generic rewrite when we need full response transform
43
+ rewrite: needsResponseTransform ? undefined : rewrite,
36
44
  headers: {
37
45
  "Content-Type": "application/json",
38
46
  Authorization: `Bearer ${accessToken}`,
@@ -43,11 +51,288 @@ export const provider: Provider = {
43
51
  "User-Agent": codexHeaderValues.USER_AGENT,
44
52
  Version: codexHeaderValues.VERSION,
45
53
  ...(accountId ? { [codexHeaders.ACCOUNT_ID]: accountId } : {}),
54
+ ...(promptCacheKey
55
+ ? { [codexHeaders.SESSION_ID]: promptCacheKey, [codexHeaders.CONVERSATION_ID]: promptCacheKey }
56
+ : {}),
46
57
  },
47
58
  });
59
+
60
+ // Transform Responses API SSE → Chat Completions SSE when original was messages[] format
61
+ if (needsResponseTransform && response.ok) {
62
+ return transformCodexResponse(response, ampModel);
63
+ }
64
+ return response;
48
65
  },
49
66
  };
50
67
 
68
+ // ---------------------------------------------------------------------------
69
+ // Body transformation: Chat Completions → Responses API
70
+ // ---------------------------------------------------------------------------
71
+
72
+ interface ChatMessage {
73
+ role: string;
74
+ content: unknown;
75
+ tool_calls?: ToolCallItem[];
76
+ tool_call_id?: string;
77
+ name?: string;
78
+ }
79
+
80
+ interface ToolCallItem {
81
+ id: string;
82
+ type: string;
83
+ function: { name: string; arguments: string };
84
+ }
85
+
86
+ function clampReasoningEffort(model: string, effort: string): string {
87
+ const modelId = model.includes("/") ? model.split("/").pop()! : model;
88
+ if (modelId === "gpt-5.1" && effort === "xhigh") return "high";
89
+ if ((modelId.startsWith("gpt-5.2") || modelId.startsWith("gpt-5.3")) && effort === "minimal") return "low";
90
+ if (modelId === "gpt-5.1-codex-mini") {
91
+ return effort === "high" || effort === "xhigh" ? "high" : "medium";
92
+ }
93
+ return effort;
94
+ }
95
+
96
+ function transformForCodex(
97
+ rawBody: string,
98
+ promptCacheKey?: string,
99
+ ): { body: string; needsResponseTransform: boolean } {
100
+ if (!rawBody) return { body: rawBody, needsResponseTransform: false };
101
+
102
+ let parsed: Record<string, unknown>;
103
+ try {
104
+ parsed = JSON.parse(rawBody) as Record<string, unknown>;
105
+ } catch {
106
+ return { body: rawBody, needsResponseTransform: false };
107
+ }
108
+
109
+ // Convert Chat Completions messages[] → Responses API input[]
110
+ let needsResponseTransform = false;
111
+ if (Array.isArray(parsed.messages) && !parsed.input) {
112
+ const { instructions, input } = convertMessages(parsed.messages as ChatMessage[]);
113
+ parsed.input = input;
114
+ parsed.instructions = parsed.instructions ?? instructions ?? DEFAULT_INSTRUCTIONS;
115
+ delete parsed.messages;
116
+ needsResponseTransform = true;
117
+ }
118
+
119
+ // Already Responses API format — ensure instructions exists
120
+ if (!parsed.instructions) {
121
+ parsed.instructions = extractInstructionsFromInput(parsed) ?? DEFAULT_INSTRUCTIONS;
122
+ }
123
+
124
+ // Codex backend requirements
125
+ parsed.store = false;
126
+ parsed.stream = true;
127
+
128
+ // Strip id fields from input items
129
+ if (Array.isArray(parsed.input)) {
130
+ stripInputIds(parsed.input as Record<string, unknown>[]);
131
+ fixOrphanOutputs(parsed.input as Record<string, unknown>[]);
132
+ }
133
+
134
+ // Reasoning config — defaults match reference behavior
135
+ const model = (parsed.model as string) ?? "";
136
+ parsed.reasoning = {
137
+ effort: clampReasoningEffort(model, "high"),
138
+ summary: "auto",
139
+ };
140
+
141
+ parsed.text = { verbosity: "medium" };
142
+
143
+ parsed.include = ["reasoning.encrypted_content"];
144
+
145
+ if (promptCacheKey) {
146
+ parsed.prompt_cache_key = promptCacheKey;
147
+ }
148
+
149
+ // Remove fields the Codex backend doesn't accept
150
+ delete parsed.max_tokens;
151
+ delete parsed.max_completion_tokens;
152
+ // Chat Completions fields not in Responses API
153
+ delete parsed.frequency_penalty;
154
+ delete parsed.logprobs;
155
+ delete parsed.top_logprobs;
156
+ delete parsed.n;
157
+ delete parsed.presence_penalty;
158
+ delete parsed.seed;
159
+ delete parsed.stop;
160
+ delete parsed.logit_bias;
161
+ delete parsed.response_format;
162
+
163
+ // Normalize tool_choice for Responses API
164
+ if (parsed.tool_choice !== undefined && parsed.tool_choice !== null) {
165
+ if (typeof parsed.tool_choice === "string") {
166
+ // "auto", "none", "required" pass through as-is
167
+ } else if (typeof parsed.tool_choice === "object") {
168
+ const tc = parsed.tool_choice as Record<string, unknown>;
169
+ if (tc.type === "function" && tc.function) {
170
+ const fn = tc.function as Record<string, unknown>;
171
+ parsed.tool_choice = { type: "function", name: fn.name };
172
+ } else if (tc.type === "tool" && tc.name) {
173
+ parsed.tool_choice = { type: "function", name: tc.name };
174
+ }
175
+ }
176
+ }
177
+
178
+ return { body: JSON.stringify(parsed), needsResponseTransform };
179
+ }
180
+
181
+ /** Convert Chat Completions messages[] → Responses API input[] + instructions. */
182
+ function convertMessages(messages: ChatMessage[]): { instructions: string | null; input: unknown[] } {
183
+ let instructions: string | null = null;
184
+ const input: unknown[] = [];
185
+
186
+ for (const msg of messages) {
187
+ switch (msg.role) {
188
+ case "system":
189
+ case "developer": {
190
+ // First system message → instructions; additional ones → developer input items
191
+ const text = textOf(msg.content);
192
+ if (!instructions) {
193
+ instructions = text;
194
+ } else if (text) {
195
+ input.push({ role: "developer", content: [{ type: "input_text", text }] });
196
+ }
197
+ break;
198
+ }
199
+
200
+ case "user":
201
+ input.push({ role: "user", content: convertUserContent(msg.content) });
202
+ break;
203
+
204
+ case "assistant": {
205
+ // Text content → message output item
206
+ const text = textOf(msg.content);
207
+ if (text) {
208
+ input.push({
209
+ type: "message",
210
+ role: "assistant",
211
+ content: [{ type: "output_text", text, annotations: [] }],
212
+ status: "completed",
213
+ });
214
+ }
215
+ // Tool calls → function_call items
216
+ if (msg.tool_calls) {
217
+ for (const tc of msg.tool_calls) {
218
+ input.push({
219
+ type: "function_call",
220
+ call_id: tc.id,
221
+ name: tc.function.name,
222
+ arguments: tc.function.arguments,
223
+ });
224
+ }
225
+ }
226
+ break;
227
+ }
228
+
229
+ case "tool":
230
+ // Tool result → function_call_output
231
+ input.push({
232
+ type: "function_call_output",
233
+ call_id: msg.tool_call_id,
234
+ output: textOf(msg.content) ?? "",
235
+ });
236
+ break;
237
+ }
238
+ }
239
+
240
+ return { instructions, input };
241
+ }
242
+
243
+ /** Convert user message content to Responses API format. */
244
+ function convertUserContent(content: unknown): unknown[] {
245
+ if (typeof content === "string") {
246
+ return [{ type: "input_text", text: content }];
247
+ }
248
+ if (Array.isArray(content)) {
249
+ return content.map((part: Record<string, unknown>) => {
250
+ if (part.type === "text") {
251
+ return { type: "input_text", text: part.text };
252
+ }
253
+ if (part.type === "image_url") {
254
+ const imageUrl = part.image_url as Record<string, unknown>;
255
+ return { type: "input_image", image_url: imageUrl.url, detail: imageUrl.detail ?? "auto" };
256
+ }
257
+ return part;
258
+ });
259
+ }
260
+ return [{ type: "input_text", text: String(content) }];
261
+ }
262
+
263
+ /** Extract text from content (string or array). */
264
+ function textOf(content: unknown): string | null {
265
+ if (typeof content === "string") return content;
266
+ if (Array.isArray(content)) {
267
+ const texts = content
268
+ .filter((c: Record<string, unknown>) => c.type === "text" || c.type === "input_text")
269
+ .map((c: Record<string, unknown>) => c.text as string);
270
+ return texts.length > 0 ? texts.join("\n") : null;
271
+ }
272
+ return null;
273
+ }
274
+
275
+ /** Extract instructions from system/developer messages already in input[]. */
276
+ function extractInstructionsFromInput(parsed: Record<string, unknown>): string | null {
277
+ const input = parsed.input;
278
+ if (!Array.isArray(input)) return null;
279
+
280
+ for (let i = 0; i < input.length; i++) {
281
+ const item = input[i] as Record<string, unknown>;
282
+ if (item.role === "system" || item.role === "developer") {
283
+ const text = textOf(item.content);
284
+ if (text) {
285
+ input.splice(i, 1);
286
+ return text;
287
+ }
288
+ }
289
+ }
290
+ return null;
291
+ }
292
+
293
+ /** Strip `id` fields from input items — Codex backend rejects them. */
294
+ function stripInputIds(items: Record<string, unknown>[]): void {
295
+ for (const item of items) {
296
+ if ("id" in item) {
297
+ delete item.id;
298
+ }
299
+ }
300
+ }
301
+
302
+ /** Convert orphan function_call_output items (no matching function_call) to assistant messages. */
303
+ function fixOrphanOutputs(items: Record<string, unknown>[]): void {
304
+ const callIds = new Set(
305
+ items.filter((i) => i.type === "function_call" && typeof i.call_id === "string").map((i) => i.call_id as string),
306
+ );
307
+ for (let i = 0; i < items.length; i++) {
308
+ const item = items[i]!;
309
+ if (item.type === "function_call_output" && typeof item.call_id === "string" && !callIds.has(item.call_id)) {
310
+ const toolName = typeof item.name === "string" ? (item.name as string) : "tool";
311
+ let text = "";
312
+ try {
313
+ text = typeof item.output === "string" ? (item.output as string) : JSON.stringify(item.output);
314
+ } catch {
315
+ text = String(item.output ?? "");
316
+ }
317
+ if (text.length > 16000) {
318
+ text = `${text.slice(0, 16000)}\n...[truncated]`;
319
+ }
320
+ items[i] = {
321
+ type: "message",
322
+ role: "assistant",
323
+ content: [
324
+ {
325
+ type: "output_text",
326
+ text: `[Previous ${toolName} result; call_id=${item.call_id}]: ${text}`,
327
+ annotations: [],
328
+ },
329
+ ],
330
+ status: "completed",
331
+ };
332
+ }
333
+ }
334
+ }
335
+
51
336
  /** Extract chatgpt_account_id from JWT, falling back to stored credentials. */
52
337
  function getAccountId(accessToken: string, account: number): string | undefined {
53
338
  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,21 +1,23 @@
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> {
16
17
  const server = Bun.serve({
17
18
  port: config.port,
18
19
  hostname: "localhost",
20
+ idleTimeout: 255, // seconds — LLM streaming responses can take minutes
19
21
 
20
22
  async fetch(req) {
21
23
  const startTime = Date.now();
@@ -34,7 +36,7 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
34
36
  },
35
37
  });
36
38
 
37
- startCleanup();
39
+ affinity.startCleanup();
38
40
  logger.info(`ampcode-connector listening on http://localhost:${config.port}`);
39
41
 
40
42
  const shutdown = () => {
@@ -129,7 +131,7 @@ async function handleProvider(
129
131
  response = await fallbackUpstream(req, body, config);
130
132
  }
131
133
 
132
- record({
134
+ stats.record({
133
135
  timestamp: new Date().toISOString(),
134
136
  route: route.decision,
135
137
  provider: providerName,
@@ -138,6 +140,8 @@ async function handleProvider(
138
140
  durationMs: Date.now() - startTime,
139
141
  });
140
142
 
143
+ maybeShowAd();
144
+
141
145
  return response;
142
146
  }
143
147
 
@@ -158,6 +162,6 @@ function healthCheck(config: ProxyConfig): Response {
158
162
  port: config.port,
159
163
  upstream: config.ampUpstreamUrl,
160
164
  providers: config.providers,
161
- stats: snapshot(),
165
+ stats: stats.snapshot(),
162
166
  });
163
167
  }
@@ -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();