ampcode-connector 0.1.11 → 0.1.13

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.11",
3
+ "version": "0.1.13",
4
4
  "description": "Proxy AmpCode through local OAuth subscriptions (Claude Code, Codex, Gemini CLI, Antigravity)",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -7,6 +7,7 @@ import type { LogLevel } from "../utils/logger.ts";
7
7
  import { logger } from "../utils/logger.ts";
8
8
 
9
9
  export interface ProxyConfig {
10
+ hostname: string;
10
11
  port: number;
11
12
  ampUpstreamUrl: string;
12
13
  ampApiKey?: string;
@@ -20,6 +21,7 @@ export interface ProxyConfig {
20
21
  }
21
22
 
22
23
  const DEFAULTS: ProxyConfig = {
24
+ hostname: "localhost",
23
25
  port: 8765,
24
26
  ampUpstreamUrl: DEFAULT_AMP_UPSTREAM_URL,
25
27
  logLevel: "info",
@@ -44,6 +46,7 @@ export async function loadConfig(): Promise<ProxyConfig> {
44
46
  }
45
47
 
46
48
  return {
49
+ hostname: asString(file?.hostname) ?? process.env.HOST ?? DEFAULTS.hostname,
47
50
  port,
48
51
  ampUpstreamUrl: asString(file?.ampUpstreamUrl) ?? DEFAULTS.ampUpstreamUrl,
49
52
  ampApiKey: apiKey,
package/src/index.ts CHANGED
@@ -59,7 +59,7 @@ function banner(config: ProxyConfig): void {
59
59
 
60
60
  line();
61
61
  line(` ${s.bold}ampcode-connector${s.reset}`);
62
- line(` ${s.dim}http://localhost:${config.port}${s.reset}`);
62
+ line(` ${s.dim}http://${config.hostname}:${config.port}${s.reset}`);
63
63
  line();
64
64
 
65
65
  for (const p of providers) {
@@ -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;
@@ -17,7 +17,7 @@ import { type ParsedBody, parseBody } from "./body.ts";
17
17
  export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
18
18
  const server = Bun.serve({
19
19
  port: config.port,
20
- hostname: "localhost",
20
+ hostname: config.hostname,
21
21
  idleTimeout: 255, // seconds — LLM streaming responses can take minutes
22
22
 
23
23
  async fetch(req) {
@@ -38,7 +38,7 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
38
38
  });
39
39
 
40
40
  affinity.startCleanup();
41
- logger.info(`ampcode-connector listening on http://localhost:${config.port}`);
41
+ logger.info(`ampcode-connector listening on http://${config.hostname}:${config.port}`);
42
42
 
43
43
  const shutdown = () => {
44
44
  logger.info("Shutting down...");