ampcode-connector 0.1.12 → 0.1.14

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 CHANGED
@@ -10,6 +10,8 @@ bunx ampcode-connector # start
10
10
 
11
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).
12
12
 
13
+ `setup` writes `amp.url` to Amp's canonical settings file (`~/.config/amp/settings.json`, or `AMP_SETTINGS_FILE` if set). Amp tokens are stored in `~/.local/share/amp/secrets.json`.
14
+
13
15
  ## License
14
16
 
15
17
  [MIT](LICENSE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ampcode-connector",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
package/src/cli/setup.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  /** Auto-configure Amp CLI to route through ampcode-connector. */
2
2
 
3
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
4
  import { homedir } from "node:os";
5
5
  import { dirname, join } from "node:path";
6
6
  import { loadConfig } from "../config/config.ts";
@@ -10,14 +10,26 @@ import * as status from "./status.ts";
10
10
  const AMP_SECRETS_DIR = join(homedir(), ".local", "share", "amp");
11
11
  const AMP_SECRETS_PATH = join(AMP_SECRETS_DIR, "secrets.json");
12
12
 
13
- const AMP_SETTINGS_PATHS = [
14
- join(homedir(), ".config", "amp", "settings.json"),
15
- join(homedir(), ".amp", "settings.json"),
16
- ];
13
+ const AMP_SETTINGS_PATH = join(homedir(), ".config", "amp", "settings.json");
14
+ const AMP_LEGACY_SETTINGS_PATH = join(homedir(), ".amp", "settings.json");
17
15
 
18
- function ampSettingsPaths(): string[] {
16
+ function ampSettingsPath(): string {
19
17
  const envPath = process.env.AMP_SETTINGS_FILE;
20
- return envPath ? [envPath] : AMP_SETTINGS_PATHS;
18
+ return envPath || AMP_SETTINGS_PATH;
19
+ }
20
+
21
+ function warnLegacySettingsFile(): void {
22
+ if (process.env.AMP_SETTINGS_FILE || !existsSync(AMP_LEGACY_SETTINGS_PATH)) return;
23
+ try {
24
+ if (lstatSync(AMP_LEGACY_SETTINGS_PATH).isSymbolicLink()) return;
25
+ } catch {
26
+ return;
27
+ }
28
+
29
+ line(
30
+ `${s.yellow}!${s.reset} Legacy settings file detected at ${s.dim}${AMP_LEGACY_SETTINGS_PATH}${s.reset}. ` +
31
+ `Prefer a single source of truth at ${s.dim}${AMP_SETTINGS_PATH}${s.reset}.`,
32
+ );
21
33
  }
22
34
 
23
35
  function readJson(path: string): Record<string, unknown> {
@@ -88,14 +100,15 @@ export async function setup(): Promise<void> {
88
100
  line(`${s.bold}ampcode-connector setup${s.reset}`);
89
101
  line();
90
102
 
91
- // Step 1: Configure amp.url in all settings files
92
- for (const settingsPath of ampSettingsPaths()) {
93
- const settings = readJson(settingsPath);
94
- if (settings["amp.url"] === proxyUrl) continue;
103
+ // Step 1: Configure amp.url in canonical settings file
104
+ const settingsPath = ampSettingsPath();
105
+ const settings = readJson(settingsPath);
106
+ if (settings["amp.url"] !== proxyUrl) {
95
107
  settings["amp.url"] = proxyUrl;
96
108
  writeJson(settingsPath, settings);
97
109
  }
98
- line(`${s.green}ok${s.reset} amp.url = ${s.cyan}${proxyUrl}${s.reset}`);
110
+ line(`${s.green}ok${s.reset} amp.url = ${s.cyan}${proxyUrl}${s.reset} ${s.dim}${settingsPath}${s.reset}`);
111
+ warnLegacySettingsFile();
99
112
 
100
113
  // Step 2: Amp API key
101
114
  const existingKey = findAmpApiKey(proxyUrl);
@@ -23,6 +23,7 @@ interface Choice {
23
23
  interface Delta {
24
24
  role?: string;
25
25
  content?: string;
26
+ reasoning_content?: string;
26
27
  tool_calls?: ToolCallDelta[];
27
28
  }
28
29
 
@@ -38,6 +39,7 @@ interface Usage {
38
39
  completion_tokens: number;
39
40
  total_tokens: number;
40
41
  prompt_tokens_details?: { cached_tokens: number };
42
+ completion_tokens_details?: { reasoning_tokens: number };
41
43
  }
42
44
 
43
45
  interface TransformState {
@@ -128,10 +130,24 @@ function createResponseTransformer(ampModel: string): (data: string) => string {
128
130
  return serializeFinish(state, finishReason, usage);
129
131
  }
130
132
 
131
- // Reasoning/thinking delta — emit as content (Amp shows thinking)
133
+ // Response incomplete — emit finish_reason "length" + usage
134
+ case "response.incomplete": {
135
+ const resp = parsed.response as Record<string, unknown>;
136
+ const usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
137
+ return serializeFinish(state, "length", usage);
138
+ }
139
+
140
+ // Response failed — emit finish_reason "stop" (error)
141
+ case "response.failed": {
142
+ const resp = parsed.response as Record<string, unknown>;
143
+ const usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
144
+ return serializeFinish(state, "stop", usage);
145
+ }
146
+
147
+ // Reasoning/thinking delta — emit as reasoning_content (separate from content)
132
148
  case "response.reasoning_summary_text.delta": {
133
149
  const delta = parsed.delta as string;
134
- if (delta) return serialize(state, { content: delta });
150
+ if (delta) return serialize(state, { reasoning_content: delta });
135
151
  return "";
136
152
  }
137
153
 
@@ -180,11 +196,13 @@ function extractUsage(raw: Record<string, unknown> | undefined): Usage | undefin
180
196
  const input = (raw.input_tokens as number) ?? 0;
181
197
  const output = (raw.output_tokens as number) ?? 0;
182
198
  const cached = (raw.input_tokens_details as Record<string, unknown>)?.cached_tokens as number | undefined;
199
+ const reasoning = (raw.output_tokens_details as Record<string, unknown>)?.reasoning_tokens as number | undefined;
183
200
  return {
184
201
  prompt_tokens: input,
185
202
  completion_tokens: output,
186
203
  total_tokens: input + output,
187
204
  ...(cached !== undefined ? { prompt_tokens_details: { cached_tokens: cached } } : {}),
205
+ ...(reasoning !== undefined ? { completion_tokens_details: { reasoning_tokens: reasoning } } : {}),
188
206
  };
189
207
  }
190
208
 
@@ -206,6 +224,161 @@ export function transformCodexResponse(response: Response, ampModel: string): Re
206
224
  });
207
225
  }
208
226
 
227
+ /** Buffer a Codex SSE response into a single Chat Completions JSON response.
228
+ * Used when the client requests stream: false but the backend forces streaming. */
229
+ export async function bufferCodexResponse(response: Response, ampModel: string): Promise<Response> {
230
+ if (!response.body) return response;
231
+
232
+ const state: TransformState = {
233
+ responseId: "",
234
+ model: ampModel,
235
+ created: Math.floor(Date.now() / 1000),
236
+ toolCallIndex: 0,
237
+ toolCallIds: new Map(),
238
+ };
239
+
240
+ let content = "";
241
+ let reasoningContent = "";
242
+ const toolCalls: Map<number, { id: string; type: string; function: { name: string; arguments: string } }> = new Map();
243
+ let finishReason = "stop";
244
+ let usage: Usage | undefined;
245
+
246
+ const decoder = new TextDecoder();
247
+ let sseBuffer = "";
248
+
249
+ const reader = response.body.getReader();
250
+ for (;;) {
251
+ const { done, value } = await reader.read();
252
+ if (done) break;
253
+
254
+ sseBuffer += decoder.decode(value, { stream: true }).replaceAll("\r\n", "\n");
255
+ const boundary = sseBuffer.lastIndexOf("\n\n");
256
+ if (boundary === -1) continue;
257
+
258
+ const complete = sseBuffer.slice(0, boundary + 2);
259
+ sseBuffer = sseBuffer.slice(boundary + 2);
260
+
261
+ for (const chunk of sse.parse(complete)) {
262
+ if (chunk.data === "[DONE]") continue;
263
+
264
+ let parsed: Record<string, unknown>;
265
+ try {
266
+ parsed = JSON.parse(chunk.data) as Record<string, unknown>;
267
+ } catch {
268
+ continue;
269
+ }
270
+
271
+ const eventType = parsed.type as string | undefined;
272
+ if (!eventType) continue;
273
+
274
+ if (eventType === "response.created") {
275
+ const resp = parsed.response as Record<string, unknown>;
276
+ state.responseId = (resp?.id as string) ?? state.responseId;
277
+ state.created = (resp?.created_at as number) ?? state.created;
278
+ continue;
279
+ }
280
+
281
+ switch (eventType) {
282
+ case "response.output_text.delta": {
283
+ const delta = parsed.delta as string;
284
+ if (delta) content += delta;
285
+ break;
286
+ }
287
+
288
+ case "response.reasoning_summary_text.delta": {
289
+ const delta = parsed.delta as string;
290
+ if (delta) reasoningContent += delta;
291
+ break;
292
+ }
293
+
294
+ case "response.output_item.added": {
295
+ const item = parsed.item as Record<string, unknown>;
296
+ if (item?.type === "function_call") {
297
+ const callId = item.call_id as string;
298
+ const name = item.name as string;
299
+ const idx = state.toolCallIndex++;
300
+ state.toolCallIds.set(callId, idx);
301
+ toolCalls.set(idx, { id: callId, type: "function", function: { name, arguments: "" } });
302
+ }
303
+ break;
304
+ }
305
+
306
+ case "response.function_call_arguments.delta": {
307
+ const delta = parsed.delta as string;
308
+ const callId = parsed.call_id as string | undefined;
309
+ if (delta) {
310
+ const idx = callId ? (state.toolCallIds.get(callId) ?? 0) : 0;
311
+ const tc = toolCalls.get(idx);
312
+ if (tc) tc.function.arguments += delta;
313
+ }
314
+ break;
315
+ }
316
+
317
+ case "response.completed": {
318
+ const resp = parsed.response as Record<string, unknown>;
319
+ usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
320
+ finishReason = state.toolCallIndex > 0 ? "tool_calls" : "stop";
321
+ break;
322
+ }
323
+
324
+ case "response.incomplete": {
325
+ const resp = parsed.response as Record<string, unknown>;
326
+ usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
327
+ finishReason = "length";
328
+ break;
329
+ }
330
+
331
+ case "response.failed": {
332
+ const resp = parsed.response as Record<string, unknown>;
333
+ usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
334
+ break;
335
+ }
336
+ }
337
+ }
338
+ }
339
+
340
+ // Process remaining buffer
341
+ if (sseBuffer.trim()) {
342
+ for (const chunk of sse.parse(sseBuffer)) {
343
+ if (chunk.data === "[DONE]") continue;
344
+ try {
345
+ const parsed = JSON.parse(chunk.data) as Record<string, unknown>;
346
+ if (parsed.type === "response.completed") {
347
+ const resp = parsed.response as Record<string, unknown>;
348
+ usage = extractUsage(resp?.usage as Record<string, unknown> | undefined);
349
+ finishReason = state.toolCallIndex > 0 ? "tool_calls" : "stop";
350
+ }
351
+ } catch {
352
+ // skip
353
+ }
354
+ }
355
+ }
356
+
357
+ const message: Record<string, unknown> = { role: "assistant", content: content || null };
358
+ if (reasoningContent) {
359
+ message.reasoning_content = reasoningContent;
360
+ }
361
+ if (toolCalls.size > 0) {
362
+ message.tool_calls = Array.from(toolCalls.entries())
363
+ .sort(([a], [b]) => a - b)
364
+ .map(([, tc]) => tc);
365
+ }
366
+
367
+ const result = {
368
+ id: `chatcmpl-${state.responseId}`,
369
+ object: "chat.completion",
370
+ created: state.created,
371
+ model: state.model,
372
+ choices: [{ index: 0, message, finish_reason: finishReason }],
373
+ ...(usage ? { usage } : {}),
374
+ };
375
+
376
+ return new Response(JSON.stringify(result), {
377
+ status: 200,
378
+ headers: { "Content-Type": "application/json" },
379
+ });
380
+ }
381
+
209
382
  /** Custom SSE transform that strips event names (Chat Completions doesn't use them). */
210
383
  function transformStream(source: ReadableStream<Uint8Array>, fn: (data: string) => string): ReadableStream<Uint8Array> {
211
384
  const decoder = new TextDecoder();
@@ -10,7 +10,7 @@ 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 { transformCodexResponse } from "./codex-sse.ts";
13
+ import { bufferCodexResponse, transformCodexResponse } from "./codex-sse.ts";
14
14
  import { denied, forward } from "./forward.ts";
15
15
 
16
16
  const DEFAULT_INSTRUCTIONS = "You are an expert coding assistant.";
@@ -60,6 +60,9 @@ export const provider: Provider = {
60
60
 
61
61
  // Transform Responses API SSE → Chat Completions SSE when original was messages[] format
62
62
  if (needsResponseTransform && response.ok) {
63
+ if (!body.stream) {
64
+ return bufferCodexResponse(response, ampModel);
65
+ }
63
66
  return transformCodexResponse(response, ampModel);
64
67
  }
65
68
  return response;