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 +2 -0
- package/package.json +1 -1
- package/src/cli/setup.ts +25 -12
- package/src/providers/codex-sse.ts +175 -2
- package/src/providers/codex.ts +4 -1
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
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
|
|
14
|
-
|
|
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
|
|
16
|
+
function ampSettingsPath(): string {
|
|
19
17
|
const envPath = process.env.AMP_SETTINGS_FILE;
|
|
20
|
-
return envPath
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
//
|
|
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, {
|
|
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();
|
package/src/providers/codex.ts
CHANGED
|
@@ -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;
|