ampcode-connector 0.1.8 → 0.1.10
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 +6 -7
- package/src/auth/callback-server.ts +1 -1
- package/src/auth/oauth.ts +11 -3
- package/src/auth/store.ts +19 -14
- package/src/cli/ansi.ts +1 -1
- package/src/cli/setup.ts +5 -1
- package/src/cli/status.ts +12 -3
- package/src/cli/tui.ts +4 -1
- package/src/config/config.ts +6 -1
- package/src/constants.ts +2 -3
- package/src/providers/anthropic.ts +2 -1
- package/src/providers/antigravity.ts +7 -10
- package/src/providers/base.ts +1 -71
- package/src/providers/codex-sse.ts +1 -1
- package/src/providers/codex.ts +3 -1
- package/src/providers/forward.ts +74 -0
- package/src/providers/gemini.ts +3 -2
- package/src/routing/affinity.ts +2 -2
- package/src/routing/cooldown.ts +10 -1
- package/src/server/server.ts +6 -1
- package/src/tools/web-read.ts +2 -2
- package/src/tools/web-search.ts +1 -1
- package/src/utils/code-assist.ts +1 -1
- package/src/utils/streaming.ts +1 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "ampcode-connector",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.10",
|
|
4
4
|
"description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -34,22 +34,21 @@
|
|
|
34
34
|
"dev": "bun run --watch src/index.ts",
|
|
35
35
|
"setup": "bun run src/index.ts setup",
|
|
36
36
|
"login": "bun run src/index.ts login",
|
|
37
|
-
"test": "bun test tests/router.test.ts tests/middleware.test.ts tests/rewriter.test.ts",
|
|
37
|
+
"test": "bun test tests/router.test.ts tests/middleware.test.ts tests/rewriter.test.ts tests/forward.test.ts",
|
|
38
38
|
"test:e2e": "bun test tests/code-assist.test.ts",
|
|
39
39
|
"check": "biome check src/ tests/ && tsc --noEmit && bun run test",
|
|
40
40
|
"format": "biome check --write src/ tests/"
|
|
41
41
|
},
|
|
42
42
|
"devDependencies": {
|
|
43
|
-
"@biomejs/biome": "^2.4.
|
|
43
|
+
"@biomejs/biome": "^2.4.2",
|
|
44
|
+
"@google/genai": "^1.42.0",
|
|
44
45
|
"@types/bun": "^1.3.9"
|
|
45
46
|
},
|
|
46
47
|
"peerDependencies": {
|
|
47
|
-
"typescript": "^5"
|
|
48
|
+
"typescript": "^5.9.3"
|
|
48
49
|
},
|
|
49
50
|
"dependencies": {
|
|
50
|
-
"@
|
|
51
|
-
"@kreuzberg/html-to-markdown": "^2.25.0",
|
|
52
|
-
"ampcode-connector": "^0.1.5",
|
|
51
|
+
"@kreuzberg/html-to-markdown": "^2.25.1",
|
|
53
52
|
"exa-js": "^2.4.0"
|
|
54
53
|
}
|
|
55
54
|
}
|
package/src/auth/oauth.ts
CHANGED
|
@@ -60,7 +60,9 @@ export async function tokenFromAny(config: OAuthConfig): Promise<{ accessToken:
|
|
|
60
60
|
try {
|
|
61
61
|
const refreshed = await refresh(config, c.refreshToken, account);
|
|
62
62
|
return { accessToken: refreshed.accessToken, account };
|
|
63
|
-
} catch {
|
|
63
|
+
} catch (err) {
|
|
64
|
+
logger.debug(`${config.providerName}:${account} refresh failed in tokenFromAny`, { error: String(err) });
|
|
65
|
+
}
|
|
64
66
|
}
|
|
65
67
|
|
|
66
68
|
return null;
|
|
@@ -178,10 +180,16 @@ async function exchange(config: OAuthConfig, params: Record<string, string>): Pr
|
|
|
178
180
|
}
|
|
179
181
|
|
|
180
182
|
function parseTokenFields(raw: Record<string, unknown>, config: OAuthConfig): Credentials {
|
|
183
|
+
if (typeof raw.access_token !== "string" || !raw.access_token) {
|
|
184
|
+
throw new Error(`${config.providerName} token response missing access_token`);
|
|
185
|
+
}
|
|
186
|
+
if (typeof raw.expires_in !== "number" || Number.isNaN(raw.expires_in)) {
|
|
187
|
+
throw new Error(`${config.providerName} token response missing or invalid expires_in`);
|
|
188
|
+
}
|
|
181
189
|
const buffer = config.expiryBuffer !== false ? TOKEN_EXPIRY_BUFFER_MS : 0;
|
|
182
190
|
return {
|
|
183
|
-
accessToken: raw.access_token
|
|
191
|
+
accessToken: raw.access_token,
|
|
184
192
|
refreshToken: (raw.refresh_token as string) ?? "",
|
|
185
|
-
expiresAt: Date.now() +
|
|
193
|
+
expiresAt: Date.now() + raw.expires_in * 1000 - buffer,
|
|
186
194
|
};
|
|
187
195
|
}
|
package/src/auth/store.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { Database, type Statement } from "bun:sqlite";
|
|
|
6
6
|
import { mkdirSync } from "node:fs";
|
|
7
7
|
import { homedir } from "node:os";
|
|
8
8
|
import { join } from "node:path";
|
|
9
|
+
import { logger } from "../utils/logger.ts";
|
|
9
10
|
|
|
10
11
|
export interface Credentials {
|
|
11
12
|
accessToken: string;
|
|
@@ -47,13 +48,7 @@ interface Statements {
|
|
|
47
48
|
|
|
48
49
|
let _db: Database | null = null;
|
|
49
50
|
let _stmts: Statements | null = null;
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
/** Override the database path (must be called before any store operation). */
|
|
53
|
-
export function setDbPath(path: string): void {
|
|
54
|
-
_dbPath = path;
|
|
55
|
-
}
|
|
56
|
-
|
|
51
|
+
const _dbPath = DEFAULT_DB_PATH;
|
|
57
52
|
function init() {
|
|
58
53
|
if (_stmts) return _stmts;
|
|
59
54
|
|
|
@@ -92,16 +87,26 @@ function init() {
|
|
|
92
87
|
export function get(provider: ProviderName, account = 0): Credentials | undefined {
|
|
93
88
|
const row = init().get.get(provider, account);
|
|
94
89
|
if (!row) return undefined;
|
|
95
|
-
|
|
90
|
+
try {
|
|
91
|
+
return JSON.parse(row.data) as Credentials;
|
|
92
|
+
} catch (err) {
|
|
93
|
+
logger.warn(`Corrupt credentials for ${provider}:${account}, removing`, { error: String(err) });
|
|
94
|
+
init().delOne.run(provider, account);
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
96
97
|
}
|
|
97
98
|
|
|
98
99
|
export function getAll(provider: ProviderName): { account: number; credentials: Credentials }[] {
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
account: row.account,
|
|
103
|
-
|
|
104
|
-
|
|
100
|
+
const results: { account: number; credentials: Credentials }[] = [];
|
|
101
|
+
for (const row of init().getAll.all(provider)) {
|
|
102
|
+
try {
|
|
103
|
+
results.push({ account: row.account, credentials: JSON.parse(row.data) as Credentials });
|
|
104
|
+
} catch (err) {
|
|
105
|
+
logger.warn(`Corrupt credentials for ${provider}:${row.account}, removing`, { error: String(err) });
|
|
106
|
+
init().delOne.run(provider, row.account);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return results;
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
export function save(provider: ProviderName, credentials: Credentials, account = 0): void {
|
package/src/cli/ansi.ts
CHANGED
package/src/cli/setup.ts
CHANGED
|
@@ -131,6 +131,7 @@ export async function setup(): Promise<void> {
|
|
|
131
131
|
|
|
132
132
|
for (const p of providers) {
|
|
133
133
|
const connected = p.accounts.filter((a) => a.status === "connected");
|
|
134
|
+
const disabled = p.accounts.filter((a) => a.status === "disabled");
|
|
134
135
|
const total = p.accounts.filter((a) => a.status !== "disconnected");
|
|
135
136
|
|
|
136
137
|
if (connected.length > 0) {
|
|
@@ -140,7 +141,10 @@ export async function setup(): Promise<void> {
|
|
|
140
141
|
.filter(Boolean)
|
|
141
142
|
.join(", ");
|
|
142
143
|
const info = emails ? ` ${s.dim}${emails}${s.reset}` : "";
|
|
143
|
-
|
|
144
|
+
const disabledInfo = disabled.length > 0 ? ` ${s.red}${disabled.length} disabled${s.reset}` : "";
|
|
145
|
+
line(` ${p.label.padEnd(16)} ${s.green}${connected.length} account(s)${s.reset}${info}${disabledInfo}`);
|
|
146
|
+
} else if (disabled.length > 0) {
|
|
147
|
+
line(` ${p.label.padEnd(16)} ${s.red}${disabled.length} disabled${s.reset}`);
|
|
144
148
|
} else if (total.length > 0) {
|
|
145
149
|
line(` ${p.label.padEnd(16)} ${s.yellow}${total.length} expired${s.reset}`);
|
|
146
150
|
} else {
|
package/src/cli/status.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Credentials, ProviderName } from "../auth/store.ts";
|
|
2
2
|
import * as store from "../auth/store.ts";
|
|
3
|
+
import { cooldown, type QuotaPool } from "../routing/cooldown.ts";
|
|
3
4
|
|
|
4
|
-
export type ConnectionStatus = "connected" | "expired" | "disconnected";
|
|
5
|
+
export type ConnectionStatus = "connected" | "expired" | "disabled" | "disconnected";
|
|
5
6
|
|
|
6
7
|
export interface AccountStatus {
|
|
7
8
|
account: number;
|
|
@@ -23,8 +24,16 @@ const PROVIDERS: { name: ProviderName; label: string; sublabel?: string }[] = [
|
|
|
23
24
|
{ name: "google", label: "Google", sublabel: "Gemini CLI + Antigravity" },
|
|
24
25
|
];
|
|
25
26
|
|
|
26
|
-
|
|
27
|
+
const POOL_MAP: Record<ProviderName, QuotaPool[]> = {
|
|
28
|
+
anthropic: ["anthropic"],
|
|
29
|
+
codex: ["codex"],
|
|
30
|
+
google: ["gemini", "antigravity"],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
function connectionOf(name: ProviderName, account: number, creds: Credentials): ConnectionStatus {
|
|
27
34
|
if (!creds.refreshToken) return "disconnected";
|
|
35
|
+
const pools = POOL_MAP[name];
|
|
36
|
+
if (pools.some((p) => cooldown.isExhausted(p, account))) return "disabled";
|
|
28
37
|
return store.fresh(creds) ? "connected" : "expired";
|
|
29
38
|
}
|
|
30
39
|
|
|
@@ -35,7 +44,7 @@ export function all(): ProviderStatus[] {
|
|
|
35
44
|
sublabel,
|
|
36
45
|
accounts: store.getAll(name).map((e) => ({
|
|
37
46
|
account: e.account,
|
|
38
|
-
status: connectionOf(e.credentials),
|
|
47
|
+
status: connectionOf(name, e.account, e.credentials),
|
|
39
48
|
email: e.credentials.email,
|
|
40
49
|
expiresAt: e.credentials.expiresAt,
|
|
41
50
|
})),
|
package/src/cli/tui.ts
CHANGED
|
@@ -16,6 +16,7 @@ const oauthConfigs: Record<ProviderName, OAuthConfig> = {
|
|
|
16
16
|
const ICON: Record<ConnectionStatus, string> = {
|
|
17
17
|
connected: `${s.green}●${s.reset}`,
|
|
18
18
|
expired: `${s.yellow}●${s.reset}`,
|
|
19
|
+
disabled: `${s.red}●${s.reset}`,
|
|
19
20
|
disconnected: `${s.dim}○${s.reset}`,
|
|
20
21
|
};
|
|
21
22
|
|
|
@@ -109,7 +110,9 @@ function formatInfo(a: AccountStatus): string {
|
|
|
109
110
|
if (a.status === "disconnected") return `${s.dim}—${s.reset}`;
|
|
110
111
|
|
|
111
112
|
const parts: string[] = [];
|
|
112
|
-
|
|
113
|
+
if (a.status === "connected") parts.push(`${s.green}connected${s.reset}`);
|
|
114
|
+
else if (a.status === "disabled") parts.push(`${s.red}disabled${s.reset}`);
|
|
115
|
+
else parts.push(`${s.yellow}expired${s.reset}`);
|
|
113
116
|
if (a.expiresAt && a.status === "connected") parts.push(`${s.dim}${status.remaining(a.expiresAt)}${s.reset}`);
|
|
114
117
|
if (a.email) parts.push(`${s.dim}${a.email}${s.reset}`);
|
|
115
118
|
return parts.join(`${s.dim} · ${s.reset}`);
|
package/src/config/config.ts
CHANGED
|
@@ -38,8 +38,13 @@ export async function loadConfig(): Promise<ProxyConfig> {
|
|
|
38
38
|
const apiKey = await resolveApiKey(file);
|
|
39
39
|
const providers = asRecord(file?.providers);
|
|
40
40
|
|
|
41
|
+
const port = asNumber(file?.port) ?? DEFAULTS.port;
|
|
42
|
+
if (port < 1 || port > 65535) {
|
|
43
|
+
throw new Error(`Invalid port ${port}: must be between 1 and 65535`);
|
|
44
|
+
}
|
|
45
|
+
|
|
41
46
|
return {
|
|
42
|
-
port
|
|
47
|
+
port,
|
|
43
48
|
ampUpstreamUrl: asString(file?.ampUpstreamUrl) ?? DEFAULTS.ampUpstreamUrl,
|
|
44
49
|
ampApiKey: apiKey,
|
|
45
50
|
exaApiKey: asString(file?.exaApiKey) ?? process.env.EXA_API_KEY,
|
package/src/constants.ts
CHANGED
|
@@ -38,7 +38,6 @@ export const OPENAI_TOKEN_URL = "https://auth.openai.com/oauth/token";
|
|
|
38
38
|
export const GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token";
|
|
39
39
|
|
|
40
40
|
export const TOKEN_EXPIRY_BUFFER_MS = 5 * 60 * 1000;
|
|
41
|
-
export const OAUTH_CALLBACK_TIMEOUT_MS = 120_000;
|
|
42
41
|
export const CLAUDE_CODE_VERSION = "2.1.39";
|
|
43
42
|
|
|
44
43
|
export const stainlessHeaders: Readonly<Record<string, string>> = {
|
|
@@ -48,8 +47,8 @@ export const stainlessHeaders: Readonly<Record<string, string>> = {
|
|
|
48
47
|
"X-Stainless-Package-Version": "0.73.0",
|
|
49
48
|
"X-Stainless-Runtime": "node",
|
|
50
49
|
"X-Stainless-Lang": "js",
|
|
51
|
-
"X-Stainless-Arch":
|
|
52
|
-
"X-Stainless-Os": "MacOS",
|
|
50
|
+
"X-Stainless-Arch": process.arch,
|
|
51
|
+
"X-Stainless-Os": process.platform === "darwin" ? "MacOS" : process.platform === "win32" ? "Windows" : "Linux",
|
|
53
52
|
"X-Stainless-Timeout": "600",
|
|
54
53
|
};
|
|
55
54
|
|
|
@@ -11,7 +11,7 @@ import {
|
|
|
11
11
|
stainlessHeaders,
|
|
12
12
|
} from "../constants.ts";
|
|
13
13
|
import type { Provider } from "./base.ts";
|
|
14
|
-
import { denied, forward } from "./
|
|
14
|
+
import { denied, forward } from "./forward.ts";
|
|
15
15
|
|
|
16
16
|
export const provider: Provider = {
|
|
17
17
|
name: "Anthropic",
|
|
@@ -32,6 +32,7 @@ export const provider: Provider = {
|
|
|
32
32
|
streaming: body.stream,
|
|
33
33
|
providerName: "Anthropic",
|
|
34
34
|
rewrite,
|
|
35
|
+
email: store.get("anthropic", account)?.email,
|
|
35
36
|
headers: {
|
|
36
37
|
...stainlessHeaders,
|
|
37
38
|
Accept: body.stream ? "text/event-stream" : "application/json",
|
|
@@ -10,7 +10,7 @@ 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";
|
|
13
|
-
import { denied, forward } from "./
|
|
13
|
+
import { denied, forward } from "./forward.ts";
|
|
14
14
|
|
|
15
15
|
const endpoints = [ANTIGRAVITY_DAILY_ENDPOINT, AUTOPUSH_ENDPOINT, CODE_ASSIST_ENDPOINT];
|
|
16
16
|
|
|
@@ -33,7 +33,7 @@ export const provider: Provider = {
|
|
|
33
33
|
|
|
34
34
|
accountCount: () => oauth.accountCount(config),
|
|
35
35
|
|
|
36
|
-
async forward(sub, body,
|
|
36
|
+
async forward(sub, body, _originalHeaders, rewrite, account = 0) {
|
|
37
37
|
const accessToken = await oauth.token(config, account);
|
|
38
38
|
if (!accessToken) return denied("Antigravity");
|
|
39
39
|
|
|
@@ -44,15 +44,11 @@ export const provider: Provider = {
|
|
|
44
44
|
...antigravityHeaders,
|
|
45
45
|
Authorization: `Bearer ${accessToken}`,
|
|
46
46
|
"Content-Type": "application/json",
|
|
47
|
-
Accept: "text/event-stream",
|
|
47
|
+
Accept: body.stream ? "text/event-stream" : "application/json",
|
|
48
48
|
};
|
|
49
|
-
|
|
50
|
-
const anthropicBeta = originalHeaders.get("anthropic-beta");
|
|
51
|
-
if (anthropicBeta) headers["anthropic-beta"] = anthropicBeta;
|
|
52
|
-
|
|
53
49
|
const gemini = path.gemini(sub);
|
|
54
50
|
const action = gemini?.action ?? "generateContent";
|
|
55
|
-
const model = gemini?.model ?? "";
|
|
51
|
+
const model = gemini?.model === "gemini-3-flash-preview" ? "gemini-3-flash" : (gemini?.model ?? "");
|
|
56
52
|
const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, {
|
|
57
53
|
userAgent: "antigravity",
|
|
58
54
|
requestIdPrefix: "agent",
|
|
@@ -60,7 +56,7 @@ export const provider: Provider = {
|
|
|
60
56
|
});
|
|
61
57
|
const unwrapThenRewrite = withUnwrap(rewrite);
|
|
62
58
|
|
|
63
|
-
return tryEndpoints(requestBody, body.stream, headers, action, unwrapThenRewrite);
|
|
59
|
+
return tryEndpoints(requestBody, body.stream, headers, action, unwrapThenRewrite, creds?.email);
|
|
64
60
|
},
|
|
65
61
|
};
|
|
66
62
|
|
|
@@ -70,13 +66,14 @@ async function tryEndpoints(
|
|
|
70
66
|
headers: Record<string, string>,
|
|
71
67
|
action: string,
|
|
72
68
|
rewrite?: (data: string) => string,
|
|
69
|
+
email?: string,
|
|
73
70
|
): Promise<Response> {
|
|
74
71
|
let lastError: Error | null = null;
|
|
75
72
|
|
|
76
73
|
for (const endpoint of endpoints) {
|
|
77
74
|
const url = buildUrl(endpoint, action);
|
|
78
75
|
try {
|
|
79
|
-
const response = await forward({ url, body, streaming, headers, providerName: "Antigravity", rewrite });
|
|
76
|
+
const response = await forward({ url, body, streaming, headers, providerName: "Antigravity", rewrite, email });
|
|
80
77
|
if (response.status < 500) return response;
|
|
81
78
|
lastError = new Error(`${endpoint} returned ${response.status}`);
|
|
82
79
|
logger.debug("Endpoint 5xx, trying next", { provider: "Antigravity" });
|
package/src/providers/base.ts
CHANGED
|
@@ -1,9 +1,7 @@
|
|
|
1
|
-
/** Provider interface
|
|
1
|
+
/** Provider interface — the contract every provider must implement. */
|
|
2
2
|
|
|
3
3
|
import type { ParsedBody } from "../server/body.ts";
|
|
4
4
|
import type { RouteDecision } from "../utils/logger.ts";
|
|
5
|
-
import { logger } from "../utils/logger.ts";
|
|
6
|
-
import * as sse from "../utils/streaming.ts";
|
|
7
5
|
|
|
8
6
|
export interface Provider {
|
|
9
7
|
readonly name: string;
|
|
@@ -18,71 +16,3 @@ export interface Provider {
|
|
|
18
16
|
account?: number,
|
|
19
17
|
): Promise<Response>;
|
|
20
18
|
}
|
|
21
|
-
|
|
22
|
-
interface ForwardOptions {
|
|
23
|
-
url: string;
|
|
24
|
-
body: string;
|
|
25
|
-
streaming: boolean;
|
|
26
|
-
headers: Record<string, string>;
|
|
27
|
-
providerName: string;
|
|
28
|
-
rewrite?: (data: string) => string;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
const RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);
|
|
32
|
-
const MAX_RETRIES = 3;
|
|
33
|
-
const RETRY_DELAY_MS = 500;
|
|
34
|
-
|
|
35
|
-
export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
36
|
-
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
37
|
-
let response: Response;
|
|
38
|
-
try {
|
|
39
|
-
response = await fetch(opts.url, {
|
|
40
|
-
method: "POST",
|
|
41
|
-
headers: opts.headers,
|
|
42
|
-
body: opts.body,
|
|
43
|
-
});
|
|
44
|
-
} catch (err) {
|
|
45
|
-
if (attempt < MAX_RETRIES) {
|
|
46
|
-
logger.debug(`${opts.providerName} fetch error, retry ${attempt + 1}/${MAX_RETRIES}`, {
|
|
47
|
-
error: String(err),
|
|
48
|
-
});
|
|
49
|
-
await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
|
|
50
|
-
continue;
|
|
51
|
-
}
|
|
52
|
-
throw err;
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
// Retry on server errors (429 handled at routing layer)
|
|
56
|
-
if (RETRYABLE_STATUS.has(response.status) && attempt < MAX_RETRIES) {
|
|
57
|
-
await response.text(); // consume body
|
|
58
|
-
logger.debug(`${opts.providerName} returned ${response.status}, retry ${attempt + 1}/${MAX_RETRIES}`);
|
|
59
|
-
await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
|
|
60
|
-
continue;
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
const contentType = response.headers.get("Content-Type") ?? "application/json";
|
|
64
|
-
|
|
65
|
-
if (!response.ok) {
|
|
66
|
-
const text = await response.text();
|
|
67
|
-
logger.error(`${opts.providerName} API error (${response.status})`, { error: text.slice(0, 200) });
|
|
68
|
-
return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
const isSSE = contentType.includes("text/event-stream") || opts.streaming;
|
|
72
|
-
if (isSSE) return sse.proxy(response, opts.rewrite);
|
|
73
|
-
|
|
74
|
-
if (opts.rewrite) {
|
|
75
|
-
const text = await response.text();
|
|
76
|
-
return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
return new Response(response.body, { status: response.status, headers: { "Content-Type": contentType } });
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// Unreachable, but TypeScript needs it
|
|
83
|
-
throw new Error(`${opts.providerName}: all retries exhausted`);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
export function denied(providerName: string): Response {
|
|
87
|
-
return Response.json({ error: `No ${providerName} OAuth token available. Run login first.` }, { status: 401 });
|
|
88
|
-
}
|
|
@@ -50,7 +50,7 @@ interface TransformState {
|
|
|
50
50
|
}
|
|
51
51
|
|
|
52
52
|
/** Create a stateful SSE transformer: Responses API → Chat Completions. */
|
|
53
|
-
|
|
53
|
+
function createResponseTransformer(ampModel: string): (data: string) => string {
|
|
54
54
|
const state: TransformState = {
|
|
55
55
|
responseId: "",
|
|
56
56
|
model: ampModel,
|
package/src/providers/codex.ts
CHANGED
|
@@ -10,8 +10,8 @@ import * as store from "../auth/store.ts";
|
|
|
10
10
|
import { CODEX_BASE_URL, codexHeaders, codexHeaderValues, codexPathMap } from "../constants.ts";
|
|
11
11
|
import { fromBase64url } from "../utils/encoding.ts";
|
|
12
12
|
import type { Provider } from "./base.ts";
|
|
13
|
-
import { denied, forward } from "./base.ts";
|
|
14
13
|
import { transformCodexResponse } from "./codex-sse.ts";
|
|
14
|
+
import { denied, forward } from "./forward.ts";
|
|
15
15
|
|
|
16
16
|
const DEFAULT_INSTRUCTIONS = "You are an expert coding assistant.";
|
|
17
17
|
|
|
@@ -41,6 +41,7 @@ export const provider: Provider = {
|
|
|
41
41
|
providerName: "OpenAI Codex",
|
|
42
42
|
// Skip generic rewrite when we need full response transform
|
|
43
43
|
rewrite: needsResponseTransform ? undefined : rewrite,
|
|
44
|
+
email: store.get("codex", account)?.email,
|
|
44
45
|
headers: {
|
|
45
46
|
"Content-Type": "application/json",
|
|
46
47
|
Authorization: `Bearer ${accessToken}`,
|
|
@@ -149,6 +150,7 @@ function transformForCodex(
|
|
|
149
150
|
// Remove fields the Codex backend doesn't accept
|
|
150
151
|
delete parsed.max_tokens;
|
|
151
152
|
delete parsed.max_completion_tokens;
|
|
153
|
+
delete parsed.max_output_tokens;
|
|
152
154
|
// Chat Completions fields not in Responses API
|
|
153
155
|
delete parsed.frequency_penalty;
|
|
154
156
|
delete parsed.logprobs;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/** HTTP forwarding with transport-level retry, SSE proxying, and response rewriting. */
|
|
2
|
+
|
|
3
|
+
import { logger } from "../utils/logger.ts";
|
|
4
|
+
import * as sse from "../utils/streaming.ts";
|
|
5
|
+
|
|
6
|
+
export interface ForwardOptions {
|
|
7
|
+
url: string;
|
|
8
|
+
body: string;
|
|
9
|
+
streaming: boolean;
|
|
10
|
+
headers: Record<string, string>;
|
|
11
|
+
providerName: string;
|
|
12
|
+
rewrite?: (data: string) => string;
|
|
13
|
+
email?: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);
|
|
17
|
+
const MAX_RETRIES = 3;
|
|
18
|
+
const RETRY_DELAY_MS = 500;
|
|
19
|
+
|
|
20
|
+
export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
21
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
22
|
+
let response: Response;
|
|
23
|
+
try {
|
|
24
|
+
response = await fetch(opts.url, {
|
|
25
|
+
method: "POST",
|
|
26
|
+
headers: opts.headers,
|
|
27
|
+
body: opts.body,
|
|
28
|
+
});
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if (attempt < MAX_RETRIES) {
|
|
31
|
+
logger.debug(`${opts.providerName} fetch error, retry ${attempt + 1}/${MAX_RETRIES}`, {
|
|
32
|
+
error: String(err),
|
|
33
|
+
});
|
|
34
|
+
await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// Retry on server errors (429 handled at routing layer)
|
|
41
|
+
if (RETRYABLE_STATUS.has(response.status) && attempt < MAX_RETRIES) {
|
|
42
|
+
await response.text(); // consume body
|
|
43
|
+
logger.debug(`${opts.providerName} returned ${response.status}, retry ${attempt + 1}/${MAX_RETRIES}`);
|
|
44
|
+
await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const contentType = response.headers.get("Content-Type") ?? "application/json";
|
|
49
|
+
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
const text = await response.text();
|
|
52
|
+
const ctx = opts.email ? ` account=${opts.email}` : "";
|
|
53
|
+
logger.error(`${opts.providerName} API error (${response.status})${ctx}`, { error: text.slice(0, 200) });
|
|
54
|
+
return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const isSSE = contentType.includes("text/event-stream") || opts.streaming;
|
|
58
|
+
if (isSSE) return sse.proxy(response, opts.rewrite);
|
|
59
|
+
|
|
60
|
+
if (opts.rewrite) {
|
|
61
|
+
const text = await response.text();
|
|
62
|
+
return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Response(response.body, { status: response.status, headers: { "Content-Type": contentType } });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Unreachable, but TypeScript needs it
|
|
69
|
+
throw new Error(`${opts.providerName}: all retries exhausted`);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function denied(providerName: string): Response {
|
|
73
|
+
return Response.json({ error: `No ${providerName} OAuth token available. Run login first.` }, { status: 401 });
|
|
74
|
+
}
|
package/src/providers/gemini.ts
CHANGED
|
@@ -12,7 +12,7 @@ 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";
|
|
15
|
-
import { denied, forward } from "./
|
|
15
|
+
import { denied, forward } from "./forward.ts";
|
|
16
16
|
|
|
17
17
|
const geminiHeaders: Readonly<Record<string, string>> = {
|
|
18
18
|
"User-Agent": "google-cloud-sdk vscode_cloudshelleditor/0.1",
|
|
@@ -44,7 +44,7 @@ export const provider: Provider = {
|
|
|
44
44
|
...geminiHeaders,
|
|
45
45
|
Authorization: `Bearer ${accessToken}`,
|
|
46
46
|
"Content-Type": "application/json",
|
|
47
|
-
Accept: "text/event-stream",
|
|
47
|
+
Accept: body.stream ? "text/event-stream" : "application/json",
|
|
48
48
|
};
|
|
49
49
|
|
|
50
50
|
const gemini = path.gemini(sub);
|
|
@@ -67,6 +67,7 @@ export const provider: Provider = {
|
|
|
67
67
|
headers,
|
|
68
68
|
providerName: "Gemini CLI",
|
|
69
69
|
rewrite: unwrapThenRewrite,
|
|
70
|
+
email: store.get("google", account)?.email,
|
|
70
71
|
});
|
|
71
72
|
},
|
|
72
73
|
};
|
package/src/routing/affinity.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
import type { QuotaPool } from "./cooldown.ts";
|
|
7
7
|
|
|
8
|
-
|
|
8
|
+
interface AffinityEntry {
|
|
9
9
|
pool: QuotaPool;
|
|
10
10
|
account: number;
|
|
11
11
|
assignedAt: number;
|
|
@@ -16,7 +16,7 @@ const TTL_MS = 2 * 3600_000;
|
|
|
16
16
|
/** Cleanup stale entries every 10 minutes. */
|
|
17
17
|
const CLEANUP_INTERVAL_MS = 10 * 60_000;
|
|
18
18
|
|
|
19
|
-
|
|
19
|
+
class AffinityStore {
|
|
20
20
|
private map = new Map<string, AffinityEntry>();
|
|
21
21
|
private counts = new Map<string, number>();
|
|
22
22
|
private cleanupTimer: Timer | null = null;
|
package/src/routing/cooldown.ts
CHANGED
|
@@ -12,6 +12,8 @@ interface CooldownEntry {
|
|
|
12
12
|
}
|
|
13
13
|
|
|
14
14
|
/** When detected as exhausted, cooldown for this long. */
|
|
15
|
+
/** 403 = account disabled/revoked — long cooldown. */
|
|
16
|
+
const FORBIDDEN_COOLDOWN_MS = 24 * 3600_000;
|
|
15
17
|
const EXHAUSTED_COOLDOWN_MS = 2 * 3600_000;
|
|
16
18
|
/** Retry-After threshold (seconds) above which we consider quota exhausted. */
|
|
17
19
|
const EXHAUSTED_THRESHOLD_S = 300;
|
|
@@ -20,7 +22,7 @@ const EXHAUSTED_CONSECUTIVE = 3;
|
|
|
20
22
|
/** Default burst cooldown when no Retry-After header. */
|
|
21
23
|
const DEFAULT_BURST_S = 30;
|
|
22
24
|
|
|
23
|
-
|
|
25
|
+
class CooldownTracker {
|
|
24
26
|
private entries = new Map<string, CooldownEntry>();
|
|
25
27
|
|
|
26
28
|
private key(pool: QuotaPool, account: number): string {
|
|
@@ -65,6 +67,13 @@ export class CooldownTracker {
|
|
|
65
67
|
this.entries.set(k, entry);
|
|
66
68
|
}
|
|
67
69
|
|
|
70
|
+
/** 403 = account forbidden/revoked. Immediately disable for 24h. */
|
|
71
|
+
record403(pool: QuotaPool, account: number): void {
|
|
72
|
+
const k = this.key(pool, account);
|
|
73
|
+
this.entries.set(k, { until: Date.now() + FORBIDDEN_COOLDOWN_MS, exhausted: true, consecutive429: 0 });
|
|
74
|
+
logger.warn(`Account disabled (403): ${k}`, { cooldownHours: FORBIDDEN_COOLDOWN_MS / 3600_000 });
|
|
75
|
+
}
|
|
76
|
+
|
|
68
77
|
recordSuccess(pool: QuotaPool, account: number): void {
|
|
69
78
|
this.entries.delete(this.key(pool, account));
|
|
70
79
|
}
|
package/src/server/server.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { ProxyConfig } from "../config/config.ts";
|
|
|
5
5
|
import * as rewriter from "../proxy/rewriter.ts";
|
|
6
6
|
import * as upstream from "../proxy/upstream.ts";
|
|
7
7
|
import { affinity } from "../routing/affinity.ts";
|
|
8
|
+
import { cooldown } from "../routing/cooldown.ts";
|
|
8
9
|
import { tryReroute, tryWithCachePreserve } from "../routing/retry.ts";
|
|
9
10
|
import { recordSuccess, routeRequest } from "../routing/router.ts";
|
|
10
11
|
import { handleInternal, isLocalMethod } from "../tools/internal.ts";
|
|
@@ -31,7 +32,7 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
|
|
|
31
32
|
logger.error("Unhandled server error", { error: String(err) });
|
|
32
33
|
return Response.json({ error: "Internal proxy error" }, { status });
|
|
33
34
|
} finally {
|
|
34
|
-
logger.info(`${req.method} ${url.pathname} ${status}`, { duration: Date.now() - startTime });
|
|
35
|
+
logger.info(`${req.method} ${url.pathname}${url.search} ${status}`, { duration: Date.now() - startTime });
|
|
35
36
|
}
|
|
36
37
|
},
|
|
37
38
|
});
|
|
@@ -120,6 +121,10 @@ async function handleProvider(
|
|
|
120
121
|
);
|
|
121
122
|
response = rerouted ?? (await fallbackUpstream(req, body, config));
|
|
122
123
|
}
|
|
124
|
+
} else if (handlerResponse.status === 403 && route.pool) {
|
|
125
|
+
cooldown.record403(route.pool, route.account);
|
|
126
|
+
if (threadId) affinity.clear(threadId, providerName);
|
|
127
|
+
response = await fallbackUpstream(req, body, config);
|
|
123
128
|
} else if (handlerResponse.status === 401) {
|
|
124
129
|
logger.debug("Local provider denied, falling back to upstream");
|
|
125
130
|
response = await fallbackUpstream(req, body, config);
|
package/src/tools/web-read.ts
CHANGED
|
@@ -3,13 +3,13 @@
|
|
|
3
3
|
import { convert, JsPreprocessingPreset } from "@kreuzberg/html-to-markdown";
|
|
4
4
|
import { logger } from "../utils/logger.ts";
|
|
5
5
|
|
|
6
|
-
|
|
6
|
+
interface WebReadParams {
|
|
7
7
|
url: string;
|
|
8
8
|
objective?: string;
|
|
9
9
|
forceRefetch?: boolean;
|
|
10
10
|
}
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
type WebReadResult =
|
|
13
13
|
| { ok: true; result: { excerpts: string[] } | { fullContent: string } }
|
|
14
14
|
| { ok: false; error: { code: string; message: string } };
|
|
15
15
|
|
package/src/tools/web-search.ts
CHANGED
package/src/utils/code-assist.ts
CHANGED
|
@@ -10,7 +10,7 @@ interface WrapOptions {
|
|
|
10
10
|
}
|
|
11
11
|
|
|
12
12
|
/** Wrap a raw request body in the Cloud Code Assist envelope. */
|
|
13
|
-
|
|
13
|
+
function wrapRequest(opts: WrapOptions): string {
|
|
14
14
|
return JSON.stringify({
|
|
15
15
|
project: opts.projectId,
|
|
16
16
|
model: opts.model,
|
package/src/utils/streaming.ts
CHANGED
|
@@ -51,10 +51,7 @@ export function encode(chunk: Chunk): string {
|
|
|
51
51
|
const decoder = new TextDecoder();
|
|
52
52
|
const encoder = new TextEncoder();
|
|
53
53
|
|
|
54
|
-
|
|
55
|
-
source: ReadableStream<Uint8Array>,
|
|
56
|
-
fn: (data: string) => string,
|
|
57
|
-
): ReadableStream<Uint8Array> {
|
|
54
|
+
function transform(source: ReadableStream<Uint8Array>, fn: (data: string) => string): ReadableStream<Uint8Array> {
|
|
58
55
|
let buffer = "";
|
|
59
56
|
|
|
60
57
|
const stream = new TransformStream<Uint8Array, Uint8Array>({
|