ampcode-connector 0.1.9 → 0.1.11

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.9",
3
+ "version": "0.1.11",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,7 +34,7 @@
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/"
@@ -42,14 +42,15 @@
42
42
  "devDependencies": {
43
43
  "@biomejs/biome": "^2.4.2",
44
44
  "@google/genai": "^1.42.0",
45
- "@types/bun": "^1.3.9"
45
+ "@types/bun": "^1.3.9",
46
+ "@types/turndown": "^5.0.6"
46
47
  },
47
48
  "peerDependencies": {
48
49
  "typescript": "^5.9.3"
49
50
  },
50
51
  "dependencies": {
51
- "@kreuzberg/html-to-markdown": "^2.25.1",
52
- "ampcode-connector": "^0.1.8",
53
- "exa-js": "^2.4.0"
52
+ "exa-js": "^2.4.0",
53
+ "turndown": "^7.2.2",
54
+ "turndown-plugin-gfm": "^1.0.2"
54
55
  }
55
56
  }
@@ -2,7 +2,7 @@
2
2
 
3
3
  import { logger } from "../utils/logger.ts";
4
4
 
5
- export interface CallbackResult {
5
+ interface CallbackResult {
6
6
  code: string;
7
7
  state: string;
8
8
  }
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 as string,
191
+ accessToken: raw.access_token,
184
192
  refreshToken: (raw.refresh_token as string) ?? "",
185
- expiresAt: Date.now() + (raw.expires_in as number) * 1000 - buffer,
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
- let _dbPath = DEFAULT_DB_PATH;
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
- return JSON.parse(row.data) as Credentials;
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
- return init()
100
- .getAll.all(provider)
101
- .map((row) => ({
102
- account: row.account,
103
- credentials: JSON.parse(row.data) as Credentials,
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
@@ -13,7 +13,7 @@ export const screen = {
13
13
  };
14
14
 
15
15
  /** Erase from cursor to end of line. */
16
- export const eol = `${ESC}K`;
16
+ const eol = `${ESC}K`;
17
17
 
18
18
  export const s = {
19
19
  reset: `${ESC}0m`,
@@ -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: asNumber(file?.port) ?? DEFAULTS.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": "arm64",
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 "./base.ts";
14
+ import { denied, forward } from "./forward.ts";
15
15
 
16
16
  export const provider: Provider = {
17
17
  name: "Anthropic",
@@ -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 "./base.ts";
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, originalHeaders, rewrite, account = 0) {
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,12 +44,8 @@ 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
51
  const model = gemini?.model === "gemini-3-flash-preview" ? "gemini-3-flash" : (gemini?.model ?? "");
@@ -1,9 +1,7 @@
1
- /** Provider interface and shared request forwarding. */
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,73 +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
- email?: string;
30
- }
31
-
32
- const RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);
33
- const MAX_RETRIES = 3;
34
- const RETRY_DELAY_MS = 500;
35
-
36
- export async function forward(opts: ForwardOptions): Promise<Response> {
37
- for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
38
- let response: Response;
39
- try {
40
- response = await fetch(opts.url, {
41
- method: "POST",
42
- headers: opts.headers,
43
- body: opts.body,
44
- });
45
- } catch (err) {
46
- if (attempt < MAX_RETRIES) {
47
- logger.debug(`${opts.providerName} fetch error, retry ${attempt + 1}/${MAX_RETRIES}`, {
48
- error: String(err),
49
- });
50
- await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
51
- continue;
52
- }
53
- throw err;
54
- }
55
-
56
- // Retry on server errors (429 handled at routing layer)
57
- if (RETRYABLE_STATUS.has(response.status) && attempt < MAX_RETRIES) {
58
- await response.text(); // consume body
59
- logger.debug(`${opts.providerName} returned ${response.status}, retry ${attempt + 1}/${MAX_RETRIES}`);
60
- await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
61
- continue;
62
- }
63
-
64
- const contentType = response.headers.get("Content-Type") ?? "application/json";
65
-
66
- if (!response.ok) {
67
- const text = await response.text();
68
- const ctx = opts.email ? ` account=${opts.email}` : "";
69
- logger.error(`${opts.providerName} API error (${response.status})${ctx}`, { error: text.slice(0, 200) });
70
- return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
71
- }
72
-
73
- const isSSE = contentType.includes("text/event-stream") || opts.streaming;
74
- if (isSSE) return sse.proxy(response, opts.rewrite);
75
-
76
- if (opts.rewrite) {
77
- const text = await response.text();
78
- return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
79
- }
80
-
81
- return new Response(response.body, { status: response.status, headers: { "Content-Type": contentType } });
82
- }
83
-
84
- // Unreachable, but TypeScript needs it
85
- throw new Error(`${opts.providerName}: all retries exhausted`);
86
- }
87
-
88
- export function denied(providerName: string): Response {
89
- return Response.json({ error: `No ${providerName} OAuth token available. Run login first.` }, { status: 401 });
90
- }
@@ -50,7 +50,7 @@ interface TransformState {
50
50
  }
51
51
 
52
52
  /** Create a stateful SSE transformer: Responses API → Chat Completions. */
53
- export function createResponseTransformer(ampModel: string): (data: string) => string {
53
+ function createResponseTransformer(ampModel: string): (data: string) => string {
54
54
  const state: TransformState = {
55
55
  responseId: "",
56
56
  model: ampModel,
@@ -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
 
@@ -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
+ }
@@ -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 "./base.ts";
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);
@@ -5,7 +5,7 @@
5
5
 
6
6
  import type { QuotaPool } from "./cooldown.ts";
7
7
 
8
- export interface AffinityEntry {
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
- export class AffinityStore {
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;
@@ -22,7 +22,7 @@ const EXHAUSTED_CONSECUTIVE = 3;
22
22
  /** Default burst cooldown when no Retry-After header. */
23
23
  const DEFAULT_BURST_S = 30;
24
24
 
25
- export class CooldownTracker {
25
+ class CooldownTracker {
26
26
  private entries = new Map<string, CooldownEntry>();
27
27
 
28
28
  private key(pool: QuotaPool, account: number): string {
@@ -1,15 +1,16 @@
1
1
  /** Local handler for extractWebPageContent — fetches a URL, converts to Markdown, ranks by objective. */
2
2
 
3
- import { convert, JsPreprocessingPreset } from "@kreuzberg/html-to-markdown";
3
+ import TurndownService from "turndown";
4
+ import { gfm } from "turndown-plugin-gfm";
4
5
  import { logger } from "../utils/logger.ts";
5
6
 
6
- export interface WebReadParams {
7
+ interface WebReadParams {
7
8
  url: string;
8
9
  objective?: string;
9
10
  forceRefetch?: boolean;
10
11
  }
11
12
 
12
- export type WebReadResult =
13
+ type WebReadResult =
13
14
  | { ok: true; result: { excerpts: string[] } | { fullContent: string } }
14
15
  | { ok: false; error: { code: string; message: string } };
15
16
 
@@ -55,10 +56,9 @@ const CLIPPING = {
55
56
  EXCERPT_SEP_BYTES: 2, // "\n\n" separator
56
57
  } as const;
57
58
 
58
- const HTML_OPTIONS = {
59
- skipImages: true,
60
- preprocessing: { enabled: true, preset: JsPreprocessingPreset.Aggressive },
61
- };
59
+ const turndown = new TurndownService({ headingStyle: "atx", codeBlockStyle: "fenced" });
60
+ turndown.use(gfm);
61
+ turndown.remove(["script", "style", "img"]);
62
62
 
63
63
  // biome-ignore format: compact
64
64
  const STOP_WORDS = new Set(
@@ -141,7 +141,7 @@ function fetchError(message: string): FetchErr {
141
141
 
142
142
  function convertToMarkdown(raw: string, contentType: string): string {
143
143
  if (contentType.includes("text/html") || contentType.includes("application/xhtml")) {
144
- return convert(raw, HTML_OPTIONS);
144
+ return turndown.turndown(raw);
145
145
  }
146
146
 
147
147
  if (contentType.includes("application/json")) {
@@ -14,7 +14,7 @@ function getExa(apiKey: string): InstanceType<typeof Exa> {
14
14
  return _exa;
15
15
  }
16
16
 
17
- export interface SearchParams {
17
+ interface SearchParams {
18
18
  objective: string;
19
19
  searchQueries?: string[];
20
20
  maxResults?: number;
@@ -0,0 +1,8 @@
1
+ declare module "turndown-plugin-gfm" {
2
+ import type TurndownService from "turndown";
3
+ export function gfm(service: TurndownService): void;
4
+ export function tables(service: TurndownService): void;
5
+ export function strikethrough(service: TurndownService): void;
6
+ export function taskListItems(service: TurndownService): void;
7
+ export function highlightedCodeBlock(service: TurndownService): void;
8
+ }
@@ -10,7 +10,7 @@ interface WrapOptions {
10
10
  }
11
11
 
12
12
  /** Wrap a raw request body in the Cloud Code Assist envelope. */
13
- export function wrapRequest(opts: WrapOptions): string {
13
+ function wrapRequest(opts: WrapOptions): string {
14
14
  return JSON.stringify({
15
15
  project: opts.projectId,
16
16
  model: opts.model,
@@ -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
- export function transform(
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>({