ampcode-connector 0.1.2 → 0.1.3

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/README.md CHANGED
@@ -1,96 +1,14 @@
1
1
  # ampcode-connector
2
2
 
3
- Stop burning AmpCode credits. Route [AmpCode](https://ampcode.com) through your **existing** Claude Code, Codex CLI & Gemini CLI subscriptions — for free.
4
-
5
- ![demo](demo.gif)
6
-
7
- ```
8
- AmpCode → ampcode-connector → Claude Code (free)
9
- → OpenAI Codex CLI (free)
10
- → Gemini CLI (free)
11
- → AmpCode upstream (paid, last resort)
12
- ```
13
-
14
- ## Supported Providers
15
-
16
- | Provider | Models | How |
17
- |----------|--------|-----|
18
- | **Claude Code** | Opus 4, Sonnet 4, Haiku | Anthropic OAuth |
19
- | **OpenAI Codex CLI** | GPT-5, o3, Codex | OpenAI OAuth |
20
- | **Gemini CLI** | Gemini Pro, Flash | Google OAuth (dual: Gemini + Vertex pools) |
21
-
22
- > **Multi-account** — log in multiple times per provider to multiply your quota.
23
-
24
- ## Quick Start
25
-
26
- Three commands. That's it.
3
+ Route [AmpCode](https://ampcode.com) through your existing Claude Code, Codex CLI & Gemini CLI subscriptions.
27
4
 
28
5
  ```bash
29
6
  bunx ampcode-connector setup # point AmpCode → proxy
30
- bunx ampcode-connector login # authenticate providers (browser OAuth)
31
- bunx ampcode-connector # start proxy
7
+ bunx ampcode-connector login # authenticate providers
8
+ bunx ampcode-connector # start
32
9
  ```
33
10
 
34
- <details>
35
- <summary>Or clone & run locally</summary>
36
-
37
- ```bash
38
- git clone https://github.com/nghyane/ampcode-connector.git
39
- cd ampcode-connector
40
- bun install
41
-
42
- bun run setup
43
- bun run login
44
- bun start
45
- ```
46
-
47
- </details>
48
-
49
- Requires [Bun](https://bun.sh) 1.3+.
50
-
51
- ### Provider Login
52
-
53
- ```bash
54
- bunx ampcode-connector login # interactive TUI (↑↓ navigate, enter to login, d to disconnect)
55
- bunx ampcode-connector login anthropic # Claude Code
56
- bunx ampcode-connector login codex # OpenAI Codex CLI
57
- bunx ampcode-connector login google # Gemini CLI + Antigravity
58
- ```
59
-
60
- Each login opens your browser. Log in multiple times to stack accounts.
61
-
62
- ## How It Works
63
-
64
- ```
65
- Request in → local OAuth available? → yes → forward to provider API (free)
66
- → no → forward to ampcode.com (paid)
67
-
68
- On 429 → retry with different account/pool
69
- On 401 → fallback to AmpCode upstream
70
- ```
71
-
72
- Non-AI routes (auth, threads, telemetry) pass through to `ampcode.com` transparently — the proxy is invisible to AmpCode.
73
-
74
- ### Smart Routing
75
-
76
- - **Thread affinity** — same thread sticks to the same account for consistency
77
- - **Least-connections** — new threads go to the least-loaded account
78
- - **Cooldown** — rate-limited accounts are temporarily skipped
79
- - **Google cascade** — Gemini → Antigravity (separate quota pools, double the free tier)
80
-
81
- ## Configuration
82
-
83
- Edit [`config.yaml`](config.yaml) to change port, log level, or toggle providers.
84
-
85
- Amp API key resolution: `config.yaml` → `AMP_API_KEY` env → `~/.local/share/amp/secrets.json`.
86
-
87
- ## Development
88
-
89
- ```bash
90
- bun run dev # --watch mode
91
- bun test # run tests
92
- bun run check # lint + typecheck + test
93
- ```
11
+ Requires [Bun](https://bun.sh) 1.3+. Config at `./config.yaml` or `~/.config/ampcode-connector/config.yaml` — see [`config.example.yaml`](config.example.yaml).
94
12
 
95
13
  ## License
96
14
 
package/config.yaml CHANGED
@@ -1,20 +1,11 @@
1
1
  # ampcode-connector configuration
2
- # ================================
2
+ # Copy to ./config.yaml or ~/.config/ampcode-connector/config.yaml
3
3
 
4
- # Port the proxy server listens on
5
4
  port: 7860
6
-
7
- # Amp upstream URL (fallback for non-intercepted requests)
8
- # ampUpstreamUrl: https://ampcode.com
9
-
10
- # Amp API key (optional - also reads from AMP_API_KEY env or ~/.local/share/amp/secrets.json)
11
- # ampApiKey: your-amp-api-key
12
-
13
- # Log level: debug, info, warn, error
14
5
  logLevel: info
6
+ exaApiKey: d633d146-e67d-42af-8e18-a16a527d3ff2
15
7
 
16
- # Enable/disable individual providers
17
8
  providers:
18
- anthropic: true # Claude Code OAuth -> api.anthropic.com
19
- codex: true # OpenAI Codex OAuth -> api.openai.com
20
- google: true # Google OAuth -> Gemini CLI + Antigravity dual quota
9
+ anthropic: true
10
+ codex: true
11
+ google: true
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampcode-connector",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -46,6 +46,8 @@
46
46
  "typescript": "^5"
47
47
  },
48
48
  "dependencies": {
49
- "@google/genai": "^1.41.0"
49
+ "@google/genai": "^1.41.0",
50
+ "@kreuzberg/html-to-markdown": "^2.25.0",
51
+ "exa-js": "^2.4.0"
50
52
  }
51
53
  }
package/src/cli/setup.ts CHANGED
@@ -135,7 +135,10 @@ export async function setup(): Promise<void> {
135
135
 
136
136
  if (connected.length > 0) {
137
137
  hasAny = true;
138
- const emails = connected.map((a) => a.email).filter(Boolean).join(", ");
138
+ const emails = connected
139
+ .map((a) => a.email)
140
+ .filter(Boolean)
141
+ .join(", ");
139
142
  const info = emails ? ` ${s.dim}${emails}${s.reset}` : "";
140
143
  line(` ${p.label.padEnd(16)} ${s.green}${connected.length} account(s)${s.reset}${info}`);
141
144
  } else if (total.length > 0) {
@@ -10,6 +10,7 @@ export interface ProxyConfig {
10
10
  port: number;
11
11
  ampUpstreamUrl: string;
12
12
  ampApiKey?: string;
13
+ exaApiKey?: string;
13
14
  logLevel: LogLevel;
14
15
  providers: {
15
16
  anthropic: boolean;
@@ -25,7 +26,11 @@ const DEFAULTS: ProxyConfig = {
25
26
  providers: { anthropic: true, codex: true, google: true },
26
27
  };
27
28
 
28
- const CONFIG_PATH = join(process.cwd(), "config.yaml");
29
+ /** Config search order: cwd → ~/.config/ampcode-connector */
30
+ const CONFIG_PATHS = [
31
+ join(process.cwd(), "config.yaml"),
32
+ join(homedir(), ".config", "ampcode-connector", "config.yaml"),
33
+ ];
29
34
  const SECRETS_PATH = join(homedir(), ".local", "share", "amp", "secrets.json");
30
35
 
31
36
  export async function loadConfig(): Promise<ProxyConfig> {
@@ -37,6 +42,7 @@ export async function loadConfig(): Promise<ProxyConfig> {
37
42
  port: asNumber(file?.["port"]) ?? DEFAULTS.port,
38
43
  ampUpstreamUrl: asString(file?.["ampUpstreamUrl"]) ?? DEFAULTS.ampUpstreamUrl,
39
44
  ampApiKey: apiKey,
45
+ exaApiKey: asString(file?.["exaApiKey"]) ?? process.env["EXA_API_KEY"],
40
46
  logLevel: asLogLevel(file?.["logLevel"]) ?? DEFAULTS.logLevel,
41
47
  providers: {
42
48
  anthropic: asBool(providers?.["anthropic"]) ?? DEFAULTS.providers.anthropic,
@@ -47,14 +53,19 @@ export async function loadConfig(): Promise<ProxyConfig> {
47
53
  }
48
54
 
49
55
  async function readConfigFile(): Promise<Record<string, unknown> | null> {
50
- const file = Bun.file(CONFIG_PATH);
51
- if (!(await file.exists())) return null;
52
- try {
53
- const text = await file.text();
54
- return Bun.YAML.parse(text) as Record<string, unknown>;
55
- } catch (err) {
56
- throw new Error(`Invalid config.yaml: ${err}`);
56
+ for (const configPath of CONFIG_PATHS) {
57
+ const file = Bun.file(configPath);
58
+ if (await file.exists()) {
59
+ try {
60
+ const text = await file.text();
61
+ logger.info(`Loaded config from ${configPath}`);
62
+ return Bun.YAML.parse(text) as Record<string, unknown>;
63
+ } catch (err) {
64
+ throw new Error(`Invalid config at ${configPath}: ${err}`);
65
+ }
66
+ }
57
67
  }
68
+ return null;
58
69
  }
59
70
 
60
71
  /** Amp API key resolution: config file → AMP_API_KEY env → secrets.json */
package/src/constants.ts CHANGED
@@ -56,6 +56,7 @@ export const passthroughPrefixes = [
56
56
  "/api/threads",
57
57
  "/api/otel",
58
58
  "/api/tab",
59
+ "/api/durable-thread-workers",
59
60
  ] as const;
60
61
 
61
62
  /** Browser routes — redirect to ampcode.com (auth cookies need correct domain). */
@@ -10,7 +10,6 @@ import {
10
10
  filteredBetaFeatures,
11
11
  stainlessHeaders,
12
12
  } from "../constants.ts";
13
- import * as path from "../utils/path.ts";
14
13
  import type { Provider } from "./base.ts";
15
14
  import { denied, forward } from "./base.ts";
16
15
 
@@ -29,12 +28,13 @@ export const provider: Provider = {
29
28
 
30
29
  return forward({
31
30
  url: `${ANTHROPIC_API_URL}${sub}`,
32
- body,
31
+ body: body.forwardBody,
32
+ streaming: body.stream,
33
33
  providerName: "Anthropic",
34
34
  rewrite,
35
35
  headers: {
36
36
  ...stainlessHeaders,
37
- Accept: path.streaming(body) ? "text/event-stream" : "application/json",
37
+ Accept: body.stream ? "text/event-stream" : "application/json",
38
38
  "Accept-Encoding": "br, gzip, deflate",
39
39
  Connection: "keep-alive",
40
40
  "Content-Type": "application/json",
@@ -6,7 +6,7 @@ import { google as config } from "../auth/configs.ts";
6
6
  import * as oauth from "../auth/oauth.ts";
7
7
  import * as store from "../auth/store.ts";
8
8
  import { ANTIGRAVITY_DAILY_ENDPOINT, AUTOPUSH_ENDPOINT, CODE_ASSIST_ENDPOINT } from "../constants.ts";
9
- import * as codeAssist from "../utils/code-assist.ts";
9
+ import { buildUrl, maybeWrap, withUnwrap } from "../utils/code-assist.ts";
10
10
  import { logger } from "../utils/logger.ts";
11
11
  import * as path from "../utils/path.ts";
12
12
  import type { Provider } from "./base.ts";
@@ -53,37 +53,20 @@ export const provider: Provider = {
53
53
  const gemini = path.gemini(sub);
54
54
  const action = gemini?.action ?? "generateContent";
55
55
  const model = gemini?.model ?? "";
56
- const requestBody = maybeWrap(body, projectId, model);
57
- const unwrapThenRewrite = withUnwrap(rewrite);
58
-
59
- return tryEndpoints(requestBody, headers, action, unwrapThenRewrite);
60
- },
61
- };
62
-
63
- function withUnwrap(rewrite?: (d: string) => string): (d: string) => string {
64
- return rewrite ? (d: string) => rewrite(codeAssist.unwrap(d)) : codeAssist.unwrap;
65
- }
66
-
67
- function maybeWrap(body: string, projectId: string, model: string): string {
68
- try {
69
- const parsed = JSON.parse(body) as Record<string, unknown>;
70
- if (parsed["project"]) return body;
71
- return codeAssist.wrapRequest({
72
- projectId,
73
- model,
74
- body: parsed,
56
+ const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, {
75
57
  userAgent: "antigravity",
76
58
  requestIdPrefix: "agent",
77
59
  requestType: "agent",
78
60
  });
79
- } catch (err) {
80
- logger.debug("Body parse failed, forwarding as-is", { error: String(err) });
81
- return body;
82
- }
83
- }
61
+ const unwrapThenRewrite = withUnwrap(rewrite);
62
+
63
+ return tryEndpoints(requestBody, body.stream, headers, action, unwrapThenRewrite);
64
+ },
65
+ };
84
66
 
85
67
  async function tryEndpoints(
86
68
  body: string,
69
+ streaming: boolean,
87
70
  headers: Record<string, string>,
88
71
  action: string,
89
72
  rewrite?: (data: string) => string,
@@ -91,9 +74,9 @@ async function tryEndpoints(
91
74
  let lastError: Error | null = null;
92
75
 
93
76
  for (const endpoint of endpoints) {
94
- const url = codeAssist.buildUrl(endpoint, action);
77
+ const url = buildUrl(endpoint, action);
95
78
  try {
96
- const response = await forward({ url, body, headers, providerName: "Antigravity", rewrite });
79
+ const response = await forward({ url, body, streaming, headers, providerName: "Antigravity", rewrite });
97
80
  if (response.status < 500) return response;
98
81
  lastError = new Error(`${endpoint} returned ${response.status}`);
99
82
  logger.debug("Endpoint 5xx, trying next", { provider: "Antigravity" });
@@ -1,8 +1,8 @@
1
1
  /** Provider interface and shared request forwarding. */
2
2
 
3
+ import type { ParsedBody } from "../server/body.ts";
3
4
  import type { RouteDecision } from "../utils/logger.ts";
4
5
  import { logger } from "../utils/logger.ts";
5
- import * as path from "../utils/path.ts";
6
6
  import * as sse from "../utils/streaming.ts";
7
7
 
8
8
  export interface Provider {
@@ -12,7 +12,7 @@ export interface Provider {
12
12
  accountCount(): number;
13
13
  forward(
14
14
  path: string,
15
- body: string,
15
+ body: ParsedBody,
16
16
  headers: Headers,
17
17
  rewrite?: (data: string) => string,
18
18
  account?: number,
@@ -22,6 +22,7 @@ export interface Provider {
22
22
  interface ForwardOptions {
23
23
  url: string;
24
24
  body: string;
25
+ streaming: boolean;
25
26
  headers: Record<string, string>;
26
27
  providerName: string;
27
28
  rewrite?: (data: string) => string;
@@ -34,30 +35,22 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
34
35
  body: opts.body,
35
36
  });
36
37
 
37
- const isSSE = response.headers.get("Content-Type")?.includes("text/event-stream") || path.streaming(opts.body);
38
+ const contentType = response.headers.get("Content-Type") ?? "application/json";
39
+ const isSSE = contentType.includes("text/event-stream") || opts.streaming;
38
40
  if (isSSE) return sse.proxy(response, opts.rewrite);
39
41
 
40
42
  if (!response.ok) {
41
43
  const text = await response.text();
42
44
  logger.error(`${opts.providerName} API error`, { error: text.slice(0, 200) });
43
- return new Response(text, {
44
- status: response.status,
45
- headers: { "Content-Type": response.headers.get("Content-Type") ?? "application/json" },
46
- });
45
+ return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
47
46
  }
48
47
 
49
48
  if (opts.rewrite) {
50
49
  const text = await response.text();
51
- return new Response(opts.rewrite(text), {
52
- status: response.status,
53
- headers: { "Content-Type": response.headers.get("Content-Type") ?? "application/json" },
54
- });
50
+ return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
55
51
  }
56
52
 
57
- return new Response(response.body, {
58
- status: response.status,
59
- headers: { "Content-Type": response.headers.get("Content-Type") ?? "application/json" },
60
- });
53
+ return new Response(response.body, { status: response.status, headers: { "Content-Type": contentType } });
61
54
  }
62
55
 
63
56
  export function denied(providerName: string): Response {
@@ -4,7 +4,6 @@ import { codex as config } from "../auth/configs.ts";
4
4
  import * as oauth from "../auth/oauth.ts";
5
5
  import * as store from "../auth/store.ts";
6
6
  import { OPENAI_API_URL } from "../constants.ts";
7
- import * as path from "../utils/path.ts";
8
7
  import type { Provider } from "./base.ts";
9
8
  import { denied, forward } from "./base.ts";
10
9
 
@@ -23,13 +22,14 @@ export const provider: Provider = {
23
22
 
24
23
  return forward({
25
24
  url: `${OPENAI_API_URL}${sub}`,
26
- body,
25
+ body: body.forwardBody,
26
+ streaming: body.stream,
27
27
  providerName: "OpenAI Codex",
28
28
  rewrite,
29
29
  headers: {
30
30
  "Content-Type": "application/json",
31
31
  Authorization: `Bearer ${accessToken}`,
32
- Accept: path.streaming(body) ? "text/event-stream" : "application/json",
32
+ Accept: body.stream ? "text/event-stream" : "application/json",
33
33
  },
34
34
  });
35
35
  },
@@ -8,7 +8,7 @@ import { google as config } from "../auth/configs.ts";
8
8
  import * as oauth from "../auth/oauth.ts";
9
9
  import * as store from "../auth/store.ts";
10
10
  import { CODE_ASSIST_ENDPOINT } from "../constants.ts";
11
- import * as codeAssist from "../utils/code-assist.ts";
11
+ import { buildUrl, maybeWrap, withUnwrap } from "../utils/code-assist.ts";
12
12
  import { logger } from "../utils/logger.ts";
13
13
  import * as path from "../utils/path.ts";
14
14
  import type { Provider } from "./base.ts";
@@ -53,31 +53,20 @@ export const provider: Provider = {
53
53
  return denied("Gemini CLI (unsupported path)");
54
54
  }
55
55
 
56
- const url = codeAssist.buildUrl(CODE_ASSIST_ENDPOINT, gemini.action);
57
- const requestBody = maybeWrap(body, projectId, gemini.model);
56
+ const url = buildUrl(CODE_ASSIST_ENDPOINT, gemini.action);
57
+ const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, gemini.model, {
58
+ userAgent: "pi-coding-agent",
59
+ requestIdPrefix: "pi",
60
+ });
58
61
  const unwrapThenRewrite = withUnwrap(rewrite);
59
62
 
60
- return forward({ url, body: requestBody, headers, providerName: "Gemini CLI", rewrite: unwrapThenRewrite });
63
+ return forward({
64
+ url,
65
+ body: requestBody,
66
+ streaming: body.stream,
67
+ headers,
68
+ providerName: "Gemini CLI",
69
+ rewrite: unwrapThenRewrite,
70
+ });
61
71
  },
62
72
  };
63
-
64
- function withUnwrap(rewrite?: (d: string) => string): (d: string) => string {
65
- return rewrite ? (d: string) => rewrite(codeAssist.unwrap(d)) : codeAssist.unwrap;
66
- }
67
-
68
- function maybeWrap(body: string, projectId: string, model: string): string {
69
- try {
70
- const parsed = JSON.parse(body) as Record<string, unknown>;
71
- if (parsed["project"]) return body;
72
- return codeAssist.wrapRequest({
73
- projectId,
74
- model,
75
- body: parsed,
76
- userAgent: "pi-coding-agent",
77
- requestIdPrefix: "pi",
78
- });
79
- } catch (err) {
80
- logger.debug("Body parse failed, forwarding as-is", { error: String(err) });
81
- return body;
82
- }
83
- }
@@ -0,0 +1,21 @@
1
+ /** Centralized model name mapping: Amp CLI model → provider API model.
2
+ * Amp's proxy may use aliased model names that differ from the provider's API.
3
+ * This module resolves the correct model name and provides the serialized body. */
4
+
5
+ /** Suffix patterns stripped when forwarding to the real provider API. */
6
+ const STRIP_SUFFIXES = ["-api-preview"] as const;
7
+
8
+ /** Resolve the model name the provider API expects.
9
+ * Returns the original if no mapping applies. */
10
+ export function resolveModel(ampModel: string): string {
11
+ for (const suffix of STRIP_SUFFIXES) {
12
+ if (ampModel.endsWith(suffix)) return ampModel.slice(0, -suffix.length);
13
+ }
14
+ return ampModel;
15
+ }
16
+
17
+ /** Return body string with provider model name substituted.
18
+ * Shallow-copies parsed to avoid mutating the shared ParsedBody.parsed reference. */
19
+ export function rewriteBodyModel(parsed: Record<string, unknown>, providerModel: string): string {
20
+ return JSON.stringify({ ...parsed, model: providerModel });
21
+ }
@@ -0,0 +1,70 @@
1
+ /** Request body — parsed once, carried through the entire pipeline.
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). */
5
+
6
+ import { resolveModel, rewriteBodyModel } from "../routing/models.ts";
7
+ import * as path from "../utils/path.ts";
8
+
9
+ export interface ParsedBody {
10
+ /** Original raw body string (for upstream fallback). */
11
+ readonly raw: string;
12
+ /** Amp model name from body.model (before mapping). */
13
+ readonly ampModel: string | null;
14
+ /** Whether body.stream === true. */
15
+ readonly stream: boolean;
16
+ /** Body string to send to provider (re-serialized only if model was remapped). */
17
+ readonly forwardBody: string;
18
+ /** Parsed JSON object — lazy, only materialized when accessed (Google CCA wrapping). */
19
+ readonly parsed: Record<string, unknown> | null;
20
+ }
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. */
24
+ export function parseBody(raw: string, sub: string): ParsedBody {
25
+ const fallbackModel = path.modelFromUrl(sub);
26
+ if (!raw) return { raw, parsed: null, ampModel: fallbackModel, stream: false, forwardBody: raw };
27
+
28
+ let _parsed: Record<string, unknown> | null | undefined;
29
+ function ensureParsed(): Record<string, unknown> | null {
30
+ if (_parsed === undefined) {
31
+ try {
32
+ _parsed = JSON.parse(raw) as Record<string, unknown>;
33
+ } catch {
34
+ _parsed = null;
35
+ }
36
+ }
37
+ return _parsed;
38
+ }
39
+
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
+ let _forwardBody: string | undefined;
47
+ function ensureForwardBody(): string {
48
+ if (_forwardBody === undefined) {
49
+ if (needsRewrite) {
50
+ const p = ensureParsed();
51
+ _forwardBody = p ? rewriteBodyModel(p, providerModel!) : raw;
52
+ } else {
53
+ _forwardBody = raw;
54
+ }
55
+ }
56
+ return _forwardBody;
57
+ }
58
+
59
+ return {
60
+ raw,
61
+ ampModel,
62
+ stream,
63
+ get parsed() {
64
+ return ensureParsed();
65
+ },
66
+ get forwardBody() {
67
+ return ensureForwardBody();
68
+ },
69
+ };
70
+ }
@@ -5,8 +5,10 @@ import * as rewriter from "../proxy/rewriter.ts";
5
5
  import * as upstream from "../proxy/upstream.ts";
6
6
  import { parseRetryAfter, record429 } from "../routing/cooldown.ts";
7
7
  import { recordSuccess, rerouteAfter429, routeRequest } from "../routing/router.ts";
8
+ import { handleInternal, isLocalMethod } from "../tools/internal.ts";
8
9
  import { logger } from "../utils/logger.ts";
9
10
  import * as path from "../utils/path.ts";
11
+ import { parseBody } from "./body.ts";
10
12
 
11
13
  /** Max 429-reroute attempts before falling back to upstream. */
12
14
  const MAX_REROUTE_ATTEMPTS = 4;
@@ -52,6 +54,12 @@ async function handle(req: Request, config: ProxyConfig): Promise<Response> {
52
54
  }
53
55
 
54
56
  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
+ }
62
+ }
55
63
  return upstream.forward(req, config.ampUpstreamUrl, config.ampApiKey);
56
64
  }
57
65
 
@@ -70,21 +78,20 @@ async function handleProvider(
70
78
  const sub = path.subpath(pathname);
71
79
  const threadId = req.headers.get("x-amp-thread-id") ?? undefined;
72
80
 
73
- let body = "";
74
- if (req.method === "POST") body = await req.text();
75
-
76
- const model = path.model(body) ?? path.modelFromUrl(sub);
77
- let route = routeRequest(providerName, model, config, threadId);
81
+ const rawBody = req.method === "POST" ? await req.text() : "";
82
+ const body = parseBody(rawBody, sub);
83
+ const ampModel = body.ampModel;
84
+ let route = routeRequest(providerName, ampModel, config, threadId);
78
85
 
79
86
  logger.info(
80
- `ROUTE ${route.decision} provider=${providerName} model=${model ?? "?"} account=${route.account} sub=${sub}`,
87
+ `ROUTE ${route.decision} provider=${providerName} model=${ampModel ?? "?"} account=${route.account} sub=${sub}`,
81
88
  );
82
89
 
83
90
  if (route.handler) {
84
- const rewrite = model ? rewriter.rewrite(model) : undefined;
91
+ const rewrite = ampModel ? rewriter.rewrite(ampModel) : undefined;
85
92
  const response = await route.handler.forward(sub, body, req.headers, rewrite, route.account);
86
93
 
87
- // 429 try to preserve prompt cache by waiting briefly on same account,
94
+ // 429 try to preserve prompt cache by waiting briefly on same account,
88
95
  // then fall back to rerouting to a different account/pool.
89
96
  if (response.status === 429 && route.pool) {
90
97
  const retryAfter = parseRetryAfter(response.headers.get("retry-after"));
@@ -108,11 +115,11 @@ async function handleProvider(
108
115
 
109
116
  // Reroute to different account/pool (cache loss accepted)
110
117
  for (let attempt = 0; attempt < MAX_REROUTE_ATTEMPTS; attempt++) {
111
- const next = rerouteAfter429(providerName, model, config, route.pool, route.account, retryAfter, threadId);
118
+ const next = rerouteAfter429(providerName, ampModel, config, route.pool, route.account, retryAfter, threadId);
112
119
  if (!next) break;
113
120
 
114
121
  route = next;
115
- logger.info(`REROUTE ${route.decision} account=${route.account}`);
122
+ logger.info(`REROUTE -> ${route.decision} account=${route.account}`);
116
123
  const retryResponse = await route.handler!.forward(sub, body, req.headers, rewrite, route.account);
117
124
 
118
125
  if (retryResponse.status === 429 && route.pool) {
@@ -140,7 +147,7 @@ async function handleProvider(
140
147
  const upstreamReq = new Request(req.url, {
141
148
  method: req.method,
142
149
  headers: req.headers,
143
- body: body || undefined,
150
+ body: body.raw || undefined,
144
151
  });
145
152
  return upstream.forward(upstreamReq, config.ampUpstreamUrl, config.ampApiKey);
146
153
  }
@@ -0,0 +1,72 @@
1
+ /** Dispatcher for /api/internal?{method} — routes to local handlers or upstream. */
2
+
3
+ import type { ProxyConfig } from "../config/config.ts";
4
+ import * as upstream from "../proxy/upstream.ts";
5
+ import { logger } from "../utils/logger.ts";
6
+ import { handleExtract } from "./web-extract.ts";
7
+ import { handleSearch } from "./web-search.ts";
8
+
9
+ /** Methods handled locally instead of forwarding to Amp upstream. */
10
+ const LOCAL_METHODS = new Set(["extractWebPageContent", "webSearch2"]);
11
+
12
+ /** Check if an internal method should be handled locally. */
13
+ export function isLocalMethod(search: string): boolean {
14
+ const method = search.replace("?", "");
15
+ return LOCAL_METHODS.has(method);
16
+ }
17
+
18
+ /** Handle an internal RPC call locally. Returns null if method is unknown. */
19
+ export async function handleInternal(req: Request, search: string, config: ProxyConfig): Promise<Response> {
20
+ const method = search.replace("?", "");
21
+ const body = req.method === "POST" ? await req.text() : "";
22
+
23
+ let params: Record<string, unknown> = {};
24
+ try {
25
+ const parsed = JSON.parse(body);
26
+ params = parsed.params ?? {};
27
+ } catch {
28
+ return jsonResponse({ ok: false, error: { code: "invalid-body", message: "Invalid JSON body" } });
29
+ }
30
+
31
+ logger.info(`[INTERNAL] ${method} params=${JSON.stringify(params).slice(0, 200)}`);
32
+
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
+ }
61
+
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 });
65
+ }
66
+
67
+ function jsonResponse(data: unknown, status = 200): Response {
68
+ return new Response(JSON.stringify(data), {
69
+ status,
70
+ headers: { "Content-Type": "application/json" },
71
+ });
72
+ }
@@ -0,0 +1,137 @@
1
+ /** Local handler for extractWebPageContent — fetches a URL and converts HTML to Markdown. */
2
+
3
+ import type { JsPreprocessingPreset } from "@kreuzberg/html-to-markdown";
4
+ import { convertWithOptionsHandle, createConversionOptionsHandle } from "@kreuzberg/html-to-markdown";
5
+ import { logger } from "../utils/logger.ts";
6
+
7
+ const FETCH_TIMEOUT_MS = 30_000;
8
+ const MAX_CONTENT_BYTES = 262_144; // 256 KB — matches CLI truncation limit
9
+
10
+ const conversionHandle = createConversionOptionsHandle({
11
+ skipImages: true,
12
+ preprocessing: { enabled: true, preset: "Aggressive" as JsPreprocessingPreset },
13
+ });
14
+
15
+ export interface ExtractParams {
16
+ url: string;
17
+ objective?: string;
18
+ forceRefetch?: boolean;
19
+ }
20
+
21
+ type ExtractResult =
22
+ | { ok: true; result: { excerpts: string[] } }
23
+ | { ok: true; result: { fullContent: string } }
24
+ | { ok: false; error: { code: string; message: string } };
25
+
26
+ export async function handleExtract(params: ExtractParams): Promise<ExtractResult> {
27
+ const { url, objective } = params;
28
+
29
+ let response: Response;
30
+ try {
31
+ response = await fetch(url, {
32
+ signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
33
+ redirect: "follow",
34
+ headers: { "User-Agent": "Mozilla/5.0 (compatible; AmpBot/1.0)" },
35
+ });
36
+ } catch (err) {
37
+ logger.warn("extractWebPageContent fetch failed", { url, error: String(err) });
38
+ return { ok: false, error: { code: "fetch-error", message: `Failed to fetch ${url}: ${String(err)}` } };
39
+ }
40
+
41
+ if (!response.ok) {
42
+ return {
43
+ ok: false,
44
+ error: { code: "fetch-error", message: `HTTP ${response.status} from ${url}` },
45
+ };
46
+ }
47
+
48
+ const raw = await response.text();
49
+ const contentType = response.headers.get("content-type") ?? "";
50
+ const markdown = toMarkdown(raw, contentType);
51
+
52
+ if (objective) {
53
+ const excerpts = extractExcerpts(markdown, objective);
54
+ return { ok: true, result: { excerpts } };
55
+ }
56
+
57
+ return { ok: true, result: { fullContent: truncate(markdown) } };
58
+ }
59
+
60
+ /** Convert raw response body to Markdown based on content type. */
61
+ function toMarkdown(raw: string, contentType: string): string {
62
+ if (contentType.includes("text/html") || contentType.includes("application/xhtml")) {
63
+ return convertWithOptionsHandle(raw, conversionHandle);
64
+ }
65
+ if (contentType.includes("application/json")) {
66
+ try {
67
+ return `\`\`\`json\n${JSON.stringify(JSON.parse(raw), null, 2)}\n\`\`\``;
68
+ } catch {
69
+ return raw;
70
+ }
71
+ }
72
+ return raw;
73
+ }
74
+
75
+ /** Split markdown into paragraphs, score by keyword overlap with objective, return top excerpts. */
76
+ function extractExcerpts(markdown: string, objective: string): string[] {
77
+ const paragraphs = markdown
78
+ .split(/\n{2,}/)
79
+ .map((p) => p.trim())
80
+ .filter((p) => p.length > 0);
81
+
82
+ if (paragraphs.length === 0) return [truncate(markdown)];
83
+
84
+ const keywords = objective
85
+ .toLowerCase()
86
+ .split(/\W+/)
87
+ .filter((w) => w.length > 2);
88
+
89
+ if (keywords.length === 0) return [truncate(markdown)];
90
+
91
+ const scored = paragraphs.map((p, index) => {
92
+ const lower = p.toLowerCase();
93
+ const score = keywords.reduce((s, kw) => s + (lower.includes(kw) ? 1 : 0), 0);
94
+ return { text: p, score, index };
95
+ });
96
+
97
+ const matched = scored.filter((s) => s.score > 0);
98
+
99
+ // If nothing matched, return full content as single excerpt
100
+ if (matched.length === 0) return [truncate(markdown)];
101
+
102
+ // Sort by score desc, take top entries, then restore original order
103
+ matched.sort((a, b) => b.score - a.score || a.index - b.index);
104
+ const top = matched.slice(0, 20);
105
+ top.sort((a, b) => a.index - b.index);
106
+
107
+ const joined = top.map((s) => s.text);
108
+ return truncateExcerpts(joined);
109
+ }
110
+
111
+ /** Truncate a string to MAX_CONTENT_BYTES. */
112
+ function truncate(text: string): string {
113
+ const encoder = new TextEncoder();
114
+ const bytes = encoder.encode(text);
115
+ if (bytes.length <= MAX_CONTENT_BYTES) return text;
116
+ const decoder = new TextDecoder("utf-8", { fatal: false });
117
+ return decoder.decode(bytes.slice(0, MAX_CONTENT_BYTES));
118
+ }
119
+
120
+ /** Truncate excerpt array so total joined size stays within limit. */
121
+ function truncateExcerpts(excerpts: string[]): string[] {
122
+ const encoder = new TextEncoder();
123
+ let total = 0;
124
+ const result: string[] = [];
125
+ for (const e of excerpts) {
126
+ const len = encoder.encode(e).length + 2; // +2 for \n\n join
127
+ if (total + len > MAX_CONTENT_BYTES) {
128
+ // Add truncated last excerpt if there's room
129
+ const remaining = MAX_CONTENT_BYTES - total;
130
+ if (remaining > 100) result.push(truncate(e));
131
+ break;
132
+ }
133
+ result.push(e);
134
+ total += len;
135
+ }
136
+ return result.length > 0 ? result : [truncate(excerpts[0]!)];
137
+ }
@@ -0,0 +1,48 @@
1
+ /** Local handler for webSearch2 — searches via Exa API. */
2
+
3
+ import Exa from "exa-js";
4
+ import { logger } from "../utils/logger.ts";
5
+
6
+ export interface SearchParams {
7
+ objective: string;
8
+ searchQueries?: string[];
9
+ maxResults?: number;
10
+ }
11
+
12
+ interface SearchResultItem {
13
+ title: string;
14
+ url: string;
15
+ excerpts: string[];
16
+ }
17
+
18
+ type SearchResult =
19
+ | { ok: true; result: { results: SearchResultItem[]; showParallelAttribution: boolean } }
20
+ | { ok: false; error: { code: string; message: string } };
21
+
22
+ export async function handleSearch(params: SearchParams, exaApiKey: string): Promise<SearchResult> {
23
+ const { objective, searchQueries, maxResults = 5 } = params;
24
+ const query = searchQueries?.length ? searchQueries.join(" ") : objective;
25
+
26
+ try {
27
+ const exa = new Exa(exaApiKey);
28
+ const response = await exa.search(query, {
29
+ numResults: maxResults,
30
+ type: "auto",
31
+ contents: {
32
+ highlights: { query: objective },
33
+ },
34
+ });
35
+
36
+ const results: SearchResultItem[] = response.results.map((r) => ({
37
+ title: r.title ?? "",
38
+ url: r.url,
39
+ excerpts: r.highlights?.length ? r.highlights : [],
40
+ }));
41
+
42
+ logger.info(`[SEARCH] Exa returned ${results.length} results for "${query.slice(0, 80)}"`);
43
+ return { ok: true, result: { results, showParallelAttribution: false } };
44
+ } catch (err) {
45
+ logger.error("webSearch2 Exa error", { error: String(err) });
46
+ return { ok: false, error: { code: "search-error", message: String(err) } };
47
+ }
48
+ }
@@ -40,3 +40,21 @@ export function unwrap(data: string): string {
40
40
  return data;
41
41
  }
42
42
  }
43
+
44
+ /** Chain CCA unwrap with an optional rewrite function. */
45
+ export function withUnwrap(rewrite?: (d: string) => string): (d: string) => string {
46
+ return rewrite ? (d: string) => rewrite(unwrap(d)) : unwrap;
47
+ }
48
+
49
+ /** Wrap body in CCA envelope if not already wrapped. */
50
+ export function maybeWrap(
51
+ parsed: Record<string, unknown> | null,
52
+ raw: string,
53
+ projectId: string,
54
+ model: string,
55
+ opts: { userAgent: "antigravity" | "pi-coding-agent"; requestIdPrefix: "agent" | "pi"; requestType?: "agent" },
56
+ ): string {
57
+ if (!parsed) return raw;
58
+ if (parsed["project"]) return raw;
59
+ return wrapRequest({ projectId, model, body: parsed, ...opts });
60
+ }
@@ -13,6 +13,7 @@ interface LogEntry {
13
13
  model?: string;
14
14
  duration?: number;
15
15
  error?: string;
16
+ [key: string]: unknown;
16
17
  }
17
18
 
18
19
  const LOG_LEVELS: Record<LogLevel, number> = {
package/src/utils/path.ts CHANGED
@@ -22,15 +22,6 @@ export function subpath(pathname: string): string {
22
22
  return match?.[1] ?? pathname;
23
23
  }
24
24
 
25
- export function model(body: string): string | null {
26
- try {
27
- const parsed = JSON.parse(body) as { model?: string };
28
- return parsed.model ?? null;
29
- } catch {
30
- return null;
31
- }
32
- }
33
-
34
25
  export function modelFromUrl(url: string): string | null {
35
26
  const match = url.match(/models\/([^/:]+)/);
36
27
  return match?.[1] ?? null;
@@ -41,12 +32,3 @@ export function gemini(url: string): { model: string; action: string } | null {
41
32
  if (!match) return null;
42
33
  return { model: match[1]!, action: match[2]! };
43
34
  }
44
-
45
- export function streaming(body: string): boolean {
46
- try {
47
- const parsed = JSON.parse(body) as { stream?: boolean };
48
- return parsed.stream === true;
49
- } catch {
50
- return false;
51
- }
52
- }
@@ -1,6 +1,6 @@
1
1
  /** Check npm registry for newer versions on startup. */
2
2
 
3
- import { s, line } from "../cli/ansi.ts";
3
+ import { line, s } from "../cli/ansi.ts";
4
4
 
5
5
  const PACKAGE_NAME = "ampcode-connector";
6
6
  const REGISTRY_URL = `https://registry.npmjs.org/${PACKAGE_NAME}/latest`;
@@ -27,8 +27,8 @@ async function fetchLatestVersion(): Promise<string | null> {
27
27
 
28
28
  function isNewer(latest: string, current: string): boolean {
29
29
  const parse = (v: string) => v.split(".").map(Number);
30
- const [la, lb, lc] = parse(latest);
31
- const [ca, cb, cc] = parse(current);
30
+ const [la = 0, lb = 0, lc = 0] = parse(latest);
31
+ const [ca = 0, cb = 0, cc = 0] = parse(current);
32
32
  return la > ca || (la === ca && lb > cb) || (la === ca && lb === cb && lc > cc);
33
33
  }
34
34