ampcode-connector 0.1.20 → 0.1.21

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampcode-connector",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/constants.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  /** Single source of truth — no magic strings scattered across files. */
2
2
 
3
3
  export const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
4
- export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
4
+ export const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
5
+ export const ANTIGRAVITY_DAILY_SANDBOX_ENDPOINT = "https://daily-cloudcode-pa.sandbox.googleapis.com";
5
6
  export const AUTOPUSH_ENDPOINT = "https://autopush-cloudcode-pa.sandbox.googleapis.com";
6
7
  export const DEFAULT_ANTIGRAVITY_PROJECT = "rising-fact-p41fc";
7
8
 
@@ -53,7 +53,7 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
53
53
  await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
54
54
  continue;
55
55
  }
56
- throw err;
56
+ return transportErrorResponse(opts.providerName, err);
57
57
  }
58
58
 
59
59
  // Retry on server errors (429 handled at routing layer)
@@ -117,3 +117,30 @@ export async function forward(opts: ForwardOptions): Promise<Response> {
117
117
  export function denied(providerName: string): Response {
118
118
  return apiError(401, `No ${providerName} OAuth token available. Run login first.`);
119
119
  }
120
+
121
+ function transportErrorResponse(providerName: string, err: unknown): Response {
122
+ const message = transportErrorMessage(providerName, err);
123
+ logger.error(`${providerName} transport error after retries exhausted`, { error: String(err) });
124
+ return apiError(502, message, "connection_error");
125
+ }
126
+
127
+ function transportErrorMessage(providerName: string, err: unknown): string {
128
+ const base = `${providerName} connection error after retries were exhausted.`;
129
+ const details = String(err);
130
+
131
+ if (providerName !== "Anthropic") {
132
+ return `${base} ${details}`;
133
+ }
134
+
135
+ const looksLikeReset =
136
+ details.includes("ECONNRESET") ||
137
+ details.includes("socket connection was closed unexpectedly") ||
138
+ details.includes("tls") ||
139
+ details.includes("network");
140
+
141
+ if (!looksLikeReset) {
142
+ return `${base} ${details}`;
143
+ }
144
+
145
+ return `${base} ${details} This is often a local network issue rather than an OAuth bug: check Wi-Fi MTU (1492 is a common fix), hotspot stability, and iPhone dual-SIM Cellular Data Switching if you are tethering.`;
146
+ }
@@ -4,7 +4,7 @@
4
4
  import { google as config } from "../auth/configs.ts";
5
5
  import * as oauth from "../auth/oauth.ts";
6
6
  import * as store from "../auth/store.ts";
7
- import { ANTIGRAVITY_DAILY_ENDPOINT, AUTOPUSH_ENDPOINT, CODE_ASSIST_ENDPOINT } from "../constants.ts";
7
+ import { ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_DAILY_SANDBOX_ENDPOINT, CODE_ASSIST_ENDPOINT } from "../constants.ts";
8
8
  import { buildUrl, maybeWrap, withUnwrap } from "../utils/code-assist.ts";
9
9
  import { logger } from "../utils/logger.ts";
10
10
  import * as path from "../utils/path.ts";
@@ -26,7 +26,7 @@ interface GoogleStrategy {
26
26
  wrapOpts: {
27
27
  userAgent: "antigravity" | "pi-coding-agent";
28
28
  requestIdPrefix: "agent" | "pi";
29
- requestType?: "agent";
29
+ requestType?: "agent" | "image_gen";
30
30
  };
31
31
  }
32
32
 
@@ -44,15 +44,23 @@ const geminiStrategy: GoogleStrategy = {
44
44
  },
45
45
  };
46
46
 
