glmproxy 2.5.1 → 2.6.0

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.
Files changed (7) hide show
  1. package/README.md +243 -270
  2. package/anthropic.js +734 -734
  3. package/bin/cli.js +406 -406
  4. package/lib/core.js +1454 -1434
  5. package/lib/prompts.js +113 -113
  6. package/openai.js +425 -425
  7. package/package.json +1 -1
package/openai.js CHANGED
@@ -1,425 +1,425 @@
1
- /**
2
- * AutoClaw Proxy — OpenAI-format entrypoint.
3
- *
4
- * Owns ONLY the endpoint surface and wire format:
5
- * POST /v1/chat/completions (+ OpenAI SSE passthrough / non-stream assembly)
6
- * GET /v1/models (OpenAI list shape)
7
- * All shared machinery — config, tokens, catalog, upstream calls, the local
8
- * WebSocket fallback, error classification, logging, server bootstrap — lives
9
- * in lib/core.js.
10
- *
11
- * How auth works: AutoClaw keeps a fresh JWT at
12
- * ~/.openclaw-autoclaw/request-headers.json, auto-refreshed whenever it
13
- * rotates. We read that file on startup and re-read every TOKEN_TTL_MS —
14
- * zero manual auth setup required.
15
- *
16
- * Usage:
17
- * node openai.js
18
- * PORT=3001 PREFER_LOCAL=1 node openai.js
19
- *
20
- * OpenCode / any OpenAI-compatible client:
21
- * baseURL : http://localhost:18791/v1
22
- * apiKey : (value of PROXY_KEY env, default "mewmew")
23
- */
24
-
25
- import {
26
- loadConfig, loadModelCatalog, getModelCatalog, createTokenLayer, createLogger,
27
- createRateLimiter, createRequestLogger, createJsonlLogger,
28
- makeHealthHandler, createGatewayServer, printStartupBanner, installProcessGuards,
29
- sendJSON, sendErrorOpenAI, sendClassifiedErrorOpenAI, resolveClientIp,
30
- readBody, validateChatPayload, generateId,
31
- SSE_HEADERS, validateModelField, lastMessagePreview,
32
- logUpstreamErrorBody, callUpstreamWithInvalidRequestRetry,
33
- callUpstreamOpenAI, streamLocalGatewayAgent, getLocalGatewayToken,
34
- classifyUpstreamError, classifyLocalAgentError, classifyTransportError,
35
- shouldFallbackToLocal, createPermanentFailureCache, getClientHeaders, VERSION,
36
- } from "./lib/core.js";
37
-
38
- // Config
39
- const config = loadConfig({ format: "openai" });
40
- const { log } = createLogger(config.LOG_LEVEL);
41
- const { MODELS } = loadModelCatalog(config);
42
- const { getToken, invalidateToken, startWatch } = createTokenLayer(config, log);
43
- const { rateLimit, startBucketSweep } = createRateLimiter(config.RATE_LIMIT);
44
- const { logRequest } = createRequestLogger(config.REQUEST_LOG_FILE);
45
- const { logJsonl } = createJsonlLogger({ enabled: config.JSONL_LOG, sync: config.JSONL_SYNC, file: config.JSONL_FILE, maxBytes: config.JSONL_MAX_BYTES });
46
-
47
- // Remembers models that failed PERMANENTLY (quota exhausted, unknown id) so
48
- // repeat requests fail instantly instead of replaying doomed attempts.
49
- const permanentFailures = createPermanentFailureCache();
50
-
51
- // A rotated token can also mean un-quota'd state changed — drop both caches.
52
- function invalidateAuth() {
53
- invalidateToken();
54
- permanentFailures.clear();
55
- }
56
-
57
- startWatch();
58
- startBucketSweep();
59
-
60
- // SSE buffering (OpenAI-specific: assemble streamed chunks into a single response)
61
- function bufferSSE(upstreamRes, modelId) {
62
- return new Promise((resolve, reject) => {
63
- let raw = "";
64
- upstreamRes.on("data", (c) => (raw += c));
65
- upstreamRes.on("error", reject);
66
- upstreamRes.on("end", () => {
67
- try {
68
- let content = "", reasoning = "";
69
- let id = `chatcmpl-${generateId()}`;
70
- let model = modelId;
71
- let promptTokens = 0, completionTokens = 0;
72
- let finishReason = "stop";
73
- const toolCalls = {};
74
-
75
- for (const line of raw.split("\n")) {
76
- if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
77
- const chunk = JSON.parse(line.slice(6));
78
- if (chunk.id) id = chunk.id;
79
- if (chunk.model) model = chunk.model;
80
- const delta = chunk.choices?.[0]?.delta;
81
- if (delta?.content) content += delta.content;
82
- if (delta?.reasoning_content) reasoning += delta.reasoning_content;
83
- // Accumulate tool calls
84
- for (const tc of delta?.tool_calls || []) {
85
- if (!toolCalls[tc.index]) toolCalls[tc.index] = { id: "", name: "", arguments: "" };
86
- if (tc.id) toolCalls[tc.index].id = tc.id;
87
- if (tc.function?.name) toolCalls[tc.index].name = tc.function.name;
88
- if (tc.function?.arguments) toolCalls[tc.index].arguments += tc.function.arguments;
89
- }
90
- const fr = chunk.choices?.[0]?.finish_reason;
91
- if (fr) finishReason = fr;
92
- if (chunk.usage) {
93
- promptTokens = chunk.usage.prompt_tokens ?? 0;
94
- completionTokens = chunk.usage.completion_tokens ?? 0;
95
- }
96
- }
97
-
98
- // Build sorted tool_calls array
99
- const sortedToolCalls = Object.keys(toolCalls)
100
- .sort((a, b) => Number(a) - Number(b))
101
- .map((idx) => ({
102
- id: toolCalls[idx].id,
103
- type: "function",
104
- function: { name: toolCalls[idx].name, arguments: toolCalls[idx].arguments },
105
- }));
106
-
107
- resolve({
108
- id,
109
- object: "chat.completion",
110
- created: Math.floor(Date.now() / 1000),
111
- model,
112
- choices: [{
113
- index: 0,
114
- message: {
115
- role: "assistant",
116
- content,
117
- ...(reasoning ? { reasoning_content: reasoning } : {}),
118
- ...(sortedToolCalls.length ? { tool_calls: sortedToolCalls } : {}),
119
- },
120
- finish_reason: finishReason,
121
- }],
122
- usage: {
123
- prompt_tokens: promptTokens,
124
- completion_tokens: completionTokens,
125
- total_tokens: promptTokens + completionTokens,
126
- },
127
- });
128
- } catch (err) {
129
- reject(new Error(`Failed to parse upstream SSE: ${err.message}`));
130
- }
131
- });
132
- });
133
- }
134
-
135
- // Routes
136
-
137
- function handleModels(req, res) {
138
- const { models } = getModelCatalog(config);
139
- sendJSON(res, {
140
- object: "list",
141
- data: models.map((m) => ({
142
- id: m.id,
143
- object: "model",
144
- created: Math.floor(Date.now() / 1000),
145
- owned_by: "autoclaw",
146
- name: m.name,
147
- description: m.name,
148
- context_window: m.contextWindow,
149
- max_tokens: m.maxTokens,
150
- })),
151
- });
152
- }
153
-
154
- async function handleChatCompletions(req, res) {
155
- const startTime = Date.now();
156
- const clientIp = resolveClientIp(req);
157
-
158
- // Exactly one observability entry per request, written at the terminal
159
- // outcome — cloud-served AND local-agent-served alike (`via` marks which).
160
- // Optional cloud_status/cloud_error carry the rejected cloud attempt's
161
- // evidence when fallback ended up serving the request.
162
- let recorded = false;
163
- function record(status, { model = null, lastMessage = null, messageCount = 0, error, via = "cloud", cloud_status, cloud_error } = {}) {
164
- if (recorded) return;
165
- recorded = true;
166
- if (model) {
167
- logRequest({
168
- timestamp: new Date().toISOString(),
169
- model, status, via,
170
- last_message: typeof lastMessage === "string"
171
- ? lastMessage.substring(0, 300)
172
- : JSON.stringify(lastMessage)?.substring(0, 300) ?? "",
173
- ...(messageCount ? { message_count: messageCount } : {}),
174
- ...(error ? { error } : {}),
175
- ...(cloud_status ? { cloud_status, ...(cloud_error ? { cloud_error } : {}) } : {}),
176
- });
177
- }
178
- logJsonl({ model, status, ip: clientIp, latencyMs: Date.now() - startTime, ...(via !== "cloud" ? { via } : {}), ...(error ? { error } : {}) });
179
- }
180
-
181
- // R1: never let an upstream rejection pass without its body on record —
182
- // (logUpstreamErrorBody lives in lib/core.js — shared with anthropic.js)
183
-
184
- let body;
185
- try {
186
- body = await readBody(req, config.MAX_BODY_BYTES);
187
- } catch (err) {
188
- record(err.statusCode || 400, { error: "invalid_request" });
189
- return sendErrorOpenAI(res, err.message, "invalid_request_error", err.statusCode || 400, "invalid_request");
190
- }
191
-
192
- // Input validation — model field first (it drives everything downstream)
193
- const modelFieldError = validateModelField(body);
194
- if (modelFieldError) {
195
- record(400, { error: "invalid_request" });
196
- return sendErrorOpenAI(res, modelFieldError.message, modelFieldError.type, modelFieldError.status, modelFieldError.code);
197
- }
198
- const payloadError = validateChatPayload(body, config.MAX_MESSAGES);
199
- if (payloadError) {
200
- record(payloadError.statusCode, { model: body.model, error: "payload_too_large" });
201
- return sendErrorOpenAI(res, payloadError.message, "invalid_request_error", payloadError.statusCode, "invalid_payload");
202
- }
203
-
204
- const modelId = body.model;
205
- const stream = body.stream !== false; // default true
206
- const { models } = getModelCatalog(config);
207
- const knownIds = new Set(models.map((m) => m.id));
208
-
209
- log.info(`chat model=${modelId} stream=${stream}`);
210
-
211
- const lastMsgForLog = () => lastMessagePreview(body.messages);
212
-
213
- // Cloud-attempt evidence (status + classifier code) set when the cloud
214
- // rejected this request before fallback ran; consumed by record() so the
215
- // terminal ring entry carries the full story.
216
- let cloudEvidence = null;
217
-
218
- // Local AutoClaw WebSocket agent fallback. Returns true when the response
219
- // was fully handled here (success OR terminal error), false when the local
220
- // gateway is simply unavailable.
221
- const tryLocalAgent = () => {
222
- if (!getLocalGatewayToken()) return Promise.resolve(false);
223
- log.info(`Executing chat model=${modelId} via local AutoClaw WebSocket agent...`);
224
- return new Promise((resolve) => {
225
- let fullContent = "";
226
- let streamedHeader = false;
227
- const startedAt = Date.now();
228
-
229
- streamLocalGatewayAgent({
230
- config,
231
- modelId,
232
- messages: body.messages,
233
- onChunk: ({ delta }) => {
234
- if (stream) {
235
- if (!streamedHeader) {
236
- streamedHeader = true;
237
- res.writeHead(200, SSE_HEADERS);
238
- }
239
- const chunk = JSON.stringify({
240
- id: `chatcmpl-${generateId()}`,
241
- object: "chat.completion.chunk",
242
- created: Math.floor(Date.now() / 1000),
243
- model: modelId,
244
- choices: [{ index: 0, delta: { role: "assistant", content: delta }, finish_reason: null }],
245
- });
246
- res.write(`data: ${chunk}\n\n`);
247
- } else {
248
- fullContent += delta;
249
- }
250
- },
251
- onEnd: ({ finishReason }) => {
252
- if (stream) {
253
- const finalChunk = JSON.stringify({
254
- id: `chatcmpl-${generateId()}`,
255
- object: "chat.completion.chunk",
256
- created: Math.floor(Date.now() / 1000),
257
- model: modelId,
258
- choices: [{ index: 0, delta: {}, finish_reason: finishReason || "stop" }],
259
- });
260
- res.end(`data: ${finalChunk}\n\ndata: [DONE]\n\n`);
261
- } else {
262
- sendJSON(res, {
263
- id: `chatcmpl-${generateId()}`,
264
- object: "chat.completion",
265
- created: Math.floor(Date.now() / 1000),
266
- model: modelId,
267
- choices: [{ index: 0, message: { role: "assistant", content: fullContent }, finish_reason: finishReason || "stop" }],
268
- usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
269
- });
270
- }
271
- log.info(`chat model=${modelId} served via local agent (${Date.now() - startedAt}ms)`);
272
- record(200, {
273
- model: modelId, lastMessage: fullContent, messageCount: body.messages?.length || 0, via: "local",
274
- ...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}),
275
- });
276
- resolve(true);
277
- },
278
- onError: (err) => {
279
- log.warn(`Local gateway execution failed: ${err.message}`);
280
- const cls = classifyLocalAgentError(err, modelId);
281
- permanentFailures.mark(modelId, cls);
282
- if (res.headersSent) {
283
- // SSE already went out with 200 — a JSON 502 cannot follow.
284
- // Terminate the stream instead of throwing ERR_HTTP_HEADERS_SENT.
285
- try { res.end(); } catch (_) {}
286
- record(cls.status, { model: modelId, error: `${cls.code} (mid-stream)`, via: "local", ...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}) });
287
- } else {
288
- record(cls.status, {
289
- model: modelId, error: cls.code, via: "local",
290
- ...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}),
291
- });
292
- sendClassifiedErrorOpenAI(res, cls);
293
- }
294
- resolve(true);
295
- },
296
- });
297
- });
298
- };
299
-
300
- // Terminal success handling shared by first-attempt and retried responses.
301
- async function respondSuccess(successRes) {
302
- record(successRes.statusCode, { model: modelId, lastMessage: lastMsgForLog(), messageCount: body.messages?.length || 0 });
303
- log.debug(`← upstream status=${successRes.statusCode}`);
304
-
305
- if (stream) {
306
- res.writeHead(200, SSE_HEADERS);
307
- successRes.pipe(res);
308
- return;
309
- }
310
-
311
- // Non-stream: buffer SSE, assemble full response object
312
- try {
313
- sendJSON(res, await bufferSSE(successRes, modelId));
314
- } catch (err) {
315
- if (!res.headersSent) sendErrorOpenAI(res, err.message, "api_error", 502, "upstream_parse_failed");
316
- else { try { res.end(); } catch (_) {} }
317
- }
318
- }
319
-
320
- try {
321
- // PREFER_LOCAL=1: skip the cloud attempt entirely when the desktop
322
- // gateway is up — saves doomed round-trips while credits are exhausted.
323
- if (config.PREFER_LOCAL && getLocalGatewayToken()) {
324
- if (await tryLocalAgent()) return;
325
- }
326
-
327
- // Known-permanent failure within the TTL → answer instantly, identically.
328
- const cachedFailure = permanentFailures.get(modelId);
329
- if (cachedFailure) {
330
- log.info(`chat model=${modelId} short-circuited: ${cachedFailure.code} (recently confirmed)`);
331
- record(cachedFailure.status, { model: modelId, error: cachedFailure.code });
332
- return sendClassifiedErrorOpenAI(res, cachedFailure);
333
- }
334
-
335
- // Cloud call with one retry on the flaky 400 "invalid request" hiccup;
336
- // every >=400 body is buffered + logged (R1). Shared with anthropic.js.
337
- const { res: upstreamRes, errBody: upstreamErrBody } = await callUpstreamWithInvalidRequestRetry(
338
- () => callUpstreamOpenAI(config, knownIds, getClientHeaders(config), getToken, body, modelId, log),
339
- modelId, permanentFailures, log,
340
- );
341
-
342
- const effectiveStatus = upstreamRes.statusCode;
343
-
344
- if (effectiveStatus < 400) return respondSuccess(upstreamRes);
345
-
346
- // Rotate-out token caches BEFORE deciding fallback so the very next
347
- // request picks up the fresh JWT regardless of who serves this one.
348
- if (effectiveStatus === 401) invalidateAuth();
349
-
350
- if (shouldFallbackToLocal(effectiveStatus)) {
351
- const cls = classifyUpstreamError(effectiveStatus, upstreamErrBody, modelId);
352
- if (cls.permanent) permanentFailures.mark(modelId, cls);
353
- log.error(`Upstream error ${effectiveStatus}:`, cls.message);
354
- cloudEvidence = { status: effectiveStatus, code: cls.code };
355
-
356
- // The desktop gateway shares this AutoClaw account — a quota/plan wall
357
- // stops it too, so don't march a known-permanent failure into it.
358
- if (!cls.permanent || !permanentFailures.get(modelId)) {
359
- if (await tryLocalAgent()) return;
360
- } else {
361
- log.info(`Skipping local fallback for ${modelId}: ${cls.code} is account-wide`);
362
- }
363
-
364
- record(cls.status, {
365
- model: modelId, lastMessage: lastMsgForLog(), messageCount: body.messages?.length || 0,
366
- error: cls.code,
367
- // cloud evidence rides along on the terminal entry — the test CLI
368
- // renders [cloud NNN → local agent] from these fields
369
- ...(effectiveStatus !== cls.status ? { cloud_status: effectiveStatus, cloud_error: cls.code } : {}),
370
- });
371
- return sendClassifiedErrorOpenAI(res, cls);
372
- }
373
-
374
- return respondSuccess(upstreamRes);
375
- } catch (err) {
376
- // Transport-level failure (no HTTP response at all): dead token, connect
377
- // reset, upstream timeout…
378
- const cls = classifyTransportError(err);
379
- log.error(`chat model=${modelId} transport failure:`, cls.message);
380
- if (!res.headersSent && shouldFallbackToLocal(cls.status)) {
381
- if (await tryLocalAgent()) return;
382
- }
383
- if (res.headersSent) { try { res.end(); } catch (_) {} return; }
384
- record(cls.status, { model: modelId, lastMessage: lastMsgForLog(), messageCount: body.messages?.length || 0, error: cls.code });
385
- return sendClassifiedErrorOpenAI(res, cls);
386
- }
387
- }
388
-
389
- // Server
390
-
391
- const server = createGatewayServer({
392
- config, log, rateLimit,
393
- sendError: sendErrorOpenAI,
394
- routes: [
395
- { method: "GET", path: "/healthz", handler: makeHealthHandler(config, getToken) },
396
- { method: "GET", path: "/v1/models", handler: handleModels },
397
- { method: "POST", path: "/v1/chat/completions", handler: handleChatCompletions },
398
- ],
399
- });
400
-
401
- installProcessGuards(log);
402
-
403
- server.listen(config.PORT, config.HOST, () => {
404
- printStartupBanner({
405
- title: `🛸 AUTOCLAW GATEWAY PROXY (OpenAI Format v${VERSION})`,
406
- rows: [
407
- `Host : ${config.HOST}`,
408
- `Port : ${config.PORT}`,
409
- `Auth Key : ${config.PROXY_KEY}`,
410
- `Rate Lim : ${config.RATE_LIMIT} req/s per IP`,
411
- `Max Msgs : ${Number.isFinite(config.MAX_MESSAGES) ? `${config.MAX_MESSAGES} entries` : "unlimited"}`,
412
- `Models : ${MODELS.map(m => m.id).join(", ")}`,
413
- "",
414
- "OpenCode / OpenAI SDK Base URL:",
415
- `http://${config.HOST}:${config.PORT}/v1`,
416
- ],
417
- });
418
-
419
- try {
420
- getToken();
421
- console.log(" ✅ Token loaded — ready\n");
422
- } catch (e) {
423
- console.warn(` ⚠️ ${e.message}\n`);
424
- }
425
- });
1
+ /**
2
+ * AutoClaw Proxy — OpenAI-format entrypoint.
3
+ *
4
+ * Owns ONLY the endpoint surface and wire format:
5
+ * POST /v1/chat/completions (+ OpenAI SSE passthrough / non-stream assembly)
6
+ * GET /v1/models (OpenAI list shape)
7
+ * All shared machinery — config, tokens, catalog, upstream calls, the local
8
+ * WebSocket fallback, error classification, logging, server bootstrap — lives
9
+ * in lib/core.js.
10
+ *
11
+ * How auth works: AutoClaw keeps a fresh JWT at
12
+ * ~/.openclaw-autoclaw/request-headers.json, auto-refreshed whenever it
13
+ * rotates. We read that file on startup and re-read every TOKEN_TTL_MS —
14
+ * zero manual auth setup required.
15
+ *
16
+ * Usage:
17
+ * node openai.js
18
+ * PORT=3001 PREFER_LOCAL=1 node openai.js
19
+ *
20
+ * OpenCode / any OpenAI-compatible client:
21
+ * baseURL : http://localhost:18791/v1
22
+ * apiKey : (value of PROXY_KEY env, default "mewmew")
23
+ */
24
+
25
+ import {
26
+ loadConfig, loadModelCatalog, getModelCatalog, createTokenLayer, createLogger,
27
+ createRateLimiter, createRequestLogger, createJsonlLogger,
28
+ makeHealthHandler, createGatewayServer, printStartupBanner, installProcessGuards,
29
+ sendJSON, sendErrorOpenAI, sendClassifiedErrorOpenAI, resolveClientIp,
30
+ readBody, validateChatPayload, generateId,
31
+ SSE_HEADERS, validateModelField, lastMessagePreview,
32
+ logUpstreamErrorBody, callUpstreamWithInvalidRequestRetry,
33
+ callUpstreamOpenAI, streamLocalGatewayAgent, getLocalGatewayToken,
34
+ classifyUpstreamError, classifyLocalAgentError, classifyTransportError,
35
+ shouldFallbackToLocal, createPermanentFailureCache, getClientHeaders, VERSION,
36
+ } from "./lib/core.js";
37
+
38
+ // Config
39
+ const config = loadConfig({ format: "openai" });
40
+ const { log } = createLogger(config.LOG_LEVEL);
41
+ const { MODELS } = loadModelCatalog(config);
42
+ const { getToken, invalidateToken, startWatch } = createTokenLayer(config, log);
43
+ const { rateLimit, startBucketSweep } = createRateLimiter(config.RATE_LIMIT);
44
+ const { logRequest } = createRequestLogger(config.REQUEST_LOG_FILE);
45
+ const { logJsonl } = createJsonlLogger({ enabled: config.JSONL_LOG, sync: config.JSONL_SYNC, file: config.JSONL_FILE, maxBytes: config.JSONL_MAX_BYTES });
46
+
47
+ // Remembers models that failed PERMANENTLY (quota exhausted, unknown id) so
48
+ // repeat requests fail instantly instead of replaying doomed attempts.
49
+ const permanentFailures = createPermanentFailureCache();
50
+
51
+ // A rotated token can also mean un-quota'd state changed — drop both caches.
52
+ function invalidateAuth() {
53
+ invalidateToken();
54
+ permanentFailures.clear();
55
+ }
56
+
57
+ startWatch();
58
+ startBucketSweep();
59
+
60
+ // SSE buffering (OpenAI-specific: assemble streamed chunks into a single response)
61
+ function bufferSSE(upstreamRes, modelId) {
62
+ return new Promise((resolve, reject) => {
63
+ let raw = "";
64
+ upstreamRes.on("data", (c) => (raw += c));
65
+ upstreamRes.on("error", reject);
66
+ upstreamRes.on("end", () => {
67
+ try {
68
+ let content = "", reasoning = "";
69
+ let id = `chatcmpl-${generateId()}`;
70
+ let model = modelId;
71
+ let promptTokens = 0, completionTokens = 0;
72
+ let finishReason = "stop";
73
+ const toolCalls = {};
74
+
75
+ for (const line of raw.split("\n")) {
76
+ if (!line.startsWith("data: ") || line === "data: [DONE]") continue;
77
+ const chunk = JSON.parse(line.slice(6));
78
+ if (chunk.id) id = chunk.id;
79
+ if (chunk.model) model = chunk.model;
80
+ const delta = chunk.choices?.[0]?.delta;
81
+ if (delta?.content) content += delta.content;
82
+ if (delta?.reasoning_content) reasoning += delta.reasoning_content;
83
+ // Accumulate tool calls
84
+ for (const tc of delta?.tool_calls || []) {
85
+ if (!toolCalls[tc.index]) toolCalls[tc.index] = { id: "", name: "", arguments: "" };
86
+ if (tc.id) toolCalls[tc.index].id = tc.id;
87
+ if (tc.function?.name) toolCalls[tc.index].name = tc.function.name;
88
+ if (tc.function?.arguments) toolCalls[tc.index].arguments += tc.function.arguments;
89
+ }
90
+ const fr = chunk.choices?.[0]?.finish_reason;
91
+ if (fr) finishReason = fr;
92
+ if (chunk.usage) {
93
+ promptTokens = chunk.usage.prompt_tokens ?? 0;
94
+ completionTokens = chunk.usage.completion_tokens ?? 0;
95
+ }
96
+ }
97
+
98
+ // Build sorted tool_calls array
99
+ const sortedToolCalls = Object.keys(toolCalls)
100
+ .sort((a, b) => Number(a) - Number(b))
101
+ .map((idx) => ({
102
+ id: toolCalls[idx].id,
103
+ type: "function",
104
+ function: { name: toolCalls[idx].name, arguments: toolCalls[idx].arguments },
105
+ }));
106
+
107
+ resolve({
108
+ id,
109
+ object: "chat.completion",
110
+ created: Math.floor(Date.now() / 1000),
111
+ model,
112
+ choices: [{
113
+ index: 0,
114
+ message: {
115
+ role: "assistant",
116
+ content,
117
+ ...(reasoning ? { reasoning_content: reasoning } : {}),
118
+ ...(sortedToolCalls.length ? { tool_calls: sortedToolCalls } : {}),
119
+ },
120
+ finish_reason: finishReason,
121
+ }],
122
+ usage: {
123
+ prompt_tokens: promptTokens,
124
+ completion_tokens: completionTokens,
125
+ total_tokens: promptTokens + completionTokens,
126
+ },
127
+ });
128
+ } catch (err) {
129
+ reject(new Error(`Failed to parse upstream SSE: ${err.message}`));
130
+ }
131
+ });
132
+ });
133
+ }
134
+
135
+ // Routes
136
+
137
+ function handleModels(req, res) {
138
+ const { models } = getModelCatalog(config);
139
+ sendJSON(res, {
140
+ object: "list",
141
+ data: models.map((m) => ({
142
+ id: m.id,
143
+ object: "model",
144
+ created: Math.floor(Date.now() / 1000),
145
+ owned_by: "autoclaw",
146
+ name: m.name,
147
+ description: m.name,
148
+ context_window: m.contextWindow,
149
+ max_tokens: m.maxTokens,
150
+ })),
151
+ });
152
+ }
153
+
154
+ async function handleChatCompletions(req, res) {
155
+ const startTime = Date.now();
156
+ const clientIp = resolveClientIp(req);
157
+
158
+ // Exactly one observability entry per request, written at the terminal
159
+ // outcome — cloud-served AND local-agent-served alike (`via` marks which).
160
+ // Optional cloud_status/cloud_error carry the rejected cloud attempt's
161
+ // evidence when fallback ended up serving the request.
162
+ let recorded = false;
163
+ function record(status, { model = null, lastMessage = null, messageCount = 0, error, via = "cloud", cloud_status, cloud_error } = {}) {
164
+ if (recorded) return;
165
+ recorded = true;
166
+ if (model) {
167
+ logRequest({
168
+ timestamp: new Date().toISOString(),
169
+ model, status, via,
170
+ last_message: typeof lastMessage === "string"
171
+ ? lastMessage.substring(0, 300)
172
+ : JSON.stringify(lastMessage)?.substring(0, 300) ?? "",
173
+ ...(messageCount ? { message_count: messageCount } : {}),
174
+ ...(error ? { error } : {}),
175
+ ...(cloud_status ? { cloud_status, ...(cloud_error ? { cloud_error } : {}) } : {}),
176
+ });
177
+ }
178
+ logJsonl({ model, status, ip: clientIp, latencyMs: Date.now() - startTime, ...(via !== "cloud" ? { via } : {}), ...(error ? { error } : {}) });
179
+ }
180
+
181
+ // R1: never let an upstream rejection pass without its body on record —
182
+ // (logUpstreamErrorBody lives in lib/core.js — shared with anthropic.js)
183
+
184
+ let body;
185
+ try {
186
+ body = await readBody(req, config.MAX_BODY_BYTES);
187
+ } catch (err) {
188
+ record(err.statusCode || 400, { error: "invalid_request" });
189
+ return sendErrorOpenAI(res, err.message, "invalid_request_error", err.statusCode || 400, "invalid_request");
190
+ }
191
+
192
+ // Input validation — model field first (it drives everything downstream)
193
+ const modelFieldError = validateModelField(body);
194
+ if (modelFieldError) {
195
+ record(400, { error: "invalid_request" });
196
+ return sendErrorOpenAI(res, modelFieldError.message, modelFieldError.type, modelFieldError.status, modelFieldError.code);
197
+ }
198
+ const payloadError = validateChatPayload(body, config.MAX_MESSAGES);
199
+ if (payloadError) {
200
+ record(payloadError.statusCode, { model: body.model, error: "payload_too_large" });
201
+ return sendErrorOpenAI(res, payloadError.message, "invalid_request_error", payloadError.statusCode, "invalid_payload");
202
+ }
203
+
204
+ const modelId = body.model;
205
+ const stream = body.stream !== false; // default true
206
+ const { models } = getModelCatalog(config);
207
+ const knownIds = new Set(models.map((m) => m.id));
208
+
209
+ log.info(`chat model=${modelId} stream=${stream}`);
210
+
211
+ const lastMsgForLog = () => lastMessagePreview(body.messages);
212
+
213
+ // Cloud-attempt evidence (status + classifier code) set when the cloud
214
+ // rejected this request before fallback ran; consumed by record() so the
215
+ // terminal ring entry carries the full story.
216
+ let cloudEvidence = null;
217
+
218
+ // Local AutoClaw WebSocket agent fallback. Returns true when the response
219
+ // was fully handled here (success OR terminal error), false when the local
220
+ // gateway is simply unavailable.
221
+ const tryLocalAgent = () => {
222
+ if (!getLocalGatewayToken()) return Promise.resolve(false);
223
+ log.info(`Executing chat model=${modelId} via local AutoClaw WebSocket agent...`);
224
+ return new Promise((resolve) => {
225
+ let fullContent = "";
226
+ let streamedHeader = false;
227
+ const startedAt = Date.now();
228
+
229
+ streamLocalGatewayAgent({
230
+ config,
231
+ modelId,
232
+ messages: body.messages,
233
+ onChunk: ({ delta }) => {
234
+ if (stream) {
235
+ if (!streamedHeader) {
236
+ streamedHeader = true;
237
+ res.writeHead(200, SSE_HEADERS);
238
+ }
239
+ const chunk = JSON.stringify({
240
+ id: `chatcmpl-${generateId()}`,
241
+ object: "chat.completion.chunk",
242
+ created: Math.floor(Date.now() / 1000),
243
+ model: modelId,
244
+ choices: [{ index: 0, delta: { role: "assistant", content: delta }, finish_reason: null }],
245
+ });
246
+ res.write(`data: ${chunk}\n\n`);
247
+ } else {
248
+ fullContent += delta;
249
+ }
250
+ },
251
+ onEnd: ({ finishReason }) => {
252
+ if (stream) {
253
+ const finalChunk = JSON.stringify({
254
+ id: `chatcmpl-${generateId()}`,
255
+ object: "chat.completion.chunk",
256
+ created: Math.floor(Date.now() / 1000),
257
+ model: modelId,
258
+ choices: [{ index: 0, delta: {}, finish_reason: finishReason || "stop" }],
259
+ });
260
+ res.end(`data: ${finalChunk}\n\ndata: [DONE]\n\n`);
261
+ } else {
262
+ sendJSON(res, {
263
+ id: `chatcmpl-${generateId()}`,
264
+ object: "chat.completion",
265
+ created: Math.floor(Date.now() / 1000),
266
+ model: modelId,
267
+ choices: [{ index: 0, message: { role: "assistant", content: fullContent }, finish_reason: finishReason || "stop" }],
268
+ usage: { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 },
269
+ });
270
+ }
271
+ log.info(`chat model=${modelId} served via local agent (${Date.now() - startedAt}ms)`);
272
+ record(200, {
273
+ model: modelId, lastMessage: fullContent, messageCount: body.messages?.length || 0, via: "local",
274
+ ...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}),
275
+ });
276
+ resolve(true);
277
+ },
278
+ onError: (err) => {
279
+ log.warn(`Local gateway execution failed: ${err.message}`);
280
+ const cls = classifyLocalAgentError(err, modelId);
281
+ permanentFailures.mark(modelId, cls);
282
+ if (res.headersSent) {
283
+ // SSE already went out with 200 — a JSON 502 cannot follow.
284
+ // Terminate the stream instead of throwing ERR_HTTP_HEADERS_SENT.
285
+ try { res.end(); } catch (_) {}
286
+ record(cls.status, { model: modelId, error: `${cls.code} (mid-stream)`, via: "local", ...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}) });
287
+ } else {
288
+ record(cls.status, {
289
+ model: modelId, error: cls.code, via: "local",
290
+ ...(cloudEvidence ? { cloud_status: cloudEvidence.status, cloud_error: cloudEvidence.code } : {}),
291
+ });
292
+ sendClassifiedErrorOpenAI(res, cls);
293
+ }
294
+ resolve(true);
295
+ },
296
+ });
297
+ });
298
+ };
299
+
300
+ // Terminal success handling shared by first-attempt and retried responses.
301
+ async function respondSuccess(successRes) {
302
+ record(successRes.statusCode, { model: modelId, lastMessage: lastMsgForLog(), messageCount: body.messages?.length || 0 });
303
+ log.debug(`← upstream status=${successRes.statusCode}`);
304
+
305
+ if (stream) {
306
+ res.writeHead(200, SSE_HEADERS);
307
+ successRes.pipe(res);
308
+ return;
309
+ }
310
+
311
+ // Non-stream: buffer SSE, assemble full response object
312
+ try {
313
+ sendJSON(res, await bufferSSE(successRes, modelId));
314
+ } catch (err) {
315
+ if (!res.headersSent) sendErrorOpenAI(res, err.message, "api_error", 502, "upstream_parse_failed");
316
+ else { try { res.end(); } catch (_) {} }
317
+ }
318
+ }
319
+
320
+ try {
321
+ // PREFER_LOCAL=1: skip the cloud attempt entirely when the desktop
322
+ // gateway is up — saves doomed round-trips while credits are exhausted.
323
+ if (config.PREFER_LOCAL && getLocalGatewayToken()) {
324
+ if (await tryLocalAgent()) return;
325
+ }
326
+
327
+ // Known-permanent failure within the TTL → answer instantly, identically.
328
+ const cachedFailure = permanentFailures.get(modelId);
329
+ if (cachedFailure) {
330
+ log.info(`chat model=${modelId} short-circuited: ${cachedFailure.code} (recently confirmed)`);
331
+ record(cachedFailure.status, { model: modelId, error: cachedFailure.code });
332
+ return sendClassifiedErrorOpenAI(res, cachedFailure);
333
+ }
334
+
335
+ // Cloud call with one retry on the flaky 400 "invalid request" hiccup;
336
+ // every >=400 body is buffered + logged (R1). Shared with anthropic.js.
337
+ const { res: upstreamRes, errBody: upstreamErrBody } = await callUpstreamWithInvalidRequestRetry(
338
+ () => callUpstreamOpenAI(config, knownIds, getClientHeaders(config), getToken, body, modelId, log),
339
+ modelId, permanentFailures, log,
340
+ );
341
+
342
+ const effectiveStatus = upstreamRes.statusCode;
343
+
344
+ if (effectiveStatus < 400) return respondSuccess(upstreamRes);
345
+
346
+ // Rotate-out token caches BEFORE deciding fallback so the very next
347
+ // request picks up the fresh JWT regardless of who serves this one.
348
+ if (effectiveStatus === 401) invalidateAuth();
349
+
350
+ if (shouldFallbackToLocal(effectiveStatus)) {
351
+ const cls = classifyUpstreamError(effectiveStatus, upstreamErrBody, modelId);
352
+ if (cls.permanent) permanentFailures.mark(modelId, cls);
353
+ log.error(`Upstream error ${effectiveStatus}:`, cls.message);
354
+ cloudEvidence = { status: effectiveStatus, code: cls.code };
355
+
356
+ // The desktop gateway shares this AutoClaw account — a quota/plan wall
357
+ // stops it too, so don't march a known-permanent failure into it.
358
+ if (!cls.permanent || !permanentFailures.get(modelId)) {
359
+ if (await tryLocalAgent()) return;
360
+ } else {
361
+ log.info(`Skipping local fallback for ${modelId}: ${cls.code} is account-wide`);
362
+ }
363
+
364
+ record(cls.status, {
365
+ model: modelId, lastMessage: lastMsgForLog(), messageCount: body.messages?.length || 0,
366
+ error: cls.code,
367
+ // cloud evidence rides along on the terminal entry — the test CLI
368
+ // renders [cloud NNN → local agent] from these fields
369
+ ...(effectiveStatus !== cls.status ? { cloud_status: effectiveStatus, cloud_error: cls.code } : {}),
370
+ });
371
+ return sendClassifiedErrorOpenAI(res, cls);
372
+ }
373
+
374
+ return respondSuccess(upstreamRes);
375
+ } catch (err) {
376
+ // Transport-level failure (no HTTP response at all): dead token, connect
377
+ // reset, upstream timeout…
378
+ const cls = classifyTransportError(err);
379
+ log.error(`chat model=${modelId} transport failure:`, cls.message);
380
+ if (!res.headersSent && shouldFallbackToLocal(cls.status)) {
381
+ if (await tryLocalAgent()) return;
382
+ }
383
+ if (res.headersSent) { try { res.end(); } catch (_) {} return; }
384
+ record(cls.status, { model: modelId, lastMessage: lastMsgForLog(), messageCount: body.messages?.length || 0, error: cls.code });
385
+ return sendClassifiedErrorOpenAI(res, cls);
386
+ }
387
+ }
388
+
389
+ // Server
390
+
391
+ const server = createGatewayServer({
392
+ config, log, rateLimit,
393
+ sendError: sendErrorOpenAI,
394
+ routes: [
395
+ { method: "GET", path: "/healthz", handler: makeHealthHandler(config, getToken) },
396
+ { method: "GET", path: "/v1/models", handler: handleModels },
397
+ { method: "POST", path: "/v1/chat/completions", handler: handleChatCompletions },
398
+ ],
399
+ });
400
+
401
+ installProcessGuards(log);
402
+
403
+ server.listen(config.PORT, config.HOST, () => {
404
+ printStartupBanner({
405
+ title: `🛸 AUTOCLAW GATEWAY PROXY (OpenAI Format v${VERSION})`,
406
+ rows: [
407
+ `Host : ${config.HOST}`,
408
+ `Port : ${config.PORT}`,
409
+ `Auth Key : ${config.PROXY_KEY}`,
410
+ `Rate Lim : ${config.RATE_LIMIT} req/s per IP`,
411
+ `Max Msgs : ${Number.isFinite(config.MAX_MESSAGES) ? `${config.MAX_MESSAGES} entries` : "unlimited"}`,
412
+ `Models : ${MODELS.map(m => m.id).join(", ")}`,
413
+ "",
414
+ "OpenCode / OpenAI SDK Base URL:",
415
+ `http://${config.HOST}:${config.PORT}/v1`,
416
+ ],
417
+ });
418
+
419
+ try {
420
+ getToken();
421
+ console.log(" ✅ Token loaded — ready\n");
422
+ } catch (e) {
423
+ console.warn(` ⚠️ ${e.message}\n`);
424
+ }
425
+ });