ampcode-connector 0.1.2 → 0.1.4
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 +4 -86
- package/config.yaml +5 -14
- package/package.json +5 -3
- package/src/cli/setup.ts +4 -1
- package/src/config/config.ts +19 -8
- package/src/constants.ts +23 -0
- package/src/providers/anthropic.ts +3 -3
- package/src/providers/antigravity.ts +10 -27
- package/src/providers/base.ts +11 -17
- package/src/providers/codex.ts +35 -6
- package/src/providers/gemini.ts +14 -25
- package/src/routing/models.ts +21 -0
- package/src/server/body.ts +70 -0
- package/src/server/server.ts +18 -11
- package/src/tools/internal.ts +72 -0
- package/src/tools/web-extract.ts +137 -0
- package/src/tools/web-search.ts +48 -0
- package/src/utils/code-assist.ts +18 -0
- package/src/utils/logger.ts +1 -0
- package/src/utils/path.ts +0 -18
- package/src/utils/update-check.ts +3 -3
package/README.md
CHANGED
|
@@ -1,96 +1,14 @@
|
|
|
1
1
|
# ampcode-connector
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-

|
|
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
|
|
31
|
-
bunx ampcode-connector # start
|
|
7
|
+
bunx ampcode-connector login # authenticate providers
|
|
8
|
+
bunx ampcode-connector # start
|
|
32
9
|
```
|
|
33
10
|
|
|
34
|
-
|
|
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
|
|
19
|
-
codex: true
|
|
20
|
-
google: true
|
|
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.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -40,12 +40,14 @@
|
|
|
40
40
|
},
|
|
41
41
|
"devDependencies": {
|
|
42
42
|
"@biomejs/biome": "^2.4.0",
|
|
43
|
-
"@types/bun": "
|
|
43
|
+
"@types/bun": "^1.3.9"
|
|
44
44
|
},
|
|
45
45
|
"peerDependencies": {
|
|
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
|
|
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) {
|
package/src/config/config.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
@@ -7,6 +7,28 @@ export const DEFAULT_ANTIGRAVITY_PROJECT = "rising-fact-p41fc";
|
|
|
7
7
|
|
|
8
8
|
export const ANTHROPIC_API_URL = "https://api.anthropic.com";
|
|
9
9
|
export const OPENAI_API_URL = "https://api.openai.com";
|
|
10
|
+
export const CODEX_BASE_URL = "https://chatgpt.com/backend-api";
|
|
11
|
+
|
|
12
|
+
/** Codex-specific headers required by the ChatGPT backend. */
|
|
13
|
+
export const codexHeaders = {
|
|
14
|
+
BETA: "OpenAI-Beta",
|
|
15
|
+
ACCOUNT_ID: "chatgpt-account-id",
|
|
16
|
+
ORIGINATOR: "originator",
|
|
17
|
+
SESSION_ID: "session_id",
|
|
18
|
+
CONVERSATION_ID: "conversation_id",
|
|
19
|
+
} as const;
|
|
20
|
+
|
|
21
|
+
export const codexHeaderValues = {
|
|
22
|
+
BETA_RESPONSES: "responses=experimental",
|
|
23
|
+
ORIGINATOR: "codex_cli_rs",
|
|
24
|
+
VERSION: "0.101.0",
|
|
25
|
+
USER_AGENT: `codex_cli_rs/0.101.0 (${process.platform} ${process.arch})`,
|
|
26
|
+
} as const;
|
|
27
|
+
|
|
28
|
+
/** Map /v1/responses → /codex/responses for the ChatGPT backend. */
|
|
29
|
+
export const codexPathMap: Record<string, string> = {
|
|
30
|
+
"/v1/responses": "/codex/responses",
|
|
31
|
+
} as const;
|
|
10
32
|
export const DEFAULT_AMP_UPSTREAM_URL = "https://ampcode.com";
|
|
11
33
|
|
|
12
34
|
export const ANTHROPIC_TOKEN_URL = "https://platform.claude.com/v1/oauth/token";
|
|
@@ -56,6 +78,7 @@ export const passthroughPrefixes = [
|
|
|
56
78
|
"/api/threads",
|
|
57
79
|
"/api/otel",
|
|
58
80
|
"/api/tab",
|
|
81
|
+
"/api/durable-thread-workers",
|
|
59
82
|
] as const;
|
|
60
83
|
|
|
61
84
|
/** 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:
|
|
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
|
|
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
|
-
|
|
80
|
-
|
|
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 =
|
|
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" });
|
package/src/providers/base.ts
CHANGED
|
@@ -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:
|
|
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,23 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
|
34
35
|
body: opts.body,
|
|
35
36
|
});
|
|
36
37
|
|
|
37
|
-
const
|
|
38
|
-
if (isSSE) return sse.proxy(response, opts.rewrite);
|
|
38
|
+
const contentType = response.headers.get("Content-Type") ?? "application/json";
|
|
39
39
|
|
|
40
40
|
if (!response.ok) {
|
|
41
41
|
const text = await response.text();
|
|
42
|
-
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
|
-
});
|
|
42
|
+
logger.error(`${opts.providerName} API error (${response.status})`, { error: text.slice(0, 200) });
|
|
43
|
+
return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
|
|
47
44
|
}
|
|
48
45
|
|
|
46
|
+
const isSSE = contentType.includes("text/event-stream") || opts.streaming;
|
|
47
|
+
if (isSSE) return sse.proxy(response, opts.rewrite);
|
|
48
|
+
|
|
49
49
|
if (opts.rewrite) {
|
|
50
50
|
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
|
-
});
|
|
51
|
+
return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
|
|
55
52
|
}
|
|
56
53
|
|
|
57
|
-
return new Response(response.body, {
|
|
58
|
-
status: response.status,
|
|
59
|
-
headers: { "Content-Type": response.headers.get("Content-Type") ?? "application/json" },
|
|
60
|
-
});
|
|
54
|
+
return new Response(response.body, { status: response.status, headers: { "Content-Type": contentType } });
|
|
61
55
|
}
|
|
62
56
|
|
|
63
57
|
export function denied(providerName: string): Response {
|
package/src/providers/codex.ts
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
|
-
/** Forwards requests to
|
|
1
|
+
/** Forwards requests to chatgpt.com/backend-api/codex with Codex CLI OAuth token.
|
|
2
|
+
*
|
|
3
|
+
* The ChatGPT backend requires specific headers (account-id from JWT, originator,
|
|
4
|
+
* OpenAI-Beta) and a different URL path (/codex/responses) than api.openai.com. */
|
|
2
5
|
|
|
3
6
|
import { codex as config } from "../auth/configs.ts";
|
|
4
7
|
import * as oauth from "../auth/oauth.ts";
|
|
5
8
|
import * as store from "../auth/store.ts";
|
|
6
|
-
import {
|
|
7
|
-
import
|
|
9
|
+
import { CODEX_BASE_URL, codexHeaders, codexHeaderValues, codexPathMap } from "../constants.ts";
|
|
10
|
+
import { fromBase64url } from "../utils/encoding.ts";
|
|
8
11
|
import type { Provider } from "./base.ts";
|
|
9
12
|
import { denied, forward } from "./base.ts";
|
|
10
13
|
|
|
@@ -21,16 +24,42 @@ export const provider: Provider = {
|
|
|
21
24
|
const accessToken = await oauth.token(config, account);
|
|
22
25
|
if (!accessToken) return denied("OpenAI Codex");
|
|
23
26
|
|
|
27
|
+
const accountId = getAccountId(accessToken, account);
|
|
28
|
+
const codexPath = codexPathMap[sub] ?? sub;
|
|
29
|
+
|
|
24
30
|
return forward({
|
|
25
|
-
url: `${
|
|
26
|
-
body,
|
|
31
|
+
url: `${CODEX_BASE_URL}${codexPath}`,
|
|
32
|
+
body: body.forwardBody,
|
|
33
|
+
streaming: body.stream,
|
|
27
34
|
providerName: "OpenAI Codex",
|
|
28
35
|
rewrite,
|
|
29
36
|
headers: {
|
|
30
37
|
"Content-Type": "application/json",
|
|
31
38
|
Authorization: `Bearer ${accessToken}`,
|
|
32
|
-
Accept:
|
|
39
|
+
Accept: body.stream ? "text/event-stream" : "application/json",
|
|
40
|
+
Connection: "Keep-Alive",
|
|
41
|
+
[codexHeaders.BETA]: codexHeaderValues.BETA_RESPONSES,
|
|
42
|
+
[codexHeaders.ORIGINATOR]: codexHeaderValues.ORIGINATOR,
|
|
43
|
+
"User-Agent": codexHeaderValues.USER_AGENT,
|
|
44
|
+
Version: codexHeaderValues.VERSION,
|
|
45
|
+
...(accountId ? { [codexHeaders.ACCOUNT_ID]: accountId } : {}),
|
|
33
46
|
},
|
|
34
47
|
});
|
|
35
48
|
},
|
|
36
49
|
};
|
|
50
|
+
|
|
51
|
+
/** Extract chatgpt_account_id from JWT, falling back to stored credentials. */
|
|
52
|
+
function getAccountId(accessToken: string, account: number): string | undefined {
|
|
53
|
+
const creds = store.get("codex", account);
|
|
54
|
+
if (creds?.accountId) return creds.accountId;
|
|
55
|
+
|
|
56
|
+
try {
|
|
57
|
+
const parts = accessToken.split(".");
|
|
58
|
+
if (parts.length < 2 || !parts[1]) return undefined;
|
|
59
|
+
const payload = JSON.parse(new TextDecoder().decode(fromBase64url(parts[1]))) as Record<string, unknown>;
|
|
60
|
+
const auth = payload["https://api.openai.com/auth"] as Record<string, unknown> | undefined;
|
|
61
|
+
return (auth?.["chatgpt_account_id"] as string) ?? undefined;
|
|
62
|
+
} catch {
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
}
|
package/src/providers/gemini.ts
CHANGED
|
@@ -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
|
|
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 =
|
|
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({
|
|
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
|
+
}
|
package/src/server/server.ts
CHANGED
|
@@ -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
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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=${
|
|
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 =
|
|
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
|
|
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,
|
|
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
|
|
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
|
+
}
|
package/src/utils/code-assist.ts
CHANGED
|
@@ -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
|
+
}
|
package/src/utils/logger.ts
CHANGED
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 {
|
|
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
|
|