ampcode-connector 0.1.14 → 0.1.16

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.14",
3
+ "version": "0.1.16",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/auth/oauth.ts CHANGED
@@ -36,18 +36,8 @@ export async function token(config: OAuthConfig, account = 0): Promise<string |
36
36
 
37
37
  if (store.fresh(creds)) return creds.accessToken;
38
38
 
39
- try {
40
- return (await refresh(config, creds.refreshToken, account)).accessToken;
41
- } catch (err) {
42
- logger.warn(`Token refresh failed for ${config.providerName}:${account}, retrying...`, { error: String(err) });
43
- try {
44
- await Bun.sleep(1000);
45
- return (await refresh(config, creds.refreshToken, account)).accessToken;
46
- } catch (retryErr) {
47
- logger.error(`Token refresh retry failed for ${config.providerName}:${account}`, { error: String(retryErr) });
48
- return null;
49
- }
50
- }
39
+ const refreshed = await refreshWithRetry(config, creds.refreshToken, account);
40
+ return refreshed?.accessToken ?? null;
51
41
  }
52
42
 
53
43
  export async function tokenFromAny(config: OAuthConfig): Promise<{ accessToken: string; account: number } | null> {
@@ -61,6 +51,7 @@ export async function tokenFromAny(config: OAuthConfig): Promise<{ accessToken:
61
51
  const refreshed = await refresh(config, c.refreshToken, account);
62
52
  return { accessToken: refreshed.accessToken, account };
63
53
  } catch (err) {
54
+ handleRefreshFailure(config, account, err);
64
55
  logger.debug(`${config.providerName}:${account} refresh failed in tokenFromAny`, { error: String(err) });
65
56
  }
66
57
  }
@@ -160,6 +151,29 @@ async function refresh(config: OAuthConfig, refreshToken: string, account = 0):
160
151
  return credentials;
161
152
  }
162
153
 
154
+ async function refreshWithRetry(
155
+ config: OAuthConfig,
156
+ refreshToken: string,
157
+ account: number,
158
+ ): Promise<Credentials | null> {
159
+ try {
160
+ return await refresh(config, refreshToken, account);
161
+ } catch (err) {
162
+ if (handleRefreshFailure(config, account, err)) return null;
163
+
164
+ logger.warn(`Token refresh failed for ${config.providerName}:${account}, retrying...`, { error: String(err) });
165
+
166
+ try {
167
+ await Bun.sleep(1000);
168
+ return await refresh(config, refreshToken, account);
169
+ } catch (retryErr) {
170
+ handleRefreshFailure(config, account, retryErr);
171
+ logger.error(`Token refresh retry failed for ${config.providerName}:${account}`, { error: String(retryErr) });
172
+ return null;
173
+ }
174
+ }
175
+ }
176
+
163
177
  async function exchange(config: OAuthConfig, params: Record<string, string>): Promise<Record<string, unknown>> {
164
178
  const all: Record<string, string> = { client_id: config.clientId, ...params };
165
179
  if (config.clientSecret) all.client_secret = config.clientSecret;
@@ -173,12 +187,83 @@ async function exchange(config: OAuthConfig, params: Record<string, string>): Pr
173
187
 
174
188
  if (!res.ok) {
175
189
  const text = await res.text();
176
- throw new Error(`${config.providerName} token exchange failed (${res.status}): ${text}`);
190
+ const oauthError = parseOAuthError(text);
191
+ throw new TokenExchangeError(config.providerName, res.status, text, oauthError.code, oauthError.description);
177
192
  }
178
193
 
179
194
  return (await res.json()) as Record<string, unknown>;
180
195
  }
181
196
 
