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.
- package/package.json +5 -3
- package/src/auth/auto-refresh.ts +44 -0
- package/src/auth/callback-server.ts +1 -1
- package/src/auth/discovery.ts +1 -1
- package/src/auth/oauth.ts +11 -7
- package/src/auth/store.ts +73 -40
- package/src/cli/setup.ts +4 -4
- package/src/cli/tui.ts +1 -1
- package/src/config/config.ts +11 -11
- package/src/index.ts +27 -1
- package/src/providers/antigravity.ts +1 -4
- package/src/providers/base.ts +1 -4
- package/src/providers/codex.ts +1 -1
- package/src/proxy/rewriter.ts +12 -11
- package/src/proxy/upstream.ts +1 -4
- package/src/routing/affinity.ts +54 -19
- package/src/routing/cooldown.ts +6 -6
- package/src/routing/retry.ts +83 -0
- package/src/routing/router.ts +49 -33
- package/src/server/body.ts +12 -11
- package/src/server/server.ts +79 -92
- package/src/tools/internal.ts +69 -46
- package/src/tools/web-read.ts +336 -0
- package/src/tools/web-search.ts +33 -26
- package/src/utils/code-assist.ts +1 -1
- package/src/utils/logger.ts +31 -2
- package/src/utils/path.ts +9 -4
- package/src/utils/stats.ts +69 -0
- package/src/utils/streaming.ts +10 -11
- package/tsconfig.json +3 -3
- package/src/tools/web-extract.ts +0 -137
package/src/routing/cooldown.ts
CHANGED
|
@@ -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
|
|
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(
|
|
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
|
|
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(
|
|
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
|
+
}
|
package/src/routing/router.ts
CHANGED
|
@@ -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
|
|
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
|
|
161
|
+
const reg = PROVIDER_REGISTRY.get(ampProvider);
|
|
162
|
+
if (!reg || !config.providers[reg.configKey]) return [];
|
|
120
163
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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(
|
package/src/server/body.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
/** Request body —
|
|
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
|
|
4
|
-
*
|
|
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
|
-
/**
|
|
23
|
-
|
|
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) {
|
package/src/server/server.ts
CHANGED
|
@@ -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 {
|
|
7
|
-
import {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
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 } =
|
|
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 +
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
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
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
}
|
package/src/tools/internal.ts
CHANGED
|
@@ -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 {
|
|
6
|
+
import { handleWebRead } from "./web-read.ts";
|
|
7
7
|
import { handleSearch } from "./web-search.ts";
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
26
|
-
params = parsed.params ?? {};
|
|
53
|
+
params = JSON.parse(body).params ?? {};
|
|
27
54
|
} catch {
|
|
28
|
-
return
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
68
|
-
return
|
|
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
|
}
|