ampcode-connector 0.1.4 → 0.1.6

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.
@@ -11,8 +11,6 @@ interface CooldownEntry {
11
11
  consecutive429: number;
12
12
  }
13
13
 
14
- /** Consecutive 429s within this window count toward exhaustion detection. */
15
- const CONSECUTIVE_WINDOW_MS = 2 * 60_000;
16
14
  /** When detected as exhausted, cooldown for this long. */
17
15
  const EXHAUSTED_COOLDOWN_MS = 2 * 3600_000;
18
16
  /** Retry-After threshold (seconds) above which we consider quota exhausted. */
@@ -29,20 +27,22 @@ function key(pool: QuotaPool, account: number): string {
29
27
  }
30
28
 
31
29
  export function isCoolingDown(pool: QuotaPool, account: number): boolean {
32
- const entry = entries.get(key(pool, account));
30
+ const k = key(pool, account);
31
+ const entry = entries.get(k);
33
32
  if (!entry) return false;
34
33
  if (Date.now() >= entry.until) {
35
- entries.delete(key(pool, account));
34
+ entries.delete(k);
36
35
  return false;
37
36
  }
38
37
  return true;
39
38
  }
40
39
 
41
40
  export function isExhausted(pool: QuotaPool, account: number): boolean {
42
- const entry = entries.get(key(pool, account));
41
+ const k = key(pool, account);
42
+ const entry = entries.get(k);
43
43
  if (!entry) return false;
44
44
  if (Date.now() >= entry.until) {
45
- entries.delete(key(pool, account));
45
+ entries.delete(k);
46
46
  return false;
47
47
  }
48
48
  return entry.exhausted;
@@ -0,0 +1,83 @@
1
+ /** Retry logic: cache-preserving wait + reroute after 429. */
2
+
3
+ import type { ProxyConfig } from "../config/config.ts";
4
+ import type { ParsedBody } from "../server/body.ts";
5
+ import { logger } from "../utils/logger.ts";
6
+ import { parseRetryAfter, record429 } from "./cooldown.ts";
7
+ import { type RouteResult, recordSuccess, rerouteAfter429 } from "./router.ts";
8
+
9
+ /** Max 429-reroute attempts before falling back to upstream. */
10
+ const MAX_REROUTE_ATTEMPTS = 4;
11
+ /** Max seconds to wait-and-retry on the same account (preserves prompt cache). */
12
+ const CACHE_PRESERVE_WAIT_MAX_S = 10;
13
+
14
+ /** Wait briefly and retry on the same account to preserve prompt cache. */
15
+ export async function tryWithCachePreserve(
16
+ route: RouteResult,
17
+ sub: string,
18
+ body: ParsedBody,
19
+ headers: Headers,
20
+ rewrite: ((data: string) => string) | undefined,
21
+ initialResponse: Response,
22
+ ): Promise<Response | null> {
23
+ const retryAfter = parseRetryAfter(initialResponse.headers.get("retry-after"));
24
+ if (retryAfter === undefined || retryAfter > CACHE_PRESERVE_WAIT_MAX_S) return null;
25
+
26
+ logger.debug(`Waiting ${retryAfter}s to preserve prompt cache on account=${route.account}`);
27
+ await Bun.sleep(retryAfter * 1000);
28
+ const response = await route.handler!.forward(sub, body, headers, rewrite, route.account);
29
+
30
+ if (response.status !== 429 && response.status !== 401) {
31
+ recordSuccess(route.pool!, route.account);
32
+ return response;
33
+ }
34
+ if (response.status === 429) {
35
+ const nextRetryAfter = parseRetryAfter(response.headers.get("retry-after"));
36
+ record429(route.pool!, route.account, nextRetryAfter);
37
+ }
38
+ return null;
39
+ }
40
+
41
+ /** Reroute to different accounts/pools after 429 (cache loss accepted). */
42
+ export async function tryReroute(
43
+ providerName: string,
44
+ ampModel: string | null,
45
+ config: ProxyConfig,
46
+ initialRoute: RouteResult,
47
+ sub: string,
48
+ body: ParsedBody,
49
+ headers: Headers,
50
+ rewrite: ((data: string) => string) | undefined,
51
+ initialResponse: Response,
52
+ threadId?: string,
53
+ ): Promise<Response | null> {
54
+ const retryAfter = parseRetryAfter(initialResponse.headers.get("retry-after"));
55
+ logger.warn(`429 from ${initialRoute.decision} account=${initialRoute.account}`, { retryAfter });
56
+
57
+ let currentPool = initialRoute.pool!;
58
+ let currentAccount = initialRoute.account;
59
+
60
+ for (let attempt = 0; attempt < MAX_REROUTE_ATTEMPTS; attempt++) {
61
+ const next = rerouteAfter429(providerName, ampModel, config, currentPool, currentAccount, retryAfter, threadId);
62
+ if (!next) break;
63
+
64
+ logger.info(`REROUTE -> ${next.decision} account=${next.account}`);
65
+ const response = await next.handler!.forward(sub, body, headers, rewrite, next.account);
66
+
67
+ if (response.status === 429 && next.pool) {
68
+ const nextRetryAfter = parseRetryAfter(response.headers.get("retry-after"));
69
+ record429(next.pool, next.account, nextRetryAfter);
70
+ currentPool = next.pool;
71
+ currentAccount = next.account;
72
+ continue;
73
+ }
74
+
75
+ if (response.status !== 401) {
76
+ if (next.pool) recordSuccess(next.pool, next.account);
77
+ return response;
78
+ }
79
+ break;
80
+ }
81
+
82
+ return null;
83
+ }
@@ -21,6 +21,48 @@ import * as affinity from "./affinity.ts";
21
21
  import type { QuotaPool } from "./cooldown.ts";
22
22
  import * as cooldown from "./cooldown.ts";
23
23
 
24
+ interface ProviderEntry {
25
+ provider: Provider;
26
+ pool: QuotaPool;
27
+ credentialName: ProviderName;
28
+ }
29
+
30
+ /** Maps ampProvider name → list of provider entries (checked against config at lookup time). */
31
+ const PROVIDER_REGISTRY = new Map<string, { configKey: keyof ProxyConfig["providers"]; entries: ProviderEntry[] }>([
32
+ [
33
+ "anthropic",
34
+ {
35
+ configKey: "anthropic",
36
+ entries: [{ provider: anthropic, pool: "anthropic", credentialName: "anthropic" }],
37
+ },
38
+ ],
39
+ [
40
+ "openai",
41
+ {
42
+ configKey: "codex",
43
+ entries: [{ provider: codex, pool: "codex", credentialName: "codex" }],
44
+ },
45
+ ],
46
+ [
47
+ "google",
48
+ {
49
+ configKey: "google",
50
+ entries: [
51
+ { provider: gemini, pool: "gemini", credentialName: "google" },
52
+ { provider: antigravity, pool: "antigravity", credentialName: "google" },
53
+ ],
54
+ },
55
+ ],
56
+ ]);
57
+
58
+ /** Reverse map: QuotaPool → Provider (built once at module init). */
59
+ const POOL_TO_PROVIDER = new Map<QuotaPool, Provider>();
60
+ for (const [, { entries }] of PROVIDER_REGISTRY) {
61
+ for (const entry of entries) {
62
+ POOL_TO_PROVIDER.set(entry.pool, entry.provider);
63
+ }
64
+ }
65
+
24
66
  export interface RouteResult {
25
67
  decision: RouteDecision;
26
68
  provider: string;
@@ -49,7 +91,7 @@ export function routeRequest(
49
91
  const pinned = affinity.get(threadId, ampProvider);
50
92
  if (pinned && !cooldown.isExhausted(pinned.pool, pinned.account)) {
51
93
  const handler = providerForPool(pinned.pool);
52
- if (handler && handler.isAvailable(pinned.account)) {
94
+ if (handler?.isAvailable(pinned.account)) {
53
95
  if (!cooldown.isCoolingDown(pinned.pool, pinned.account)) {
54
96
  logger.route(handler.routeDecision, ampProvider, modelStr);
55
97
  return result(handler, ampProvider, modelStr, pinned.account, pinned.pool);
@@ -116,30 +158,13 @@ export function recordSuccess(pool: QuotaPool, account: number): void {
116
158
  }
117
159
 
118
160
  function buildCandidates(ampProvider: string, config: ProxyConfig): Candidate[] {
119
- const candidates: Candidate[] = [];
161
+ const reg = PROVIDER_REGISTRY.get(ampProvider);
162
+ if (!reg || !config.providers[reg.configKey]) return [];
120
163
 
121
- switch (ampProvider) {
122
- case "anthropic":
123
- if (config.providers.anthropic) {
124
- addAccountCandidates(candidates, anthropic, "anthropic", "anthropic");
125
- }
126
- break;
127
-
128
- case "openai":
129
- if (config.providers.codex) {
130
- addAccountCandidates(candidates, codex, "codex", "codex");
131
- }
132
- break;
133
-
134
- case "google":
135
- if (config.providers.google) {
136
- // Both gemini and antigravity use "google" credentials — separate quota pools
137
- addAccountCandidates(candidates, gemini, "gemini", "google");
138
- addAccountCandidates(candidates, antigravity, "antigravity", "google");
139
- }
140
- break;
164
+ const candidates: Candidate[] = [];
165
+ for (const entry of reg.entries) {
166
+ addAccountCandidates(candidates, entry.provider, entry.pool, entry.credentialName);
141
167
  }
142
-
143
168
  return candidates;
144
169
  }
145
170
 
@@ -177,16 +202,7 @@ function pickCandidate(candidates: Candidate[]): Candidate | null {
177
202
  }
178
203
 
179
204
  function providerForPool(pool: QuotaPool): Provider | null {
180
- switch (pool) {
181
- case "anthropic":
182
- return anthropic;
183
- case "codex":
184
- return codex;
185
- case "gemini":
186
- return gemini;
187
- case "antigravity":
188
- return antigravity;
189
- }
205
+ return POOL_TO_PROVIDER.get(pool) ?? null;
190
206
  }
191
207
 
192
208
  function result(
@@ -1,7 +1,7 @@
1
- /** Request body — parsed once, carried through the entire pipeline.
1
+ /** Request body — lazy parsing with regex fast path.
2
2
  * Fast path: regex extracts model + stream flag without JSON.parse.
3
- * Slow path: full parse only when Google providers need the parsed object (CCA wrapping)
4
- * or when model rewrite is needed (-api-preview). */
3
+ * Slow path: full JSON.parse only when .parsed or .forwardBody is accessed
4
+ * (e.g. Google CCA wrapping, model rewrite). */
5
5
 
6
6
  import { resolveModel, rewriteBodyModel } from "../routing/models.ts";
7
7
  import * as path from "../utils/path.ts";
@@ -19,12 +19,19 @@ export interface ParsedBody {
19
19
  readonly parsed: Record<string, unknown> | null;
20
20
  }
21
21
 
22
- /** Parse request body JSON.parse for reliable model + stream extraction.
23
- * Parsed object is cached and reused by forwardBody and Google CCA wrapping. */
22
+ /** Fast-path regex to extract "model" and "stream" without JSON.parse. */
23
+ const MODEL_RE = /"model"\s*:\s*"([^"]+)"/;
24
+ const STREAM_RE = /"stream"\s*:\s*true\b/;
25
+
24
26
  export function parseBody(raw: string, sub: string): ParsedBody {
25
27
  const fallbackModel = path.modelFromUrl(sub);
26
28
  if (!raw) return { raw, parsed: null, ampModel: fallbackModel, stream: false, forwardBody: raw };
27
29
 
30
+ const ampModel = raw.match(MODEL_RE)?.[1] ?? fallbackModel;
31
+ const stream = STREAM_RE.test(raw);
32
+ const providerModel = ampModel ? resolveModel(ampModel) : null;
33
+ const needsRewrite = !!(ampModel && providerModel && providerModel !== ampModel);
34
+
28
35
  let _parsed: Record<string, unknown> | null | undefined;
29
36
  function ensureParsed(): Record<string, unknown> | null {
30
37
  if (_parsed === undefined) {
@@ -37,12 +44,6 @@ export function parseBody(raw: string, sub: string): ParsedBody {
37
44
  return _parsed;
38
45
  }
39
46
 
40
- const parsed = ensureParsed();
41
- const ampModel = (typeof parsed?.model === "string" ? parsed.model : null) ?? fallbackModel;
42
- const stream = parsed?.stream === true;
43
- const providerModel = ampModel ? resolveModel(ampModel) : null;
44
- const needsRewrite = !!(ampModel && providerModel && providerModel !== ampModel);
45
-
46
47
  let _forwardBody: string | undefined;
47
48
  function ensureForwardBody(): string {
48
49
  if (_forwardBody === undefined) {
@@ -3,17 +3,14 @@
3
3
  import type { ProxyConfig } from "../config/config.ts";
4
4
  import * as rewriter from "../proxy/rewriter.ts";
5
5
  import * as upstream from "../proxy/upstream.ts";
6
- import { parseRetryAfter, record429 } from "../routing/cooldown.ts";
7
- import { recordSuccess, rerouteAfter429, routeRequest } from "../routing/router.ts";
6
+ import { startCleanup } from "../routing/affinity.ts";
7
+ import { tryReroute, tryWithCachePreserve } from "../routing/retry.ts";
8
+ import { recordSuccess, routeRequest } from "../routing/router.ts";
8
9
  import { handleInternal, isLocalMethod } from "../tools/internal.ts";
9
10
  import { logger } from "../utils/logger.ts";
10
11
  import * as path from "../utils/path.ts";
11
- import { parseBody } from "./body.ts";
12
-
13
- /** Max 429-reroute attempts before falling back to upstream. */
14
- const MAX_REROUTE_ATTEMPTS = 4;
15
- /** Max seconds to wait-and-retry on the same account (preserves prompt cache). */
16
- const CACHE_PRESERVE_WAIT_MAX_S = 10;
12
+ import { record, snapshot } from "../utils/stats.ts";
13
+ import { type ParsedBody, parseBody } from "./body.ts";
17
14
 
18
15
  export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
19
16
  const server = Bun.serve({
@@ -22,43 +19,51 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
22
19
 
23
20
  async fetch(req) {
24
21
  const startTime = Date.now();
22
+ const url = new URL(req.url);
23
+ let status = 500;
25
24
  try {
26
- const response = await handle(req, config);
27
- logRequest(req, startTime, response.status);
25
+ const response = await handle(req, url, config);
26
+ status = response.status;
28
27
  return response;
29
28
  } catch (err) {
30
- logRequest(req, startTime, 500);
31
29
  logger.error("Unhandled server error", { error: String(err) });
32
- return new Response(JSON.stringify({ error: "Internal proxy error" }), {
33
- status: 500,
34
- headers: { "Content-Type": "application/json" },
35
- });
30
+ return Response.json({ error: "Internal proxy error" }, { status });
31
+ } finally {
32
+ logger.info(`${req.method} ${url.pathname} ${status}`, { duration: Date.now() - startTime });
36
33
  }
37
34
  },
38
35
  });
39
36
 
37
+ startCleanup();
40
38
  logger.info(`ampcode-connector listening on http://localhost:${config.port}`);
39
+
40
+ const shutdown = () => {
41
+ logger.info("Shutting down...");
42
+ server.stop();
43
+ process.exit(0);
44
+ };
45
+
46
+ process.on("SIGTERM", shutdown);
47
+ process.on("SIGINT", shutdown);
48
+
41
49
  return server;
42
50
  }
43
51
 
44
- async function handle(req: Request, config: ProxyConfig): Promise<Response> {
45
- const { pathname } = new URL(req.url);
52
+ async function handle(req: Request, url: URL, config: ProxyConfig): Promise<Response> {
53
+ const { pathname, search } = url;
46
54
 
47
55
  if ((pathname === "/" || pathname === "/status") && req.method === "GET") {
48
56
  return healthCheck(config);
49
57
  }
50
58
 
51
59
  if (path.browser(pathname)) {
52
- const target = new URL(pathname + new URL(req.url).search, config.ampUpstreamUrl);
60
+ const target = new URL(pathname + search, config.ampUpstreamUrl);
53
61
  return Response.redirect(target.toString(), 302);
54
62
  }
55
63
 
56
64
  if (path.passthrough(pathname)) {
57
- if (pathname.startsWith("/api/internal")) {
58
- const search = new URL(req.url).search;
59
- if (isLocalMethod(search)) {
60
- return handleInternal(req, search, config);
61
- }
65
+ if (pathname.startsWith("/api/internal") && isLocalMethod(search)) {
66
+ return handleInternal(req, search, config);
62
67
  }
63
68
  return upstream.forward(req, config.ampUpstreamUrl, config.ampApiKey);
64
69
  }
@@ -75,75 +80,69 @@ async function handleProvider(
75
80
  pathname: string,
76
81
  config: ProxyConfig,
77
82
  ): Promise<Response> {
83
+ const startTime = Date.now();
78
84
  const sub = path.subpath(pathname);
79
85
  const threadId = req.headers.get("x-amp-thread-id") ?? undefined;
80
86
 
81
87
  const rawBody = req.method === "POST" ? await req.text() : "";
82
88
  const body = parseBody(rawBody, sub);
83
89
  const ampModel = body.ampModel;
84
- let route = routeRequest(providerName, ampModel, config, threadId);
90
+ const route = routeRequest(providerName, ampModel, config, threadId);
85
91
 
86
92
  logger.info(
87
93
  `ROUTE ${route.decision} provider=${providerName} model=${ampModel ?? "?"} account=${route.account} sub=${sub}`,
88
94
  );
89
95
 
96
+ let response: Response;
97
+
90
98
  if (route.handler) {
91
99
  const rewrite = ampModel ? rewriter.rewrite(ampModel) : undefined;
92
- const response = await route.handler.forward(sub, body, req.headers, rewrite, route.account);
93
-
94
- // 429 try to preserve prompt cache by waiting briefly on same account,
95
- // then fall back to rerouting to a different account/pool.
96
- if (response.status === 429 && route.pool) {
97
- const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
98
- logger.warn(`429 from ${route.decision} account=${route.account}`, { retryAfter });
99
-
100
- // Short burst: wait and retry same account to preserve prompt cache
101
- if (retryAfter !== undefined && retryAfter <= CACHE_PRESERVE_WAIT_MAX_S) {
102
- logger.debug(`Waiting ${retryAfter}s to preserve prompt cache on account=${route.account}`);
103
- await Bun.sleep(retryAfter * 1000);
104
- const retryResponse = await route.handler.forward(sub, body, req.headers, rewrite, route.account);
105
- if (retryResponse.status !== 429 && retryResponse.status !== 401) {
106
- recordSuccess(route.pool, route.account);
107
- return retryResponse;
108
- }
109
- // Still failing — fall through to reroute
110
- if (retryResponse.status === 429) {
111
- const nextRetryAfter = parseRetryAfter(retryResponse.headers.get("retry-after"));
112
- record429(route.pool, route.account, nextRetryAfter);
113
- }
114
- }
115
-
116
- // Reroute to different account/pool (cache loss accepted)
117
- for (let attempt = 0; attempt < MAX_REROUTE_ATTEMPTS; attempt++) {
118
- const next = rerouteAfter429(providerName, ampModel, config, route.pool, route.account, retryAfter, threadId);
119
- if (!next) break;
120
-
121
- route = next;
122
- logger.info(`REROUTE -> ${route.decision} account=${route.account}`);
123
- const retryResponse = await route.handler!.forward(sub, body, req.headers, rewrite, route.account);
124
-
125
- if (retryResponse.status === 429 && route.pool) {
126
- const nextRetryAfter = parseRetryAfter(retryResponse.headers.get("retry-after"));
127
- record429(route.pool, route.account, nextRetryAfter);
128
- continue;
129
- }
130
-
131
- if (retryResponse.status !== 401) {
132
- if (route.pool) recordSuccess(route.pool, route.account);
133
- return retryResponse;
134
- }
135
- break;
100
+ const handlerResponse = await route.handler.forward(sub, body, req.headers, rewrite, route.account);
101
+
102
+ if (handlerResponse.status === 429 && route.pool) {
103
+ const cached = await tryWithCachePreserve(route, sub, body, req.headers, rewrite, handlerResponse);
104
+ if (cached) {
105
+ response = cached;
106
+ } else {
107
+ const rerouted = await tryReroute(
108
+ providerName,
109
+ ampModel,
110
+ config,
111
+ route,
112
+ sub,
113
+ body,
114
+ req.headers,
115
+ rewrite,
116
+ handlerResponse,
117
+ threadId,
118
+ );
119
+ response = rerouted ?? (await fallbackUpstream(req, body, config));
136
120
  }
137
-
138
- // All reroutes failed — fall through to upstream
139
- } else if (response.status === 401) {
121
+ } else if (handlerResponse.status === 401) {
140
122
  logger.debug("Local provider denied, falling back to upstream");
123
+ response = await fallbackUpstream(req, body, config);
141
124
  } else {
142
125
  if (route.pool) recordSuccess(route.pool, route.account);
143
- return response;
126
+ response = handlerResponse;
144
127
  }
128
+ } else {
129
+ response = await fallbackUpstream(req, body, config);
145
130
  }
146
131
 
132
+ record({
133
+ timestamp: new Date().toISOString(),
134
+ route: route.decision,
135
+ provider: providerName,
136
+ model: ampModel ?? "unknown",
137
+ statusCode: response.status,
138
+ durationMs: Date.now() - startTime,
139
+ });
140
+
141
+ return response;
142
+ }
143
+
144
+ /** Fall back to Amp upstream when local providers fail. */
145
+ function fallbackUpstream(req: Request, body: ParsedBody, config: ProxyConfig): Promise<Response> {
147
146
  const upstreamReq = new Request(req.url, {
148
147
  method: req.method,
149
148
  headers: req.headers,
@@ -153,24 +152,12 @@ async function handleProvider(
153
152
  }
154
153
 
155
154
  function healthCheck(config: ProxyConfig): Response {
156
- return new Response(
157
- JSON.stringify(
158
- {
159
- status: "ok",
160
- service: "ampcode-connector",
161
- port: config.port,
162
- upstream: config.ampUpstreamUrl,
163
- providers: config.providers,
164
- },
165
- null,
166
- 2,
167
- ),
168
- { status: 200, headers: { "Content-Type": "application/json" } },
169
- );
170
- }
171
-
172
- function logRequest(req: Request, startTime: number, statusCode: number): void {
173
- const duration = Date.now() - startTime;
174
- const { pathname } = new URL(req.url);
175
- logger.info(`${req.method} ${pathname}`, { duration });
155
+ return Response.json({
156
+ status: "ok",
157
+ service: "ampcode-connector",
158
+ port: config.port,
159
+ upstream: config.ampUpstreamUrl,
160
+ providers: config.providers,
161
+ stats: snapshot(),
162
+ });
176
163
  }
@@ -3,70 +3,93 @@
3
3
  import type { ProxyConfig } from "../config/config.ts";
4
4
  import * as upstream from "../proxy/upstream.ts";
5
5
  import { logger } from "../utils/logger.ts";
6
- import { handleExtract } from "./web-extract.ts";
6
+ import { handleWebRead } from "./web-read.ts";
7
7
  import { handleSearch } from "./web-search.ts";
8
8
 
9
- /** Methods handled locally instead of forwarding to Amp upstream. */
10
- const LOCAL_METHODS = new Set(["extractWebPageContent", "webSearch2"]);
9
+ interface HandlerContext {
10
+ params: Record<string, unknown>;
11
+ config: ProxyConfig;
12
+ forward: () => Promise<Response>;
13
+ }
14
+
15
+ type Handler = (ctx: HandlerContext) => Promise<Response>;
16
+
17
+ const handlers: Record<string, Handler> = {
18
+ extractWebPageContent: async ({ params }) => {
19
+ const url = str(params, "url");
20
+ if (!url) return error("invalid-params", "missing 'url'");
21
+ return json(
22
+ await handleWebRead({ url, objective: str(params, "objective"), forceRefetch: bool(params, "forceRefetch") }),
23
+ );
24
+ },
25
+
26
+ webSearch2: async ({ params, config, forward }) => {
27
+ if (!config.exaApiKey) {
28
+ logger.warn("webSearch2: no exaApiKey configured, forwarding upstream");
29
+ return forward();
30
+ }
31
+ const objective = str(params, "objective");
32
+ if (!objective) return error("invalid-params", "missing 'objective'");
33
+ return handleSearch(
34
+ { objective, searchQueries: strArray(params, "searchQueries"), maxResults: num(params, "maxResults") },
35
+ config.exaApiKey,
36
+ ).then(json, (err) => {
37
+ logger.warn("webSearch2 local failed, falling back to upstream", { error: String(err) });
38
+ return forward();
39
+ });
40
+ },
41
+ };
11
42
 
12
- /** Check if an internal method should be handled locally. */
13
43
  export function isLocalMethod(search: string): boolean {
14
- const method = search.replace("?", "");
15
- return LOCAL_METHODS.has(method);
44
+ return search.replace("?", "") in handlers;
16
45
  }
17
46
 
18
- /** Handle an internal RPC call locally. Returns null if method is unknown. */
19
47
  export async function handleInternal(req: Request, search: string, config: ProxyConfig): Promise<Response> {
20
48
  const method = search.replace("?", "");
21
49
  const body = req.method === "POST" ? await req.text() : "";
22
50
 
23
- let params: Record<string, unknown> = {};
51
+ let params: Record<string, unknown>;
24
52
  try {
25
- const parsed = JSON.parse(body);
26
- params = parsed.params ?? {};
53
+ params = JSON.parse(body).params ?? {};
27
54
  } catch {
28
- return jsonResponse({ ok: false, error: { code: "invalid-body", message: "Invalid JSON body" } });
55
+ return error("invalid-body", "Invalid JSON body");
29
56
  }
30
57
 
31
58
  logger.info(`[INTERNAL] ${method} params=${JSON.stringify(params).slice(0, 200)}`);
32
59
 
33
- switch (method) {
34
- case "extractWebPageContent": {
35
- const result = await handleExtract({
36
- url: params.url as string,
37
- objective: params.objective as string | undefined,
38
- forceRefetch: params.forceRefetch as boolean | undefined,
39
- });
40
- return jsonResponse(result);
41
- }
42
- case "webSearch2": {
43
- if (!config.exaApiKey) {
44
- logger.warn("webSearch2 called but no exaApiKey configured, forwarding upstream");
45
- return upstream.forward(rebuildRequest(req, body), config.ampUpstreamUrl, config.ampApiKey);
46
- }
47
- const result = await handleSearch(
48
- {
49
- objective: params.objective as string,
50
- searchQueries: params.searchQueries as string[] | undefined,
51
- maxResults: params.maxResults as number | undefined,
52
- },
53
- config.exaApiKey,
54
- );
55
- return jsonResponse(result);
56
- }
57
- default:
58
- return upstream.forward(req, config.ampUpstreamUrl, config.ampApiKey);
59
- }
60
+ const forward = () => {
61
+ const rebuilt = new Request(req.url, { method: req.method, headers: req.headers, body: body || undefined });
62
+ return upstream.forward(rebuilt, config.ampUpstreamUrl, config.ampApiKey);
63
+ };
64
+
65
+ const handler = handlers[method];
66
+ return handler ? handler({ params, config, forward }) : forward();
67
+ }
68
+
69
+ function str(p: Record<string, unknown>, k: string): string | undefined {
70
+ const v = p[k];
71
+ return typeof v === "string" ? v : undefined;
72
+ }
73
+
74
+ function num(p: Record<string, unknown>, k: string): number | undefined {
75
+ const v = p[k];
76
+ return typeof v === "number" ? v : undefined;
77
+ }
78
+
79
+ function bool(p: Record<string, unknown>, k: string): boolean | undefined {
80
+ const v = p[k];
81
+ return typeof v === "boolean" ? v : undefined;
82
+ }
83
+
84
+ function strArray(p: Record<string, unknown>, k: string): string[] | undefined {
85
+ const v = p[k];
86
+ return Array.isArray(v) && v.every((i: unknown) => typeof i === "string") ? (v as string[]) : undefined;
60
87
  }
61
88
 
62
- /** Rebuild request with already-consumed body for upstream forwarding. */
63
- function rebuildRequest(req: Request, body: string): Request {
64
- return new Request(req.url, { method: req.method, headers: req.headers, body: body || undefined });
89
+ function json(data: unknown): Response {
90
+ return Response.json(data);
65
91
  }
66
92
 
67
- function jsonResponse(data: unknown, status = 200): Response {
68
- return new Response(JSON.stringify(data), {
69
- status,
70
- headers: { "Content-Type": "application/json" },
71
- });
93
+ function error(code: string, message: string): Response {
94
+ return Response.json({ ok: false, error: { code, message } });
72
95
  }