ampcode-connector 0.1.7 → 0.1.8
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 +1 -1
- package/src/providers/base.ts +48 -19
- package/src/providers/codex.ts +85 -3
- package/src/server/server.ts +1 -0
package/package.json
CHANGED
package/src/providers/base.ts
CHANGED
|
@@ -28,30 +28,59 @@ interface ForwardOptions {
|
|
|
28
28
|
rewrite?: (data: string) => string;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
const RETRYABLE_STATUS = new Set([408, 500, 502, 503, 504]);
|
|
32
|
+
const MAX_RETRIES = 3;
|
|
33
|
+
const RETRY_DELAY_MS = 500;
|
|
34
|
+
|
|
31
35
|
export async function forward(opts: ForwardOptions): Promise<Response> {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
36
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
37
|
+
let response: Response;
|
|
38
|
+
try {
|
|
39
|
+
response = await fetch(opts.url, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: opts.headers,
|
|
42
|
+
body: opts.body,
|
|
43
|
+
});
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (attempt < MAX_RETRIES) {
|
|
46
|
+
logger.debug(`${opts.providerName} fetch error, retry ${attempt + 1}/${MAX_RETRIES}`, {
|
|
47
|
+
error: String(err),
|
|
48
|
+
});
|
|
49
|
+
await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
throw err;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// Retry on server errors (429 handled at routing layer)
|
|
56
|
+
if (RETRYABLE_STATUS.has(response.status) && attempt < MAX_RETRIES) {
|
|
57
|
+
await response.text(); // consume body
|
|
58
|
+
logger.debug(`${opts.providerName} returned ${response.status}, retry ${attempt + 1}/${MAX_RETRIES}`);
|
|
59
|
+
await Bun.sleep(RETRY_DELAY_MS * (attempt + 1));
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const contentType = response.headers.get("Content-Type") ?? "application/json";
|
|
64
|
+
|
|
65
|
+
if (!response.ok) {
|
|
66
|
+
const text = await response.text();
|
|
67
|
+
logger.error(`${opts.providerName} API error (${response.status})`, { error: text.slice(0, 200) });
|
|
68
|
+
return new Response(text, { status: response.status, headers: { "Content-Type": contentType } });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const isSSE = contentType.includes("text/event-stream") || opts.streaming;
|
|
72
|
+
if (isSSE) return sse.proxy(response, opts.rewrite);
|
|
45
73
|
|
|
46
|
-
|
|
47
|
-
|
|
74
|
+
if (opts.rewrite) {
|
|
75
|
+
const text = await response.text();
|
|
76
|
+
return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
|
|
77
|
+
}
|
|
48
78
|
|
|
49
|
-
|
|
50
|
-
const text = await response.text();
|
|
51
|
-
return new Response(opts.rewrite(text), { status: response.status, headers: { "Content-Type": contentType } });
|
|
79
|
+
return new Response(response.body, { status: response.status, headers: { "Content-Type": contentType } });
|
|
52
80
|
}
|
|
53
81
|
|
|
54
|
-
|
|
82
|
+
// Unreachable, but TypeScript needs it
|
|
83
|
+
throw new Error(`${opts.providerName}: all retries exhausted`);
|
|
55
84
|
}
|
|
56
85
|
|
|
57
86
|
export function denied(providerName: string): Response {
|
package/src/providers/codex.ts
CHANGED
|
@@ -24,13 +24,14 @@ export const provider: Provider = {
|
|
|
24
24
|
|
|
25
25
|
accountCount: () => oauth.accountCount(config),
|
|
26
26
|
|
|
27
|
-
async forward(sub, body,
|
|
27
|
+
async forward(sub, body, originalHeaders, rewrite, account = 0) {
|
|
28
28
|
const accessToken = await oauth.token(config, account);
|
|
29
29
|
if (!accessToken) return denied("OpenAI Codex");
|
|
30
30
|
|
|
31
31
|
const accountId = getAccountId(accessToken, account);
|
|
32
32
|
const codexPath = codexPathMap[sub] ?? sub;
|
|
33
|
-
const
|
|
33
|
+
const promptCacheKey = originalHeaders.get("x-amp-thread-id") ?? undefined;
|
|
34
|
+
const { body: codexBody, needsResponseTransform } = transformForCodex(body.forwardBody, promptCacheKey);
|
|
34
35
|
const ampModel = body.ampModel ?? "gpt-5.2";
|
|
35
36
|
|
|
36
37
|
const response = await forward({
|
|
@@ -50,6 +51,9 @@ export const provider: Provider = {
|
|
|
50
51
|
"User-Agent": codexHeaderValues.USER_AGENT,
|
|
51
52
|
Version: codexHeaderValues.VERSION,
|
|
52
53
|
...(accountId ? { [codexHeaders.ACCOUNT_ID]: accountId } : {}),
|
|
54
|
+
...(promptCacheKey
|
|
55
|
+
? { [codexHeaders.SESSION_ID]: promptCacheKey, [codexHeaders.CONVERSATION_ID]: promptCacheKey }
|
|
56
|
+
: {}),
|
|
53
57
|
},
|
|
54
58
|
});
|
|
55
59
|
|
|
@@ -79,7 +83,20 @@ interface ToolCallItem {
|
|
|
79
83
|
function: { name: string; arguments: string };
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
function
|
|
86
|
+
function clampReasoningEffort(model: string, effort: string): string {
|
|
87
|
+
const modelId = model.includes("/") ? model.split("/").pop()! : model;
|
|
88
|
+
if (modelId === "gpt-5.1" && effort === "xhigh") return "high";
|
|
89
|
+
if ((modelId.startsWith("gpt-5.2") || modelId.startsWith("gpt-5.3")) && effort === "minimal") return "low";
|
|
90
|
+
if (modelId === "gpt-5.1-codex-mini") {
|
|
91
|
+
return effort === "high" || effort === "xhigh" ? "high" : "medium";
|
|
92
|
+
}
|
|
93
|
+
return effort;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function transformForCodex(
|
|
97
|
+
rawBody: string,
|
|
98
|
+
promptCacheKey?: string,
|
|
99
|
+
): { body: string; needsResponseTransform: boolean } {
|
|
83
100
|
if (!rawBody) return { body: rawBody, needsResponseTransform: false };
|
|
84
101
|
|
|
85
102
|
let parsed: Record<string, unknown>;
|
|
@@ -111,6 +128,22 @@ function transformForCodex(rawBody: string): { body: string; needsResponseTransf
|
|
|
111
128
|
// Strip id fields from input items
|
|
112
129
|
if (Array.isArray(parsed.input)) {
|
|
113
130
|
stripInputIds(parsed.input as Record<string, unknown>[]);
|
|
131
|
+
fixOrphanOutputs(parsed.input as Record<string, unknown>[]);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Reasoning config — defaults match reference behavior
|
|
135
|
+
const model = (parsed.model as string) ?? "";
|
|
136
|
+
parsed.reasoning = {
|
|
137
|
+
effort: clampReasoningEffort(model, "high"),
|
|
138
|
+
summary: "auto",
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
parsed.text = { verbosity: "medium" };
|
|
142
|
+
|
|
143
|
+
parsed.include = ["reasoning.encrypted_content"];
|
|
144
|
+
|
|
145
|
+
if (promptCacheKey) {
|
|
146
|
+
parsed.prompt_cache_key = promptCacheKey;
|
|
114
147
|
}
|
|
115
148
|
|
|
116
149
|
// Remove fields the Codex backend doesn't accept
|
|
@@ -127,6 +160,21 @@ function transformForCodex(rawBody: string): { body: string; needsResponseTransf
|
|
|
127
160
|
delete parsed.logit_bias;
|
|
128
161
|
delete parsed.response_format;
|
|
129
162
|
|
|
163
|
+
// Normalize tool_choice for Responses API
|
|
164
|
+
if (parsed.tool_choice !== undefined && parsed.tool_choice !== null) {
|
|
165
|
+
if (typeof parsed.tool_choice === "string") {
|
|
166
|
+
// "auto", "none", "required" pass through as-is
|
|
167
|
+
} else if (typeof parsed.tool_choice === "object") {
|
|
168
|
+
const tc = parsed.tool_choice as Record<string, unknown>;
|
|
169
|
+
if (tc.type === "function" && tc.function) {
|
|
170
|
+
const fn = tc.function as Record<string, unknown>;
|
|
171
|
+
parsed.tool_choice = { type: "function", name: fn.name };
|
|
172
|
+
} else if (tc.type === "tool" && tc.name) {
|
|
173
|
+
parsed.tool_choice = { type: "function", name: tc.name };
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
130
178
|
return { body: JSON.stringify(parsed), needsResponseTransform };
|
|
131
179
|
}
|
|
132
180
|
|
|
@@ -251,6 +299,40 @@ function stripInputIds(items: Record<string, unknown>[]): void {
|
|
|
251
299
|
}
|
|
252
300
|
}
|
|
253
301
|
|
|
302
|
+
/** Convert orphan function_call_output items (no matching function_call) to assistant messages. */
|
|
303
|
+
function fixOrphanOutputs(items: Record<string, unknown>[]): void {
|
|
304
|
+
const callIds = new Set(
|
|
305
|
+
items.filter((i) => i.type === "function_call" && typeof i.call_id === "string").map((i) => i.call_id as string),
|
|
306
|
+
);
|
|
307
|
+
for (let i = 0; i < items.length; i++) {
|
|
308
|
+
const item = items[i]!;
|
|
309
|
+
if (item.type === "function_call_output" && typeof item.call_id === "string" && !callIds.has(item.call_id)) {
|
|
310
|
+
const toolName = typeof item.name === "string" ? (item.name as string) : "tool";
|
|
311
|
+
let text = "";
|
|
312
|
+
try {
|
|
313
|
+
text = typeof item.output === "string" ? (item.output as string) : JSON.stringify(item.output);
|
|
314
|
+
} catch {
|
|
315
|
+
text = String(item.output ?? "");
|
|
316
|
+
}
|
|
317
|
+
if (text.length > 16000) {
|
|
318
|
+
text = `${text.slice(0, 16000)}\n...[truncated]`;
|
|
319
|
+
}
|
|
320
|
+
items[i] = {
|
|
321
|
+
type: "message",
|
|
322
|
+
role: "assistant",
|
|
323
|
+
content: [
|
|
324
|
+
{
|
|
325
|
+
type: "output_text",
|
|
326
|
+
text: `[Previous ${toolName} result; call_id=${item.call_id}]: ${text}`,
|
|
327
|
+
annotations: [],
|
|
328
|
+
},
|
|
329
|
+
],
|
|
330
|
+
status: "completed",
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
254
336
|
/** Extract chatgpt_account_id from JWT, falling back to stored credentials. */
|
|
255
337
|
function getAccountId(accessToken: string, account: number): string | undefined {
|
|
256
338
|
const creds = store.get("codex", account);
|
package/src/server/server.ts
CHANGED
|
@@ -17,6 +17,7 @@ export function startServer(config: ProxyConfig): ReturnType<typeof Bun.serve> {
|
|
|
17
17
|
const server = Bun.serve({
|
|
18
18
|
port: config.port,
|
|
19
19
|
hostname: "localhost",
|
|
20
|
+
idleTimeout: 255, // seconds — LLM streaming responses can take minutes
|
|
20
21
|
|
|
21
22
|
async fetch(req) {
|
|
22
23
|
const startTime = Date.now();
|