ampcode-connector 0.1.5 → 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 +5 -3
- package/src/auth/store.ts +12 -4
- package/src/cli/ads.ts +38 -0
- package/src/constants.ts +3 -1
- package/src/index.ts +3 -0
- package/src/providers/codex-sse.ts +247 -0
- package/src/providers/codex.ts +208 -5
- package/src/routing/affinity.ts +88 -66
- package/src/routing/cooldown.ts +43 -43
- package/src/routing/retry.ts +3 -3
- package/src/routing/router.ts +2 -3
- package/src/server/server.ts +8 -5
- package/src/tools/web-read.ts +42 -23
- package/src/utils/stats.ts +56 -41
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ampcode-connector",
|
|
3
|
-
"version": "0.1.
|
|
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": {
|
|
@@ -34,8 +34,9 @@
|
|
|
34
34
|
"dev": "bun run --watch src/index.ts",
|
|
35
35
|
"setup": "bun run src/index.ts setup",
|
|
36
36
|
"login": "bun run src/index.ts login",
|
|
37
|
-
"test": "bun test",
|
|
38
|
-
"
|
|
37
|
+
"test": "bun test tests/router.test.ts tests/middleware.test.ts tests/rewriter.test.ts",
|
|
38
|
+
"test:e2e": "bun test tests/code-assist.test.ts",
|
|
39
|
+
"check": "biome check src/ tests/ && tsc --noEmit && bun run test",
|
|
39
40
|
"format": "biome check --write src/ tests/"
|
|
40
41
|
},
|
|
41
42
|
"devDependencies": {
|
|
@@ -48,6 +49,7 @@
|
|
|
48
49
|
"dependencies": {
|
|
49
50
|
"@google/genai": "^1.41.0",
|
|
50
51
|
"@kreuzberg/html-to-markdown": "^2.25.0",
|
|
52
|
+
"ampcode-connector": "^0.1.5",
|
|
51
53
|
"exa-js": "^2.4.0"
|
|
52
54
|
}
|
|
53
55
|
}
|
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
|
|
22
|
-
const
|
|
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,13 +47,21 @@ 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
|
-
|
|
55
|
-
|
|
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");
|
|
64
|
+
_db.exec("PRAGMA busy_timeout=5000");
|
|
57
65
|
_db.exec(`
|
|
58
66
|
CREATE TABLE IF NOT EXISTS credentials (
|
|
59
67
|
provider TEXT NOT NULL,
|
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
|
|
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
|
+
}
|
package/src/providers/codex.ts
CHANGED
|
@@ -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
|
|
4
|
-
*
|
|
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
|
-
|
|
36
|
+
const response = await forward({
|
|
31
37
|
url: `${CODEX_BASE_URL}${codexPath}`,
|
|
32
|
-
body:
|
|
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);
|
package/src/routing/affinity.ts
CHANGED
|
@@ -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
|
-
|
|
20
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
}
|
|
24
|
+
private key(threadId: string, ampProvider: string): string {
|
|
25
|
+
return `${threadId}\0${ampProvider}`;
|
|
26
|
+
}
|
|
25
27
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
}
|
|
28
|
+
private countKey(pool: QuotaPool, account: number): string {
|
|
29
|
+
return `${pool}:${account}`;
|
|
30
|
+
}
|
|
29
31
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
incCount(pool, account);
|
|
58
|
+
return entry;
|
|
66
59
|
}
|
|
67
|
-
map.set(k, { pool, account, assignedAt: Date.now() });
|
|
68
|
-
}
|
|
69
60
|
|
|
70
|
-
/**
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
}
|
|
117
|
+
}
|
|
99
118
|
}
|
|
119
|
+
|
|
120
|
+
/** Singleton instance for production use. */
|
|
121
|
+
export const affinity = new AffinityStore();
|
package/src/routing/cooldown.ts
CHANGED
|
@@ -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
|
-
|
|
23
|
+
export class CooldownTracker {
|
|
24
|
+
private entries = new Map<string, CooldownEntry>();
|
|
24
25
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
26
|
+
private key(pool: QuotaPool, account: number): string {
|
|
27
|
+
return `${pool}:${account}`;
|
|
28
|
+
}
|
|
28
29
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
41
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
68
|
+
recordSuccess(pool: QuotaPool, account: number): void {
|
|
69
|
+
this.entries.delete(this.key(pool, account));
|
|
70
|
+
}
|
|
69
71
|
|
|
70
|
-
|
|
71
|
-
|
|
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;
|
package/src/routing/retry.ts
CHANGED
|
@@ -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 {
|
|
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;
|
package/src/routing/router.ts
CHANGED
|
@@ -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
|
|
21
|
-
import type
|
|
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;
|
package/src/server/server.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
}
|
package/src/tools/web-read.ts
CHANGED
|
@@ -44,6 +44,9 @@ const RANKING = {
|
|
|
44
44
|
MIN_KEYWORD_LEN: 3,
|
|
45
45
|
HEADING_BOOST: 2,
|
|
46
46
|
BIGRAM_BOOST: 1.5,
|
|
47
|
+
POSITION_DECAY: 0.1,
|
|
48
|
+
BM25_K1: 1.5,
|
|
49
|
+
BM25_B: 0.75,
|
|
47
50
|
} as const;
|
|
48
51
|
|
|
49
52
|
const CLIPPING = {
|
|
@@ -155,20 +158,20 @@ function convertToMarkdown(raw: string, contentType: string): string {
|
|
|
155
158
|
function rankExcerpts(markdown: string, objective: string): string[] {
|
|
156
159
|
const sections = splitSections(markdown);
|
|
157
160
|
if (!sections.length) return [clipText(markdown)];
|
|
158
|
-
|
|
159
161
|
const { unigrams, bigrams } = parseTerms(objective);
|
|
160
162
|
if (!unigrams.length) return [clipText(markdown)];
|
|
161
|
-
|
|
162
|
-
const idfWeights = computeIdf(sections,
|
|
163
|
-
const
|
|
164
|
-
|
|
163
|
+
const unigramPatterns = unigrams.map((w) => new RegExp(`\\b${RegExp.escape(w)}\\b`, "g"));
|
|
164
|
+
const idfWeights = computeIdf(sections, unigramPatterns);
|
|
165
|
+
const avgDocLen = sections.reduce((sum, s) => sum + (s.text.split(/\s+/).length || 1), 0) / sections.length;
|
|
166
|
+
const totalSections = sections.length;
|
|
167
|
+
const scored = sections.map((section) =>
|
|
168
|
+
scoreSection(section, unigramPatterns, bigrams, idfWeights, avgDocLen, totalSections),
|
|
169
|
+
);
|
|
165
170
|
const hits = scored.filter((s) => s.score > 0);
|
|
166
171
|
if (!hits.length) return [clipText(markdown)];
|
|
167
|
-
|
|
168
172
|
hits.sort((a, b) => b.score - a.score || a.index - b.index);
|
|
169
173
|
const top = hits.slice(0, RANKING.MAX_SECTIONS);
|
|
170
174
|
top.sort((a, b) => a.index - b.index);
|
|
171
|
-
|
|
172
175
|
return clipMany(top.map((s) => s.text));
|
|
173
176
|
}
|
|
174
177
|
|
|
@@ -185,45 +188,61 @@ function parseTerms(objective: string): { unigrams: string[]; bigrams: RegExp[]
|
|
|
185
188
|
return { unigrams: words, bigrams };
|
|
186
189
|
}
|
|
187
190
|
|
|
188
|
-
function computeIdf(sections: Section[],
|
|
191
|
+
function computeIdf(sections: Section[], patterns: RegExp[]): number[] {
|
|
189
192
|
const lowerTexts = sections.map((section) => section.text.toLowerCase());
|
|
190
193
|
const totalSections = sections.length;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
194
|
+
return patterns.map((pattern) => {
|
|
195
|
+
const docFreq = lowerTexts.filter((text) => {
|
|
196
|
+
pattern.lastIndex = 0;
|
|
197
|
+
return pattern.test(text);
|
|
198
|
+
}).length;
|
|
199
|
+
return docFreq > 0 ? Math.log((totalSections - docFreq + 0.5) / (docFreq + 0.5) + 1) : 0;
|
|
196
200
|
});
|
|
197
201
|
}
|
|
198
202
|
|
|
199
|
-
function scoreSection(
|
|
203
|
+
function scoreSection(
|
|
204
|
+
section: Section,
|
|
205
|
+
unigramPatterns: RegExp[],
|
|
206
|
+
bigrams: RegExp[],
|
|
207
|
+
idfWeights: number[],
|
|
208
|
+
avgDocLen: number,
|
|
209
|
+
totalSections: number,
|
|
210
|
+
): ScoredSection {
|
|
200
211
|
const lowerText = section.text.toLowerCase();
|
|
201
212
|
const lowerHeading = section.heading.toLowerCase();
|
|
202
|
-
const
|
|
213
|
+
const docLen = lowerText.split(/\s+/).length || 1;
|
|
203
214
|
|
|
204
|
-
//
|
|
215
|
+
// BM25 scoring
|
|
216
|
+
const { BM25_K1: k1, BM25_B: b } = RANKING;
|
|
205
217
|
let score = 0;
|
|
206
|
-
for (let i = 0; i <
|
|
207
|
-
const pattern =
|
|
218
|
+
for (let i = 0; i < unigramPatterns.length; i++) {
|
|
219
|
+
const pattern = unigramPatterns[i]!;
|
|
220
|
+
pattern.lastIndex = 0;
|
|
208
221
|
const matches = lowerText.match(pattern);
|
|
209
222
|
if (matches) {
|
|
210
|
-
|
|
223
|
+
const tf = matches.length;
|
|
224
|
+
score += idfWeights[i]! * ((tf * (k1 + 1)) / (tf + k1 * (1 - b + b * (docLen / avgDocLen))));
|
|
211
225
|
}
|
|
212
226
|
}
|
|
213
|
-
|
|
214
227
|
// Bigram bonus
|
|
215
228
|
for (const pattern of bigrams) {
|
|
216
229
|
if (pattern.test(lowerText)) score *= RANKING.BIGRAM_BOOST;
|
|
217
230
|
}
|
|
218
231
|
|
|
219
|
-
// Heading match boost
|
|
232
|
+
// Heading match boost (reuse pre-compiled patterns)
|
|
220
233
|
if (section.heading) {
|
|
221
|
-
|
|
222
|
-
|
|
234
|
+
if (
|
|
235
|
+
unigramPatterns.some((pattern) => {
|
|
236
|
+
pattern.lastIndex = 0;
|
|
237
|
+
return pattern.test(lowerHeading);
|
|
238
|
+
})
|
|
239
|
+
) {
|
|
223
240
|
score *= RANKING.HEADING_BOOST;
|
|
224
241
|
}
|
|
225
242
|
}
|
|
226
243
|
|
|
244
|
+
// Position decay — earlier sections get mild boost
|
|
245
|
+
score *= 1 + RANKING.POSITION_DECAY * (1 - section.index / totalSections);
|
|
227
246
|
return { text: section.text, score, index: section.index };
|
|
228
247
|
}
|
|
229
248
|
|
package/src/utils/stats.ts
CHANGED
|
@@ -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
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
44
|
-
|
|
45
|
-
if (entry.statusCode === 429) count429++;
|
|
46
|
-
totalDuration += entry.durationMs;
|
|
29
|
+
constructor(maxEntries = 1000) {
|
|
30
|
+
this.maxEntries = maxEntries;
|
|
47
31
|
}
|
|
48
32
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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();
|