ai-zero-token 2.0.3 → 2.0.4

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.
@@ -11,6 +11,7 @@ import { requestText } from "../core/providers/http-client.js";
11
11
  const packageRoot = path.dirname(fileURLToPath(new URL("../../package.json", import.meta.url)));
12
12
  const adminUiDistDir = path.join(packageRoot, "admin-ui", "dist");
13
13
  const adminUiIndexPath = path.join(adminUiDistDir, "index.html");
14
+ const MAX_GATEWAY_REQUEST_LOGS = 100;
14
15
  const assetContentTypes = {
15
16
  ".css": "text/css; charset=utf-8",
16
17
  ".gif": "image/gif",
@@ -198,13 +199,6 @@ const imageEditsBodySchema = z.object({
198
199
  response_format: z.enum(["b64_json", "url"]).optional(),
199
200
  user: z.string().optional()
200
201
  }).passthrough();
201
- const chatCompletionExcludedKeys = /* @__PURE__ */ new Set([
202
- "messages",
203
- "n",
204
- "stream",
205
- "max_tokens",
206
- "max_completion_tokens"
207
- ]);
208
202
  function extractTextInput(input) {
209
203
  if (typeof input === "undefined") {
210
204
  return "";
@@ -242,7 +236,20 @@ function normalizeChatRole(role) {
242
236
  }
243
237
  return role ?? "user";
244
238
  }
245
- function normalizeChatContentPart(part) {
239
+ function safeJsonStringify(value) {
240
+ if (typeof value === "string") {
241
+ return value;
242
+ }
243
+ if (typeof value === "undefined" || value === null) {
244
+ return "";
245
+ }
246
+ try {
247
+ return JSON.stringify(value);
248
+ } catch {
249
+ return String(value);
250
+ }
251
+ }
252
+ function normalizeChatContentPart(part, textType) {
246
253
  if (part.type === "image_url") {
247
254
  const url = typeof part.image_url === "string" ? part.image_url : part.image_url?.url;
248
255
  if (!url) {
@@ -258,37 +265,220 @@ function normalizeChatContentPart(part) {
258
265
  }
259
266
  const text = typeof part.text === "string" ? part.text : "";
260
267
  return {
261
- type: "input_text",
268
+ type: textType,
262
269
  text
263
270
  };
264
271
  }
265
- function normalizeChatContent(content) {
272
+ function normalizeChatContent(content, role) {
273
+ const textType = role === "assistant" ? "output_text" : "input_text";
266
274
  if (typeof content === "string") {
267
- return [{ type: "input_text", text: content }];
275
+ return [{ type: textType, text: content }];
268
276
  }
269
277
  if (!Array.isArray(content) || content.length === 0) {
270
- return [{ type: "input_text", text: "" }];
278
+ return [{ type: textType, text: "" }];
271
279
  }
272
- return content.map((part) => normalizeChatContentPart(part));
280
+ return content.map((part) => normalizeChatContentPart(part, textType));
273
281
  }
274
282
  function normalizeChatMessages(messages) {
275
- return messages.map((message) => ({
276
- role: normalizeChatRole(message.role),
277
- content: normalizeChatContent(message.content),
278
- ...message.name ? { name: message.name } : {},
279
- ...message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}
283
+ const normalized = [];
284
+ for (const message of messages) {
285
+ const record = message;
286
+ if (message.role === "tool") {
287
+ normalized.push({
288
+ type: "function_call_output",
289
+ call_id: message.tool_call_id,
290
+ output: typeof message.content === "string" ? message.content : safeJsonStringify(message.content)
291
+ });
292
+ continue;
293
+ }
294
+ normalized.push({
295
+ role: normalizeChatRole(message.role),
296
+ content: normalizeChatContent(message.content, message.role),
297
+ ...message.name ? { name: message.name } : {},
298
+ ...message.tool_call_id ? { tool_call_id: message.tool_call_id } : {}
299
+ });
300
+ const toolCalls = Array.isArray(record.tool_calls) ? record.tool_calls : [];
301
+ for (const toolCall of toolCalls) {
302
+ const call = toolCall && typeof toolCall === "object" ? toolCall : {};
303
+ const fn = call.function && typeof call.function === "object" ? call.function : {};
304
+ const name = typeof fn.name === "string" ? fn.name : void 0;
305
+ if (!name) {
306
+ continue;
307
+ }
308
+ normalized.push({
309
+ type: "function_call",
310
+ call_id: typeof call.id === "string" ? call.id : `call_${normalized.length}`,
311
+ name,
312
+ arguments: safeJsonStringify(fn.arguments)
313
+ });
314
+ }
315
+ }
316
+ return normalized;
317
+ }
318
+ function normalizeChatTools(tools) {
319
+ if (!tools) {
320
+ return void 0;
321
+ }
322
+ return tools.map((tool) => {
323
+ if (!tool || typeof tool !== "object") {
324
+ return tool;
325
+ }
326
+ const record = tool;
327
+ const fn = record.function && typeof record.function === "object" ? record.function : null;
328
+ if (record.type !== "function" || !fn) {
329
+ return tool;
330
+ }
331
+ return {
332
+ type: "function",
333
+ name: fn.name,
334
+ description: fn.description,
335
+ parameters: fn.parameters
336
+ };
337
+ });
338
+ }
339
+ function normalizeChatToolChoice(toolChoice) {
340
+ if (!toolChoice || typeof toolChoice !== "object") {
341
+ return toolChoice;
342
+ }
343
+ const record = toolChoice;
344
+ const fn = record.function && typeof record.function === "object" ? record.function : null;
345
+ if (record.type === "function" && fn && typeof fn.name === "string") {
346
+ return {
347
+ type: "function",
348
+ name: fn.name
349
+ };
350
+ }
351
+ return toolChoice;
352
+ }
353
+ function normalizeReasoningEffort(value) {
354
+ if (value === "low" || value === "medium" || value === "high") {
355
+ return value;
356
+ }
357
+ if (value === "minimal") {
358
+ return "low";
359
+ }
360
+ if (value === "xhigh") {
361
+ return "high";
362
+ }
363
+ return void 0;
364
+ }
365
+ function normalizeChatReasoning(data) {
366
+ const record = data;
367
+ const existing = record.reasoning;
368
+ if (existing && typeof existing === "object" && !Array.isArray(existing)) {
369
+ return existing;
370
+ }
371
+ const effort = normalizeReasoningEffort(record.reasoning_effort);
372
+ return effort ? { effort } : void 0;
373
+ }
374
+ function truncateForLog(value, maxLength = 300) {
375
+ const normalized = value.replace(/\s+/g, " ").trim();
376
+ if (normalized.length <= maxLength) {
377
+ return normalized;
378
+ }
379
+ return `${normalized.slice(0, maxLength)}...`;
380
+ }
381
+ function extractChatMessageText(message) {
382
+ if (typeof message.content === "string") {
383
+ return message.content;
384
+ }
385
+ if (!Array.isArray(message.content)) {
386
+ return "";
387
+ }
388
+ return message.content.map((part) => typeof part.text === "string" ? part.text : part.image_url ? "[image]" : "").filter(Boolean).join("\n");
389
+ }
390
+ function countRoles(messages) {
391
+ const counts = {};
392
+ for (const message of messages) {
393
+ const role = message.role ?? "user";
394
+ counts[role] = (counts[role] ?? 0) + 1;
395
+ }
396
+ return counts;
397
+ }
398
+ function summarizeRecentMessages(messages) {
399
+ return messages.slice(-8).map((message) => ({
400
+ role: message.role ?? "user",
401
+ textPreview: truncateForLog(extractChatMessageText(message), 180),
402
+ toolCallId: message.tool_call_id
280
403
  }));
281
404
  }
405
+ function summarizeToolNames(tools) {
406
+ if (!tools) {
407
+ return [];
408
+ }
409
+ return tools.map((tool) => {
410
+ if (!tool || typeof tool !== "object") {
411
+ return "";
412
+ }
413
+ const record = tool;
414
+ const fn = record.function && typeof record.function === "object" ? record.function : null;
415
+ return typeof fn?.name === "string" ? fn.name : typeof record.name === "string" ? record.name : "";
416
+ }).filter(Boolean);
417
+ }
418
+ function summarizeChatCompletionsRequest(data) {
419
+ const lastUserMessage = [...data.messages].reverse().find((message) => (message.role ?? "user") === "user");
420
+ const toolNames = summarizeToolNames(data.tools);
421
+ return {
422
+ endpoint: "/v1/chat/completions",
423
+ model: data.model ?? "default",
424
+ stream: data.stream ?? false,
425
+ messageCount: data.messages.length,
426
+ roleCounts: countRoles(data.messages),
427
+ recentMessages: summarizeRecentMessages(data.messages),
428
+ lastUserTextPreview: lastUserMessage ? truncateForLog(extractChatMessageText(lastUserMessage)) : "",
429
+ toolCount: data.tools?.length ?? 0,
430
+ toolNames: toolNames.slice(0, 50),
431
+ toolNamesTruncated: toolNames.length > 50,
432
+ toolChoice: typeof data.tool_choice === "undefined" ? "default" : typeof data.tool_choice,
433
+ parallelToolCalls: data.parallel_tool_calls,
434
+ hasReasoning: Boolean(data.reasoning || data.reasoning_effort),
435
+ maxTokens: data.max_completion_tokens ?? data.max_tokens
436
+ };
437
+ }
438
+ function summarizeCodexChatBody(body) {
439
+ const toolNames = summarizeToolNames(Array.isArray(body.tools) ? body.tools : void 0);
440
+ return {
441
+ keys: Object.keys(body).sort(),
442
+ model: body.model ?? "default",
443
+ stream: body.stream,
444
+ store: body.store,
445
+ inputItems: Array.isArray(body.input) ? body.input.length : void 0,
446
+ tools: Array.isArray(body.tools) ? body.tools.length : void 0,
447
+ toolNames: toolNames.slice(0, 50),
448
+ toolNamesTruncated: toolNames.length > 50,
449
+ toolChoice: typeof body.tool_choice === "undefined" ? "default" : typeof body.tool_choice,
450
+ parallelToolCalls: body.parallel_tool_calls,
451
+ hasReasoning: Boolean(body.reasoning)
452
+ };
453
+ }
454
+ function profileLogLabel(profile) {
455
+ return profile?.email || profile?.accountId || profile?.profileId || "-";
456
+ }
457
+ function requestSourceFromUserAgent(userAgent) {
458
+ return typeof userAgent === "string" && userAgent.toLowerCase().includes("openclaw") ? "OpenClaw" : "API";
459
+ }
282
460
  function createChatCompletionsCodexBody(data) {
283
- const body = Object.fromEntries(
284
- Object.entries(data).filter(([key]) => !chatCompletionExcludedKeys.has(key))
285
- );
286
- if (typeof data.max_completion_tokens === "number") {
287
- body.max_output_tokens = data.max_completion_tokens;
288
- } else if (typeof data.max_tokens === "number") {
289
- body.max_output_tokens = data.max_tokens;
461
+ const body = {
462
+ store: false,
463
+ stream: true,
464
+ input: normalizeChatMessages(data.messages)
465
+ };
466
+ if (data.model) {
467
+ body.model = data.model;
468
+ }
469
+ if (typeof data.parallel_tool_calls === "boolean") {
470
+ body.parallel_tool_calls = data.parallel_tool_calls;
471
+ }
472
+ if (data.tools) {
473
+ body.tools = normalizeChatTools(data.tools);
474
+ }
475
+ if (typeof data.tool_choice !== "undefined") {
476
+ body.tool_choice = normalizeChatToolChoice(data.tool_choice);
477
+ }
478
+ const reasoning = normalizeChatReasoning(data);
479
+ if (reasoning) {
480
+ body.reasoning = reasoning;
290
481
  }
291
- body.input = normalizeChatMessages(data.messages);
292
482
  return body;
293
483
  }
294
484
  function summarizeImageRequestForLog(body) {
@@ -373,6 +563,7 @@ function buildResponseApiBody(result, includeRaw) {
373
563
  return responseBody;
374
564
  }
375
565
  function buildChatCompletionsBody(result) {
566
+ const hasToolCalls = result.toolCalls.length > 0;
376
567
  const body = {
377
568
  id: `chatcmpl_${randomUUID().replace(/-/g, "")}`,
378
569
  object: "chat.completion",
@@ -381,10 +572,11 @@ function buildChatCompletionsBody(result) {
381
572
  choices: [
382
573
  {
383
574
  index: 0,
384
- finish_reason: "stop",
575
+ finish_reason: hasToolCalls ? "tool_calls" : "stop",
385
576
  message: {
386
577
  role: "assistant",
387
- content: result.text
578
+ content: hasToolCalls ? result.text || null : result.text,
579
+ ...hasToolCalls ? { tool_calls: result.toolCalls } : {}
388
580
  }
389
581
  }
390
582
  ]
@@ -394,6 +586,79 @@ function buildChatCompletionsBody(result) {
394
586
  }
395
587
  return body;
396
588
  }
589
+ function writeChatCompletionsSseEvent(reply, data) {
590
+ reply.raw.write(`data: ${JSON.stringify(data)}
591
+
592
+ `);
593
+ }
594
+ function buildChatCompletionChunk(params) {
595
+ return {
596
+ id: params.id,
597
+ object: "chat.completion.chunk",
598
+ created: params.created,
599
+ model: params.model,
600
+ choices: [
601
+ {
602
+ index: 0,
603
+ delta: params.delta,
604
+ finish_reason: params.finishReason ?? null
605
+ }
606
+ ]
607
+ };
608
+ }
609
+ function sendChatCompletionsStream(reply, result) {
610
+ const id = `chatcmpl_${randomUUID().replace(/-/g, "")}`;
611
+ const created = Math.floor(Date.now() / 1e3);
612
+ reply.raw.writeHead(200, {
613
+ "Content-Type": "text/event-stream; charset=utf-8",
614
+ "Cache-Control": "no-cache, no-transform",
615
+ Connection: "keep-alive",
616
+ "X-Accel-Buffering": "no"
617
+ });
618
+ writeChatCompletionsSseEvent(reply, buildChatCompletionChunk({
619
+ id,
620
+ created,
621
+ model: result.model,
622
+ delta: { role: "assistant" }
623
+ }));
624
+ if (result.text) {
625
+ writeChatCompletionsSseEvent(reply, buildChatCompletionChunk({
626
+ id,
627
+ created,
628
+ model: result.model,
629
+ delta: { content: result.text }
630
+ }));
631
+ }
632
+ result.toolCalls.forEach((toolCall, index) => {
633
+ writeChatCompletionsSseEvent(reply, buildChatCompletionChunk({
634
+ id,
635
+ created,
636
+ model: result.model,
637
+ delta: {
638
+ tool_calls: [
639
+ {
640
+ index,
641
+ id: toolCall.id,
642
+ type: toolCall.type,
643
+ function: {
644
+ name: toolCall.function.name,
645
+ arguments: toolCall.function.arguments
646
+ }
647
+ }
648
+ ]
649
+ }
650
+ }));
651
+ });
652
+ writeChatCompletionsSseEvent(reply, buildChatCompletionChunk({
653
+ id,
654
+ created,
655
+ model: result.model,
656
+ delta: {},
657
+ finishReason: result.toolCalls.length > 0 ? "tool_calls" : "stop"
658
+ }));
659
+ reply.raw.write("data: [DONE]\n\n");
660
+ reply.raw.end();
661
+ }
397
662
  function validateImageRequest(data) {
398
663
  if (data.response_format === "url") {
399
664
  return "\u5F53\u524D\u7F51\u5173\u4EC5\u652F\u6301 response_format=b64_json\uFF0C\u6682\u4E0D\u652F\u6301\u8FD4\u56DE\u6258\u7BA1\u56FE\u7247 URL\u3002";
@@ -502,6 +767,24 @@ function createApp(params) {
502
767
  bodyLimit: params?.bodyLimit
503
768
  });
504
769
  const ctx = createGatewayContext();
770
+ const gatewayRequestLogs = [];
771
+ function pushGatewayRequestLog(log) {
772
+ gatewayRequestLogs.unshift({
773
+ id: log.id ?? randomUUID(),
774
+ time: log.time ?? Date.now(),
775
+ method: log.method,
776
+ endpoint: log.endpoint,
777
+ account: log.account,
778
+ model: log.model,
779
+ statusCode: log.statusCode,
780
+ durationMs: log.durationMs,
781
+ source: log.source,
782
+ details: log.details
783
+ });
784
+ if (gatewayRequestLogs.length > MAX_GATEWAY_REQUEST_LOGS) {
785
+ gatewayRequestLogs.length = MAX_GATEWAY_REQUEST_LOGS;
786
+ }
787
+ }
505
788
  void app.register(cors, {
506
789
  origin: params?.corsOrigin ?? true,
507
790
  methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"]
@@ -524,6 +807,9 @@ function createApp(params) {
524
807
  }
525
808
  };
526
809
  });
810
+ app.get("/_gateway/admin/request-logs", async () => ({
811
+ data: gatewayRequestLogs
812
+ }));
527
813
  async function buildAdminConfig(request) {
528
814
  const [status, models, modelCatalog, versionStatus, settings, profile, profiles, codexStatus] = await Promise.all([
529
815
  ctx.authService.getStatus(),
@@ -548,6 +834,7 @@ function createApp(params) {
548
834
  adminUrl: `${origin}/`,
549
835
  baseUrl: `${origin}/v1`,
550
836
  restartSupported: Boolean(params?.onRestart),
837
+ codexRestartSupported: Boolean(params?.onRestartCodex),
551
838
  supportedEndpoints: [
552
839
  {
553
840
  method: "GET",
@@ -839,6 +1126,22 @@ function createApp(params) {
839
1126
  restarting: true
840
1127
  };
841
1128
  });
1129
+ app.post("/_gateway/admin/desktop/restart-codex", async (_request, reply) => {
1130
+ if (!params?.onRestartCodex) {
1131
+ reply.code(501);
1132
+ return {
1133
+ error: {
1134
+ type: "not_supported",
1135
+ message: "\u5F53\u524D\u73AF\u5883\u4E0D\u652F\u6301\u91CD\u542F Codex\u3002"
1136
+ }
1137
+ };
1138
+ }
1139
+ await params.onRestartCodex();
1140
+ return {
1141
+ ok: true,
1142
+ restarted: true
1143
+ };
1144
+ });
842
1145
  app.post("/_gateway/admin/settings/proxy-test", async (request, reply) => {
843
1146
  const parsed = proxyTestSchema.safeParse(request.body);
844
1147
  if (!parsed.success) {
@@ -1030,8 +1333,27 @@ function createApp(params) {
1030
1333
  return buildResponseApiBody(result, parsed.data.experimental_codex?.include_raw);
1031
1334
  });
1032
1335
  app.post("/v1/chat/completions", async (request, reply) => {
1336
+ const startedAt = performance.now();
1033
1337
  const parsed = chatCompletionsBodySchema.safeParse(request.body);
1034
1338
  if (!parsed.success) {
1339
+ pushGatewayRequestLog({
1340
+ method: request.method,
1341
+ endpoint: request.url,
1342
+ account: "-",
1343
+ model: "-",
1344
+ statusCode: 400,
1345
+ durationMs: performance.now() - startedAt,
1346
+ source: "API",
1347
+ details: {
1348
+ requestId: request.id,
1349
+ remoteAddress: request.ip,
1350
+ userAgent: request.headers["user-agent"],
1351
+ error: {
1352
+ type: "validation_error",
1353
+ message: parsed.error.issues[0]?.message ?? "\u8BF7\u6C42\u4F53\u683C\u5F0F\u9519\u8BEF"
1354
+ }
1355
+ }
1356
+ });
1035
1357
  reply.code(400);
1036
1358
  return {
1037
1359
  error: {
@@ -1040,16 +1362,24 @@ function createApp(params) {
1040
1362
  }
1041
1363
  };
1042
1364
  }
1043
- if (parsed.data.stream) {
1044
- reply.code(501);
1045
- return {
1046
- error: {
1047
- type: "not_supported",
1048
- message: "\u5F53\u524D\u7F51\u5173\u6682\u4E0D\u652F\u6301 chat.completions \u7684 stream=true"
1049
- }
1050
- };
1051
- }
1052
1365
  if (typeof parsed.data.n === "number" && parsed.data.n > 1) {
1366
+ pushGatewayRequestLog({
1367
+ method: request.method,
1368
+ endpoint: request.url,
1369
+ account: "-",
1370
+ model: parsed.data.model ?? "default",
1371
+ statusCode: 501,
1372
+ durationMs: performance.now() - startedAt,
1373
+ source: "API",
1374
+ details: {
1375
+ requestId: request.id,
1376
+ request: summarizeChatCompletionsRequest(parsed.data),
1377
+ error: {
1378
+ type: "not_supported",
1379
+ message: "\u5F53\u524D\u7F51\u5173\u6682\u4E0D\u652F\u6301\u4E00\u6B21\u8FD4\u56DE\u591A\u4E2A choices\uFF08n > 1\uFF09"
1380
+ }
1381
+ }
1382
+ });
1053
1383
  reply.code(501);
1054
1384
  return {
1055
1385
  error: {
@@ -1059,16 +1389,93 @@ function createApp(params) {
1059
1389
  };
1060
1390
  }
1061
1391
  const codexBody = createChatCompletionsCodexBody(parsed.data);
1392
+ console.info("[gateway:chat:request]", {
1393
+ requestId: request.id,
1394
+ remoteAddress: request.ip,
1395
+ userAgent: request.headers["user-agent"],
1396
+ ...summarizeChatCompletionsRequest(parsed.data),
1397
+ codex: summarizeCodexChatBody(codexBody)
1398
+ });
1062
1399
  const fallbackInput = parsed.data.messages.map(
1063
1400
  (message) => typeof message.content === "string" ? message.content : (message.content ?? []).map((part) => typeof part.text === "string" ? part.text : "").filter(Boolean).join("\n")
1064
1401
  ).filter(Boolean).join("\n").trim();
1065
- const result = await ctx.chatService.chat({
1066
- model: parsed.data.model,
1067
- input: fallbackInput || void 0,
1068
- experimental: {
1069
- codexBody
1402
+ let result;
1403
+ try {
1404
+ result = await ctx.chatService.chat({
1405
+ model: parsed.data.model,
1406
+ input: fallbackInput || void 0,
1407
+ experimental: {
1408
+ codexBody
1409
+ }
1410
+ });
1411
+ } catch (error) {
1412
+ const normalized = normalizeError(error);
1413
+ const statusCode = getErrorStatusCode(normalized);
1414
+ pushGatewayRequestLog({
1415
+ method: request.method,
1416
+ endpoint: request.url,
1417
+ account: profileLogLabel(await ctx.authService.getActiveProfile()),
1418
+ model: parsed.data.model ?? "default",
1419
+ statusCode,
1420
+ durationMs: performance.now() - startedAt,
1421
+ source: requestSourceFromUserAgent(request.headers["user-agent"]),
1422
+ details: {
1423
+ requestId: request.id,
1424
+ remoteAddress: request.ip,
1425
+ userAgent: request.headers["user-agent"],
1426
+ request: summarizeChatCompletionsRequest(parsed.data),
1427
+ codex: summarizeCodexChatBody(codexBody),
1428
+ error: {
1429
+ message: normalized.message,
1430
+ upstreamStatus: normalized.upstreamStatus,
1431
+ upstreamErrorCode: normalized.upstreamErrorCode,
1432
+ upstreamErrorMessage: normalized.upstreamErrorMessage
1433
+ }
1434
+ }
1435
+ });
1436
+ throw error;
1437
+ }
1438
+ pushGatewayRequestLog({
1439
+ method: request.method,
1440
+ endpoint: request.url,
1441
+ account: profileLogLabel(await ctx.authService.getActiveProfile()),
1442
+ model: result.model,
1443
+ statusCode: 200,
1444
+ durationMs: performance.now() - startedAt,
1445
+ source: requestSourceFromUserAgent(request.headers["user-agent"]),
1446
+ details: {
1447
+ requestId: request.id,
1448
+ remoteAddress: request.ip,
1449
+ userAgent: request.headers["user-agent"],
1450
+ request: summarizeChatCompletionsRequest(parsed.data),
1451
+ codex: summarizeCodexChatBody(codexBody),
1452
+ response: {
1453
+ textPreview: truncateForLog(result.text),
1454
+ textLength: result.text.length,
1455
+ toolCallCount: result.toolCalls.length,
1456
+ toolCalls: result.toolCalls.map((toolCall) => ({
1457
+ id: toolCall.id,
1458
+ name: toolCall.function.name,
1459
+ argumentsPreview: truncateForLog(toolCall.function.arguments)
1460
+ })),
1461
+ artifactCount: result.artifacts.length,
1462
+ stream: parsed.data.stream ?? false
1463
+ }
1070
1464
  }
1071
1465
  });
1466
+ console.info("[gateway:chat:response]", {
1467
+ requestId: request.id,
1468
+ model: result.model,
1469
+ stream: parsed.data.stream ?? false,
1470
+ durationMs: Math.round((performance.now() - startedAt) * 100) / 100,
1471
+ textLength: result.text.length,
1472
+ toolCallCount: result.toolCalls.length,
1473
+ artifactCount: result.artifacts.length
1474
+ });
1475
+ if (parsed.data.stream) {
1476
+ sendChatCompletionsStream(reply, result);
1477
+ return reply;
1478
+ }
1072
1479
  return buildChatCompletionsBody(result);
1073
1480
  });
1074
1481
  app.post("/v1/images/generations", async (request, reply) => {
@@ -41,7 +41,8 @@ async function startServer(params) {
41
41
  const app = createApp({
42
42
  corsOrigin: resolveCorsOrigin(),
43
43
  bodyLimit,
44
- onRestart: params?.onRestart
44
+ onRestart: params?.onRestart,
45
+ onRestartCodex: params?.onRestartCodex
45
46
  });
46
47
  try {
47
48
  await app.listen({
package/docs/API_USAGE.md CHANGED
@@ -41,6 +41,24 @@ Image model: gpt-image-2
41
41
 
42
42
  Use `GET /v1/models` to see the models available through the current local Codex cache.
43
43
 
44
+ ## OpenClaw Settings
45
+
46
+ Use the OpenAI-compatible provider mode in OpenClaw:
47
+
48
+ ```text
49
+ Provider: OpenAI compatible
50
+ Base URL: http://127.0.0.1:8787/v1
51
+ API Key: local
52
+ Model: gpt-5.4
53
+ Chat endpoint: /chat/completions
54
+ Streaming: enabled
55
+ Tools / function calling: enabled
56
+ ```
57
+
58
+ The gateway accepts OpenClaw-style `chat.completions` requests with `tools`, `tool_choice`, `parallel_tool_calls`, `reasoning_effort`, assistant `tool_calls`, and tool-role result messages. It translates those fields to the upstream Codex Responses shape and returns OpenAI-style chat responses.
59
+
60
+ OpenClaw requests are visible in the management console request log when the client sends an OpenClaw user agent. The log keeps safe summaries only; it does not store full access tokens.
61
+
44
62
  ## Models
45
63
 
46
64
  ```bash
@@ -74,6 +92,50 @@ curl http://127.0.0.1:8787/v1/chat/completions \
74
92
  "messages": [
75
93
  { "role": "user", "content": "Reply with OK only." }
76
94
  ]
95
+ }'
96
+ ```
97
+
98
+ Streaming chat completions:
99
+
100
+ ```bash
101
+ curl http://127.0.0.1:8787/v1/chat/completions \
102
+ -H "Content-Type: application/json" \
103
+ -d '{
104
+ "model": "gpt-5.4",
105
+ "stream": true,
106
+ "messages": [
107
+ { "role": "user", "content": "Reply with OK only." }
108
+ ]
109
+ }'
110
+ ```
111
+
112
+ Tool-call compatible request:
113
+
114
+ ```bash
115
+ curl http://127.0.0.1:8787/v1/chat/completions \
116
+ -H "Content-Type: application/json" \
117
+ -d '{
118
+ "model": "gpt-5.4",
119
+ "messages": [
120
+ { "role": "user", "content": "What is the weather tool argument for Shanghai?" }
121
+ ],
122
+ "tools": [
123
+ {
124
+ "type": "function",
125
+ "function": {
126
+ "name": "get_weather",
127
+ "description": "Get weather for a city.",
128
+ "parameters": {
129
+ "type": "object",
130
+ "properties": {
131
+ "city": { "type": "string" }
132
+ },
133
+ "required": ["city"]
134
+ }
135
+ }
136
+ }
137
+ ],
138
+ "tool_choice": "auto"
77
139
  }'
78
140
  ```
79
141
 
@@ -134,5 +196,7 @@ console.log(response.choices[0]?.message?.content);
134
196
 
135
197
  - Login first through the management page or `azt login`.
136
198
  - A model appearing in `/v1/models` means the local Codex cache lists it. Final availability still depends on the active account.
137
- - `stream=true` is not supported yet.
199
+ - `stream=true` is supported for `/v1/chat/completions` through OpenAI-style SSE chunks. `/v1/responses` streaming is still not implemented.
200
+ - `n > 1` is not supported for `/v1/chat/completions`.
201
+ - Tool/function calling is supported for common OpenAI-compatible clients, including OpenClaw, but exact upstream behavior still depends on the active Codex model and account.
138
202
  - The default listener is `0.0.0.0:8787`, so local-network clients can call the gateway by using the machine IP.