47
+ /** Antigravity uses different model names than what Amp CLI sends. */
48
+ const antigravityModelMap: Record<string, string> = {
49
+ "gemini-3-flash-preview": "gemini-3-flash",
50
+ "gemini-3-pro-preview": "gemini-3-pro-high",
51
+ "gemini-3-pro-image-preview": "gemini-3.1-flash-image",
52
+ "gemini-3.1-flash-image-preview": "gemini-3.1-flash-image",
53
+ };
54
+
47
55
  const antigravityStrategy: GoogleStrategy = {
48
56
  name: "antigravity",
49
57
  headers: {
50
- "User-Agent": "antigravity/1.15.8 darwin/arm64",
58
+ "User-Agent": "antigravity/1.104.0 darwin/arm64",
51
59
  "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
52
60
  "Client-Metadata": GOOGLE_CLIENT_METADATA,
53
61
  },
54
- endpoints: [ANTIGRAVITY_DAILY_ENDPOINT, AUTOPUSH_ENDPOINT, CODE_ASSIST_ENDPOINT],
55
- modelMapper: (model: string) => (model === "gemini-3-flash-preview" ? "gemini-3-flash" : model),
62
+ endpoints: [ANTIGRAVITY_DAILY_ENDPOINT, ANTIGRAVITY_DAILY_SANDBOX_ENDPOINT, CODE_ASSIST_ENDPOINT],
63
+ modelMapper: (model: string) => antigravityModelMap[model] ?? model,
56
64
  wrapOpts: {
57
65
  userAgent: "antigravity",
58
66
  requestIdPrefix: "agent",
@@ -62,6 +70,9 @@ const antigravityStrategy: GoogleStrategy = {
62
70
 
63
71
  const strategies: readonly GoogleStrategy[] = [geminiStrategy, antigravityStrategy];
64
72
 
73
+ /** Models that only work on the antigravity strategy. */
74
+ const ANTIGRAVITY_ONLY_MODELS = new Set(["gemini-3-pro-image-preview", "gemini-3.1-flash-image-preview"]);
75
+
65
76
  const COOLDOWN_MS = 60_000;
66
77
 
67
78
  interface StrategyPreference {
@@ -78,8 +89,16 @@ function cooldownKey(account: number, strategy: GoogleStrategy): string {
78
89
  return `${account}:${strategy.name}`;
79
90
  }
80
91
 
81
- function getOrderedStrategies(account: number): GoogleStrategy[] {
92
+ function getOrderedStrategies(account: number, model?: string): GoogleStrategy[] {
82
93
  const now = Date.now();
94
+
95
+ // Some models only work on antigravity — skip other strategies entirely
96
+ if (model && ANTIGRAVITY_ONLY_MODELS.has(model)) {
97
+ const cd = cooldowns.get(cooldownKey(account, antigravityStrategy));
98
+ if (cd && cd > now) return [];
99
+ return [antigravityStrategy];
100
+ }
101
+
83
102
  const pref = preferredStrategy.get(account);
84
103
  const ordered =
85
104
  pref && pref.until > now ? [pref.strategy, ...strategies.filter((s) => s !== pref.strategy)] : [...strategies];
@@ -103,6 +122,78 @@ function markFailure(account: number, strategy: GoogleStrategy): void {
103
122
  }
104
123
  }
105
124
 
125
+ /** Buffer an SSE response and merge all chunks into a single JSON response.
126
+ * Used when we force streamGenerateContent but the client expects non-streaming JSON.
127
+ * Accumulates all candidate parts across chunks (image inlineData may be in earlier chunks). */
128
+ async function bufferSSEToJSON(response: Response): Promise<Response | null> {
129
+ const text = await response.text();
130
+ const chunks: Record<string, unknown>[] = [];
131
+
132
+ for (const line of text.split("\n")) {
133
+ if (!line.startsWith("data: ")) continue;
134
+ const data = line.slice(6).trim();
135
+ if (!data || data === "[DONE]") continue;
136
+ try {
137
+ chunks.push(JSON.parse(data) as Record<string, unknown>);
138
+ } catch {
139
+ // skip non-JSON
140
+ }
141
+ }
142
+
143
+ if (chunks.length === 0) return null;
144
+ if (chunks.length === 1) {
145
+ return new Response(JSON.stringify(chunks[0]), {
146
+ status: 200,
147
+ headers: { "Content-Type": "application/json" },
148
+ });
149
+ }
150
+
151
+ // Merge: accumulate parts from all chunks into the first candidate
152
+ const merged = chunks[0]! as Record<string, unknown>;
153
+ const allParts: unknown[] = [];
154
+
155
+ for (const chunk of chunks) {
156
+ const candidates = chunk.candidates as { content?: { parts?: unknown[] } }[] | undefined;
157
+ if (!candidates) continue;
158
+ for (const candidate of candidates) {
159
+ if (candidate.content?.parts) {
160
+ allParts.push(...candidate.content.parts);
161
+ }
162
+ }
163
+ }
164
+
165
+ // Use last chunk's metadata (finishReason, usageMetadata)
166
+ const last = chunks[chunks.length - 1]! as Record<string, unknown>;
167
+ const lastCandidates = last.candidates as Record<string, unknown>[] | undefined;
168
+ const mergedCandidates = merged.candidates as Record<string, unknown>[] | undefined;
169
+
170
+ if (mergedCandidates?.[0] && allParts.length > 0) {
171
+ const content = (mergedCandidates[0] as Record<string, unknown>).content as Record<string, unknown> | undefined;
172
+ if (content) content.parts = allParts;
173
+ if (lastCandidates?.[0]) {
174
+ (mergedCandidates[0] as Record<string, unknown>).finishReason = (
175
+ lastCandidates[0] as Record<string, unknown>
176
+ ).finishReason;
177
+ }
178
+ }
179
+ if (last.usageMetadata) merged.usageMetadata = last.usageMetadata;
180
+
181
+ return new Response(JSON.stringify(merged), {
182
+ status: 200,
183
+ headers: { "Content-Type": "application/json" },
184
+ });
185
+ }
186
+
187
+ /** Generate a fallback project ID when none is stored (matches CLIProxyAPI behavior). */
188
+ function generateProjectId(): string {
189
+ const adjectives = ["useful", "bright", "swift", "calm", "bold"];
190
+ const nouns = ["fuze", "wave", "spark", "flow", "core"];
191
+ const adj = adjectives[Math.floor(Math.random() * adjectives.length)]!;
192
+ const noun = nouns[Math.floor(Math.random() * nouns.length)]!;
193
+ const rand = crypto.randomUUID().slice(0, 5).toLowerCase();
194
+ return `${adj}-${noun}-${rand}`;
195
+ }
196
+
106
197
  export const provider: Provider = {
107
198
  name: "Google",
108
199
  routeDecision: "LOCAL_GOOGLE",
@@ -117,7 +208,7 @@ export const provider: Provider = {
117
208
  if (!accessToken) return denied("Google");
118
209
 
119
210
  const creds = store.get("google", account);
120
- const projectId = creds?.projectId ?? "";
211
+ const projectId = creds?.projectId || generateProjectId();
121
212
  const email = creds?.email;
122
213
 
123
214
  const modelAction = path.googleModel(sub);
@@ -127,7 +218,7 @@ export const provider: Provider = {
127
218
  }
128
219
 
129
220
  const unwrapThenRewrite = withUnwrap(rewrite);
130
- const orderedStrategies = getOrderedStrategies(account);
221
+ const orderedStrategies = getOrderedStrategies(account, modelAction.model);
131
222
 
132
223
  if (orderedStrategies.length === 0) {
133
224
  const now = Date.now();
@@ -150,7 +241,9 @@ export const provider: Provider = {
150
241
 
151
242
  for (const strategy of orderedStrategies) {
152
243
  const model = strategy.modelMapper ? strategy.modelMapper(modelAction.model) : modelAction.model;
153
- const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, strategy.wrapOpts);
244
+ const isImageModel = model.includes("image");
245
+ const wrapOpts = isImageModel ? { ...strategy.wrapOpts, requestType: "image_gen" as const } : strategy.wrapOpts;
246
+ const requestBody = maybeWrap(body.parsed, body.forwardBody, projectId, model, wrapOpts);
154
247
 
155
248
  const headers: Record<string, string> = {
156
249
  ...strategy.headers,
@@ -159,25 +252,45 @@ export const provider: Provider = {
159
252
  Accept: body.stream ? "text/event-stream" : "application/json",
160
253
  };
161
254
 
162
- logger.info(`Google strategy=${strategy.name} account=${account}`);
255
+ logger.info(`Google strategy=${strategy.name} account=${account} model=${model}`);
256
+
257
+ // Antigravity forces streaming endpoint for gemini-3-pro* and image models (matches CLIProxyAPI behavior)
258
+ const forceStream = strategy.name === "antigravity" && (model.startsWith("gemini-3-pro") || isImageModel);
259
+ const action = forceStream ? "streamGenerateContent" : modelAction.action;
163
260
 
164
261
  for (const endpoint of strategy.endpoints) {
165
- const url = buildUrl(endpoint, modelAction.action);
262
+ const url = buildUrl(endpoint, action);
166
263
  try {
264
+ const forceStreamNonStreaming = forceStream && !body.stream;
167
265
  const response = await forward({
168
266
  url,
169
267
  body: requestBody,
170
- streaming: body.stream,
268
+ streaming: forceStreamNonStreaming ? true : body.stream,
171
269
  headers,
172
270
  providerName: `Google/${strategy.name}`,
173
271
  rewrite: unwrapThenRewrite,
174
272
  email,
175
273
  });
176
274
 
275
+ // When we forced streaming but client expects JSON, buffer SSE and return last chunk
276
+ if (forceStreamNonStreaming && response.ok && response.body) {
277
+ const merged = await bufferSSEToJSON(response);
278
+ if (merged) {
279
+ markSuccess(account, strategy);
280
+ return merged;
281
+ }
282
+ }
283
+
177
284
  if (response.status === 401 || response.status === 403) {
178
285
  return response;
179
286
  }
180
287
 
288
+ if (response.status === 404) {
289
+ lastResponse = response;
290
+ logger.debug(`Google strategy=${strategy.name} model not found (404), trying next endpoint`);
291
+ continue;
292
+ }
293
+
181
294
  if (response.status === 429) {
182
295
  saw429 = true;
183
296
  lastResponse = response;
@@ -102,7 +102,10 @@ async function handleProvider(
102
102
  const rewrite = ampModel ? rewriter.rewrite(ampModel) : undefined;
103
103
  const handlerResponse = await route.handler.forward(sub, body, req.headers, rewrite, route.account);
104
104
 
105
- if ((handlerResponse.status === 429 || handlerResponse.status === 403) && route.pool) {
105
+ if (
106
+ (handlerResponse.status === 429 || handlerResponse.status === 403 || handlerResponse.status === 404) &&
107
+ route.pool
108
+ ) {
106
109
  const ctx = { providerName, ampModel, config, sub, body, headers: req.headers, rewrite, threadId };
107
110
  // 429: try short wait to preserve prompt cache first
108
111
  const cached =
@@ -6,18 +6,23 @@ interface WrapOptions {
6
6
  body: Record<string, unknown>;
7
7
  userAgent: "antigravity" | "pi-coding-agent";
8
8
  requestIdPrefix: "agent" | "pi";
9
- requestType?: "agent";
9
+ requestType?: "agent" | "image_gen";
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
+ const isImageGen = opts.requestType === "image_gen";
15
+ const requestId = isImageGen
16
+ ? `image_gen/${Date.now()}/${crypto.randomUUID()}/12`
17
+ : `${opts.requestIdPrefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`;
18
+
14
19
  return JSON.stringify({
15
20
  project: opts.projectId,
16
21
  model: opts.model,
17
22
  request: opts.body,
18
23
  ...(opts.requestType && { requestType: opts.requestType }),
19
24
  userAgent: opts.userAgent,
20
- requestId: `${opts.requestIdPrefix}-${Date.now()}-${crypto.randomUUID().slice(0, 8)}`,
25
+ requestId,
21
26
  });
22
27
  }
23
28
 
@@ -118,7 +123,11 @@ export function maybeWrap(
118
123
  raw: string,
119
124
  projectId: string,
120
125
  model: string,
121
- opts: { userAgent: "antigravity" | "pi-coding-agent"; requestIdPrefix: "agent" | "pi"; requestType?: "agent" },
126
+ opts: {
127
+ userAgent: "antigravity" | "pi-coding-agent";
128
+ requestIdPrefix: "agent" | "pi";
129
+ requestType?: "agent" | "image_gen";
130
+ },
122
131
  ): string {
123
132
  if (!parsed) return raw;
124
133
  if (parsed.project) return raw;