197
+ class TokenExchangeError extends Error {
198
+ readonly status: number;
199
+ readonly responseBody: string;
200
+ readonly errorCode: string | null;
201
+ readonly errorDescription: string | null;
202
+
203
+ constructor(
204
+ providerName: string,
205
+ status: number,
206
+ responseBody: string,
207
+ errorCode: string | null,
208
+ errorDescription: string | null,
209
+ ) {
210
+ super(`${providerName} token exchange failed (${status}): ${responseBody}`);
211
+ this.name = "TokenExchangeError";
212
+ this.status = status;
213
+ this.responseBody = responseBody;
214
+ this.errorCode = errorCode;
215
+ this.errorDescription = errorDescription;
216
+ }
217
+ }
218
+
219
+ function parseOAuthError(responseBody: string): { code: string | null; description: string | null } {
220
+ try {
221
+ const parsed = JSON.parse(responseBody) as { error?: unknown; error_description?: unknown };
222
+ const code = typeof parsed.error === "string" ? parsed.error : null;
223
+ const description = typeof parsed.error_description === "string" ? parsed.error_description : null;
224
+ return { code, description };
225
+ } catch {
226
+ return { code: null, description: null };
227
+ }
228
+ }
229
+
230
+ /** Handles terminal refresh failures and returns true when retry should stop. */
231
+ function handleRefreshFailure(config: OAuthConfig, account: number, err: unknown): boolean {
232
+ if (!isInvalidRefreshTokenError(err)) return false;
233
+ if (!store.get(config.providerName, account)) return false;
234
+
235
+ store.remove(config.providerName, account);
236
+ logger.warn(`Removed invalid refresh token for ${config.providerName}:${account}; re-login required.`, {
237
+ error: String(err),
238
+ });
239
+ return true;
240
+ }
241
+
242
+ function isInvalidRefreshTokenError(err: unknown): boolean {
243
+ if (!(err instanceof TokenExchangeError)) return false;
244
+ if (err.status !== 400 && err.status !== 401) return false;
245
+
246
+ if (err.errorCode === "invalid_grant") return true;
247
+
248
+ const description = err.errorDescription?.toLowerCase() ?? "";
249
+ const hasRefreshTokenContext = description.includes("refresh token");
250
+ const indicatesInvalidState =
251
+ description.includes("invalid") ||
252
+ description.includes("not found") ||
253
+ description.includes("expired") ||
254
+ description.includes("revoked");
255
+
256
+ if (hasRefreshTokenContext && indicatesInvalidState) return true;
257
+
258
+ const body = err.responseBody.toLowerCase();
259
+ return (
260
+ body.includes("invalid_grant") ||
261
+ body.includes("invalid refresh token") ||
262
+ body.includes("refresh token not found") ||
263
+ body.includes("refresh token is invalid")
264
+ );
265
+ }
266
+
182
267
  function parseTokenFields(raw: Record<string, unknown>, config: OAuthConfig): Credentials {
183
268
  if (typeof raw.access_token !== "string" || !raw.access_token) {
184
269
  throw new Error(`${config.providerName} token response missing access_token`);
@@ -62,7 +62,7 @@ function createResponseTransformer(ampModel: string): (data: string) => string {
62
62
  };
63
63
 
64
64
  return (data: string): string => {
65
- if (data === "[DONE]") return data;
65
+ if (data === "[DONE]") return "";
66
66
 
67
67
  let parsed: Record<string, unknown>;
68
68
  try {
@@ -130,18 +130,26 @@ function createResponseTransformer(ampModel: string): (data: string) => string {
130
130
  return serializeFinish(state, finishReason, usage);
131
131
  }
132
132
 
133
- // Response incomplete — emit finish_reason "length" + usage
133
+ // Response incomplete — inspect reason to determine finish_reason
134
134
  case "response.incomplete": {
135
135
  const resp = parsed.response as Record<string, unknown>;
136
136
  const usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
137
- return serializeFinish(state, "length", usage);
137
+ const finishReason = incompleteReason(resp);
138
+ return serializeFinish(state, finishReason, usage);
138
139
  }
139
140
 
140
- // Response failed — emit finish_reason "stop" (error)
141
+ // Response failed — emit error content so the client sees the failure
141
142
  case "response.failed": {
142
143
  const resp = parsed.response as Record<string, unknown>;
143
144
  const usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
144
- return serializeFinish(state, "stop", usage);
145
+ const errorMsg = extractErrorMessage(resp);
146
+ let chunks = "";
147
+ if (errorMsg) {
148
+ chunks = serialize(state, { role: "assistant", content: `[Error] ${errorMsg}` });
149
+ chunks += "\n\n";
150
+ }
151
+ chunks += serializeFinish(state, "stop", usage);
152
+ return chunks;
145
153
  }
146
154
 
147
155
  // Reasoning/thinking delta — emit as reasoning_content (separate from content)
@@ -191,6 +199,28 @@ function serializeFinish(state: TransformState, finishReason: string, usage?: Us
191
199
  return JSON.stringify(chunk);
192
200
  }
193
201
 
202
+ /** Map Responses API incomplete reason → Chat Completions finish_reason. */
203
+ function incompleteReason(resp: Record<string, unknown> | undefined): string {
204
+ if (!resp) return "length";
205
+ const reason = resp.incomplete_details as Record<string, unknown> | undefined;
206
+ const type = reason?.reason as string | undefined;
207
+ if (type === "max_output_tokens" || type === "max_tokens") return "length";
208
+ if (type === "content_filter") return "content_filter";
209
+ return "length";
210
+ }
211
+
212
+ /** Extract a human-readable error message from a failed response. */
213
+ function extractErrorMessage(resp: Record<string, unknown> | undefined): string | null {
214
+ if (!resp) return null;
215
+ const error = resp.error as Record<string, unknown> | undefined;
216
+ if (!error) return null;
217
+ const message = error.message as string | undefined;
218
+ const code = error.code as string | undefined;
219
+ if (message) return code ? `${code}: ${message}` : message;
220
+ if (code) return code;
221
+ return null;
222
+ }
223
+
194
224
  function extractUsage(raw: Record<string, unknown> | undefined): Usage | undefined {
195
225
  if (!raw) return undefined;
196
226
  const input = (raw.input_tokens as number) ?? 0;
@@ -206,6 +236,15 @@ function extractUsage(raw: Record<string, unknown> | undefined): Usage | undefin
206
236
  };
207
237
  }
208
238
 
239
+ const FORWARDED_HEADERS = [
240
+ "x-request-id",
241
+ "request-id",
242
+ "x-ratelimit-limit-requests",
243
+ "x-ratelimit-remaining-requests",
244
+ "x-ratelimit-limit-tokens",
245
+ "x-ratelimit-remaining-tokens",
246
+ ] as const;
247
+
209
248
  /** Wrap a Codex SSE response with the Responses → Chat Completions transformer.
210
249
  * Strips Responses API event names so output looks like standard Chat Completions SSE. */
211
250
  export function transformCodexResponse(response: Response, ampModel: string): Response {
@@ -214,14 +253,17 @@ export function transformCodexResponse(response: Response, ampModel: string): Re
214
253
  const transformer = createResponseTransformer(ampModel);
215
254
  const body = transformStream(response.body, transformer);
216
255
 
217
- return new Response(body, {
218
- status: response.status,
219
- headers: {
220
- "Content-Type": "text/event-stream",
221
- "Cache-Control": "no-cache",
222
- Connection: "keep-alive",
223
- },
224
- });
256
+ const headers: Record<string, string> = {
257
+ "Content-Type": "text/event-stream",
258
+ "Cache-Control": "no-cache",
259
+ Connection: "keep-alive",
260
+ };
261
+ for (const name of FORWARDED_HEADERS) {
262
+ const value = response.headers.get(name);
263
+ if (value) headers[name] = value;
264
+ }
265
+
266
+ return new Response(body, { status: response.status, headers });
225
267
  }
226
268
 
227
269
  /** Buffer a Codex SSE response into a single Chat Completions JSON response.
@@ -324,32 +366,87 @@ export async function bufferCodexResponse(response: Response, ampModel: string):
324
366
  case "response.incomplete": {
325
367
  const resp = parsed.response as Record<string, unknown>;
326
368
  usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
327
- finishReason = "length";
369
+ finishReason = incompleteReason(resp);
328
370
  break;
329
371
  }
330
372
 
331
373
  case "response.failed": {
332
374
  const resp = parsed.response as Record<string, unknown>;
333
375
  usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
376
+ const errorMsg = extractErrorMessage(resp);
377
+ if (errorMsg) content += `[Error] ${errorMsg}`;
334
378
  break;
335
379
  }
336
380
  }
337
381
  }
338
382
  }
339
383
 
340
- // Process remaining buffer
384
+ // Process remaining buffer — reuse the same event handling as main loop
341
385
  if (sseBuffer.trim()) {
342
386
  for (const chunk of sse.parse(sseBuffer)) {
343
387
  if (chunk.data === "[DONE]") continue;
388
+
389
+ let parsed: Record<string, unknown>;
344
390
  try {
345
- const parsed = JSON.parse(chunk.data) as Record<string, unknown>;
346
- if (parsed.type === "response.completed") {
391
+ parsed = JSON.parse(chunk.data) as Record<string, unknown>;
392
+ } catch {
393
+ continue;
394
+ }
395
+
396
+ const eventType = parsed.type as string | undefined;
397
+ if (!eventType) continue;
398
+
399
+ switch (eventType) {
400
+ case "response.output_text.delta": {
401
+ const delta = parsed.delta as string;
402
+ if (delta) content += delta;
403
+ break;
404
+ }
405
+ case "response.reasoning_summary_text.delta": {
406
+ const delta = parsed.delta as string;
407
+ if (delta) reasoningContent += delta;
408
+ break;
409
+ }
410
+ case "response.output_item.added": {
411
+ const item = parsed.item as Record<string, unknown>;
412
+ if (item?.type === "function_call") {
413
+ const callId = item.call_id as string;
414
+ const name = item.name as string;
415
+ const idx = state.toolCallIndex++;
416
+ state.toolCallIds.set(callId, idx);
417
+ toolCalls.set(idx, { id: callId, type: "function", function: { name, arguments: "" } });
418
+ }
419
+ break;
420
+ }
421
+ case "response.function_call_arguments.delta": {
422
+ const delta = parsed.delta as string;
423
+ const callId = parsed.call_id as string | undefined;
424
+ if (delta) {
425
+ const idx = callId ? (state.toolCallIds.get(callId) ?? 0) : 0;
426
+ const tc = toolCalls.get(idx);
427
+ if (tc) tc.function.arguments += delta;
428
+ }
429
+ break;
430
+ }
431
+ case "response.completed": {
347
432
  const resp = parsed.response as Record<string, unknown>;
348
433
  usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
349
434
  finishReason = state.toolCallIndex > 0 ? "tool_calls" : "stop";
435
+ break;
436
+ }
437
+ case "response.incomplete": {
438
+ const resp = parsed.response as Record<string, unknown>;
439
+ usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
440
+ finishReason = incompleteReason(resp);
441
+ break;
442
+ }
443
+ case "response.failed": {
444
+ const resp = parsed.response as Record<string, unknown>;
445
+ usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
446
+ const errorMsg = extractErrorMessage(resp);
447
+ if (errorMsg) content += `[Error] ${errorMsg}`;
448
+ break;
350
449
  }
351
- } catch {
352
- // skip
353
450
  }
354
451
  }
355
452
  }
@@ -135,16 +135,22 @@ function transformForCodex(
135
135
  fixOrphanOutputs(parsed.input as Record<string, unknown>[]);
136
136
  }
137
137
 
138
- // Reasoning config — defaults match reference behavior
138
+ // Reasoning config — merge with caller-provided values, defaults match reference behavior
139
139
  const model = (parsed.model as string) ?? "";
140
+ const existingReasoning = (parsed.reasoning as Record<string, unknown>) ?? {};
140
141
  parsed.reasoning = {
141
- effort: clampReasoningEffort(model, "high"),
142
- summary: "auto",
142
+ effort: clampReasoningEffort(model, (existingReasoning.effort as string) ?? "high"),
143
+ summary: existingReasoning.summary ?? "auto",
143
144
  };
144
145
 
145
- parsed.text = { verbosity: "medium" };
146
+ const existingText = (parsed.text as Record<string, unknown>) ?? {};
147
+ parsed.text = { ...existingText, verbosity: existingText.verbosity ?? "medium" };
146
148
 
147
- parsed.include = ["reasoning.encrypted_content"];
149
+ const existingInclude = Array.isArray(parsed.include) ? (parsed.include as string[]) : [];
150
+ if (!existingInclude.includes("reasoning.encrypted_content")) {
151
+ existingInclude.push("reasoning.encrypted_content");
152
+ }
153
+ parsed.include = existingInclude;
148
154
 
149
155
  if (promptCacheKey) {
150
156
  parsed.prompt_cache_key = promptCacheKey;
@@ -165,6 +171,23 @@ function transformForCodex(
165
171
  delete parsed.logit_bias;
166
172
  delete parsed.response_format;
167
173
 
174
+ // Normalize tools[] for Responses API: flatten function.{name,description,parameters,strict} to top-level
175
+ if (Array.isArray(parsed.tools)) {
176
+ parsed.tools = (parsed.tools as Record<string, unknown>[]).map((tool) => {
177
+ if (tool.type === "function" && tool.function && typeof tool.function === "object") {
178
+ const fn = tool.function as Record<string, unknown>;
179
+ return {
180
+ type: "function",
181
+ name: fn.name,
182
+ description: fn.description,
183
+ parameters: fn.parameters,
184
+ ...(fn.strict !== undefined ? { strict: fn.strict } : {}),
185
+ };
186
+ }
187
+ return tool;
188
+ });
189
+ }
190
+
168
191
  // Normalize tool_choice for Responses API
169
192
  if (parsed.tool_choice !== undefined && parsed.tool_choice !== null) {
170
193
  if (typeof parsed.tool_choice === "string") {
@@ -236,7 +259,7 @@ function convertMessages(messages: ChatMessage[]): { instructions: string | null
236
259
  input.push({
237
260
  type: "function_call_output",
238
261
  call_id: msg.tool_call_id,
239
- output: textOf(msg.content) ?? "",
262
+ output: stringifyContent(msg.content),
240
263
  });
241
264
  break;
242
265
  }
@@ -265,6 +288,18 @@ function convertUserContent(content: unknown): unknown[] {
265
288
  return [{ type: "input_text", text: String(content) }];
266
289
  }
267
290
 
291
+ /** Convert content to string, with JSON fallback for non-text values. */
292
+ function stringifyContent(content: unknown): string {
293
+ if (typeof content === "string") return content;
294
+ const text = textOf(content);
295
+ if (text !== null) return text;
296
+ try {
297
+ return JSON.stringify(content);
298
+ } catch {
299
+ return String(content ?? "");
300
+ }
301
+ }
302
+
268
303
  /** Extract text from content (string or array). */
269
304
  function textOf(content: unknown): string | null {
270
305
  if (typeof content === "string") return content;