codexmate 0.0.25 → 0.0.27

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 (35) hide show
  1. package/README.md +11 -3
  2. package/README.zh.md +10 -2
  3. package/cli/builtin-proxy.js +315 -95
  4. package/cli/openai-bridge.js +99 -5
  5. package/cli/session-convert-args.js +65 -0
  6. package/cli/session-convert-io.js +82 -0
  7. package/cli/session-convert.js +43 -0
  8. package/cli.js +547 -32
  9. package/package.json +74 -74
  10. package/web-ui/app.js +24 -2
  11. package/web-ui/logic.session-convert.mjs +70 -0
  12. package/web-ui/logic.sessions.mjs +151 -0
  13. package/web-ui/modules/app.computed.dashboard.mjs +44 -1
  14. package/web-ui/modules/app.computed.session.mjs +336 -12
  15. package/web-ui/modules/app.methods.claude-config.mjs +11 -1
  16. package/web-ui/modules/app.methods.codex-config.mjs +76 -0
  17. package/web-ui/modules/app.methods.navigation.mjs +51 -3
  18. package/web-ui/modules/app.methods.session-actions.mjs +55 -3
  19. package/web-ui/modules/app.methods.session-browser.mjs +270 -3
  20. package/web-ui/modules/app.methods.session-timeline.mjs +34 -3
  21. package/web-ui/modules/app.methods.session-trash.mjs +16 -1
  22. package/web-ui/modules/app.methods.startup-claude.mjs +234 -125
  23. package/web-ui/modules/i18n.dict.mjs +76 -0
  24. package/web-ui/partials/index/panel-config-claude.html +12 -4
  25. package/web-ui/partials/index/panel-sessions.html +33 -10
  26. package/web-ui/partials/index/panel-settings.html +16 -0
  27. package/web-ui/partials/index/panel-usage.html +95 -85
  28. package/web-ui/session-helpers.mjs +3 -0
  29. package/web-ui/styles/base-theme.css +29 -25
  30. package/web-ui/styles/layout-shell.css +1 -1
  31. package/web-ui/styles/navigation-panels.css +9 -9
  32. package/web-ui/styles/sessions-list.css +17 -0
  33. package/web-ui/styles/sessions-toolbar-trash.css +62 -0
  34. package/web-ui/styles/sessions-usage.css +211 -83
  35. package/web-ui/styles/settings-panel.css +19 -0
package/README.md CHANGED
@@ -1,10 +1,10 @@
1
- <div align="center">
1
+ <div align="center">
2
2
 
3
3
  <img src="site/.vitepress/public/images/logo.png" alt="Codex Mate logo" width="180" />
4
4
 
5
5
  # Codex Mate
6
6
 
7
- **Local-first control panel for Codex / Claude Code / OpenClaw configs, sessions, and usage analytics.**
7
+ **Local-first CLI + Web UI that edits your AI tool configs and sessions directly on disk, with built-in usage analytics and safe rollback.**
8
8
 
9
9
  [![Build](https://img.shields.io/github/actions/workflow/status/SakuraByteCore/codexmate/release.yml?label=build&style=flat)](https://github.com/SakuraByteCore/codexmate/actions/workflows/release.yml)
10
10
  [![Version](https://img.shields.io/npm/v/codexmate?label=version&style=flat)](https://www.npmjs.com/package/codexmate)
@@ -16,6 +16,9 @@
16
16
 
17
17
  [Docs](https://sakurabytecore.github.io/codexmate/) · [Quick Start](#quick-start) · [Commands](#command-reference) · [Web UI](#web-ui) · [MCP](#mcp) · [中文](README.zh.md)
18
18
 
19
+ <br />
20
+ <img src="site/.vitepress/public/images/readme-hero.png" alt="Codex Mate screenshot" width="960" />
21
+
19
22
  </div>
20
23
 
21
24
  ---
@@ -25,6 +28,7 @@
25
28
  Codex Mate is a local-first CLI + Web UI for unified management of:
26
29
 
27
30
  - Codex provider/model switching and config writes
31
+ - OpenAI-compatible bridge mode for Codex Responses API conversion
28
32
  - Claude Code profiles (writes to `~/.claude/settings.json`)
29
33
  - Claude Code `CLAUDE.md` editing (writes to `~/.claude/CLAUDE.md`)
30
34
  - OpenClaw JSON5 profiles and workspace `AGENTS.md`
@@ -52,6 +56,7 @@ It works on local files directly and does not require cloud hosting. The skills
52
56
  **Configuration**
53
57
  - Provider/model switching (`switch`, `use`)
54
58
  - Codex `config.toml` template confirmation before write
59
+ - OpenAI bridge providers: write Codex to a local `/bridge/openai/<provider>/v1` endpoint and normalize Responses API requests for OpenAI-compatible upstreams
55
60
  - Claude Code profile management and apply
56
61
  - Claude Code `CLAUDE.md` editing (writes to `~/.claude/CLAUDE.md`)
57
62
  - OpenClaw JSON5 profile management
@@ -86,6 +91,7 @@ It works on local files directly and does not require cloud hosting. The skills
86
91
  - MCP stdio domains (`tools`, `resources`, `prompts`)
87
92
  - Automation hooks (`/hooks/*`) + outbound webhook notifiers
88
93
  - Built-in proxy controls (`proxy`)
94
+ - OpenAI bridge conversion for Codex `/v1/responses` requests, including upstream `/responses` preference, `/chat/completions` fallback, and function-tool normalization
89
95
  - Auth profile management (`auth`)
90
96
  - Zip/unzip utilities
91
97
 
@@ -226,7 +232,7 @@ npm run reset 79
226
232
  | `codexmate setup` | Interactive setup |
227
233
  | `codexmate list` / `codexmate models` | List providers / models |
228
234
  | `codexmate switch <provider>` / `codexmate use <model>` | Switch provider / model |
229
- | `codexmate add <name> <URL> [API_KEY]` | Add provider |
235
+ | `codexmate add <name> <URL> [API_KEY] [--bridge openai]` | Add provider; `--bridge openai` creates a local Codex Responses-compatible bridge for OpenAI-style upstreams |
230
236
  | `codexmate delete <name>` | Delete provider |
231
237
  | `codexmate claude <BaseURL> <API_KEY> [model]` | Write Claude Code config |
232
238
  | `codexmate auth <list\|import\|switch\|delete\|status>` | Auth profile management |
@@ -253,6 +259,7 @@ codexmate codex --model gpt-5.3-codex --follow-up "step1" --follow-up "step2"
253
259
  ### Codex Mode
254
260
  - Provider/model switching
255
261
  - Model list management
262
+ - OpenAI bridge providers for Codex Responses API conversion to OpenAI-compatible upstreams
256
263
  - `~/.codex/AGENTS.md` editing
257
264
 
258
265
  ### Claude Code Mode
@@ -307,6 +314,7 @@ codexmate mcp serve --allow-write
307
314
  - `~/.codex/auth.json`
308
315
  - `~/.codex/models.json`
309
316
  - `~/.codex/provider-current-models.json`
317
+ - `~/.codex/codexmate-openai-bridge.json`
310
318
  - `~/.claude/settings.json`
311
319
  - `~/.claude/CLAUDE.md`
312
320
  - `~/.openclaw/openclaw.json`
package/README.zh.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  # Codex Mate
6
6
 
7
- **Codex / Claude Code / OpenClaw 的本地控制台:配置、会话与 Usage 统计一体化管理。**
7
+ **本地优先的 CLI + Web UI:直接写入你的本地配置与会话文件,内置 Usage 统计,并提供可审计、可回滚的变更保护。**
8
8
 
9
9
  [![Build](https://img.shields.io/github/actions/workflow/status/SakuraByteCore/codexmate/release.yml?label=build&style=flat)](https://github.com/SakuraByteCore/codexmate/actions/workflows/release.yml)
10
10
  [![Version](https://img.shields.io/npm/v/codexmate?label=version&style=flat)](https://www.npmjs.com/package/codexmate)
@@ -16,6 +16,9 @@
16
16
 
17
17
  [文档](https://sakurabytecore.github.io/codexmate/) · [快速开始](#快速开始) · [命令速查](#命令速查) · [Web 界面](#web-界面) · [MCP](#mcp) · [English](README.md)
18
18
 
19
+ <br />
20
+ <img src="site/.vitepress/public/images/readme-hero.png" alt="Codex Mate 界面预览" width="960" />
21
+
19
22
  </div>
20
23
 
21
24
  ---
@@ -25,6 +28,7 @@
25
28
  Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理:
26
29
 
27
30
  - Codex 的 provider / model 切换与配置写入
31
+ - 面向 Codex Responses API 的 OpenAI 兼容桥接转换
28
32
  - Claude Code 配置方案(写入 `~/.claude/settings.json`)
29
33
  - Claude Code `CLAUDE.md` 编辑(写入 `~/.claude/CLAUDE.md`)
30
34
  - OpenClaw JSON5 配置与 Workspace `AGENTS.md`
@@ -52,6 +56,7 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理:
52
56
  **配置管理**
53
57
  - provider / model 切换(`switch` / `use`)
54
58
  - Codex `config.toml` 模板确认后写入
59
+ - OpenAI 桥接 provider:将 Codex 写到本地 `/bridge/openai/<provider>/v1`,并为 OpenAI 兼容上游归一化 Responses API 请求
55
60
  - Claude Code 多配置方案管理与一键应用
56
61
  - Claude Code `CLAUDE.md` 编辑(写入 `~/.claude/CLAUDE.md`)
57
62
  - 分享命令前缀切换(`npm start` / `codexmate`),用于复制 provider / Claude 导入命令
@@ -90,6 +95,7 @@ Codex Mate 提供一套本地优先的 CLI + Web UI,用于统一管理:
90
95
  **工程能力**
91
96
  - MCP stdio 能力(tools/resources/prompts)
92
97
  - 自动化钩子(`/hooks/*`)+ 外发 webhook 通知
98
+ - Codex `/v1/responses` 的 OpenAI 桥接转换:优先尝试上游 `/responses`,必要时回退 `/chat/completions`,并归一化 function tools
93
99
  - Zip 压缩/解压(优先系统工具,失败回退 JS 库)
94
100
 
95
101
  ## 自动化(信号 → 行动)
@@ -229,7 +235,7 @@ npm run reset 79
229
235
  | `codexmate setup` | 交互式初始化 |
230
236
  | `codexmate list` / `codexmate models` | 查看提供商 / 模型 |
231
237
  | `codexmate switch <provider>` / `codexmate use <model>` | 切换 provider / model |
232
- | `codexmate add <name> <URL> [API_KEY]` | 添加提供商 |
238
+ | `codexmate add <name> <URL> [API_KEY] [--bridge openai]` | 添加提供商;`--bridge openai` 会为 OpenAI 风格上游创建本地 Codex Responses 兼容桥接 |
233
239
  | `codexmate delete <name>` | 删除提供商 |
234
240
  | `codexmate claude <BaseURL> <API_KEY> [model]` | 写入 Claude Code 配置 |
235
241
  | `codexmate workflow <list\|get\|validate\|run\|runs>` | MCP 工作流管理 |
@@ -255,6 +261,7 @@ codexmate codex --model gpt-5.3-codex --follow-up "步骤1" --follow-up "步骤2
255
261
  ### Codex 配置模式
256
262
  - provider / model 切换
257
263
  - 模型管理
264
+ - OpenAI 桥接 provider:将 Codex Responses API 转换给 OpenAI 兼容上游
258
265
  - `~/.codex/AGENTS.md` 编辑
259
266
 
260
267
  ### Claude Code 配置模式
@@ -315,6 +322,7 @@ codexmate mcp serve --allow-write
315
322
  - `~/.codex/auth.json`
316
323
  - `~/.codex/models.json`
317
324
  - `~/.codex/provider-current-models.json`
325
+ - `~/.codex/codexmate-openai-bridge.json`
318
326
  - `~/.claude/settings.json`
319
327
  - `~/.claude/CLAUDE.md`
320
328
  - `~/.openclaw/openclaw.json`
@@ -174,70 +174,182 @@ function createBuiltinProxyRuntimeController(deps = {}) {
174
174
  });
175
175
  }
176
176
 
177
- function extractChatCompletionResult(payload) {
178
- if (!payload || typeof payload !== 'object') return { text: '' };
179
- const choice = Array.isArray(payload.choices) ? payload.choices[0] : null;
180
- const message = choice && typeof choice === 'object' ? choice.message : null;
181
- const content = message && typeof message === 'object' ? message.content : '';
182
- let text = '';
183
- if (typeof content === 'string') {
184
- text = content;
185
- } else if (Array.isArray(content)) {
186
- text = content
187
- .map((item) => {
188
- if (!item) return '';
189
- if (typeof item === 'string') return item;
190
- if (typeof item === 'object') {
191
- if (typeof item.text === 'string') return item.text;
192
- if (typeof item.content === 'string') return item.content;
193
- }
194
- return '';
195
- })
196
- .filter(Boolean)
197
- .join('');
177
+ function buildUpstreamUrlCandidates(baseUrl, pathSuffix) {
178
+ const safeSuffix = String(pathSuffix || '').replace(/^\/+/, '');
179
+ const candidates = [];
180
+ const push = (url) => {
181
+ if (url && !candidates.includes(url)) {
182
+ candidates.push(url);
183
+ }
184
+ };
185
+ push(joinApiUrl(baseUrl, safeSuffix));
186
+ const trimmed = normalizeBaseUrl(baseUrl);
187
+ if (trimmed && safeSuffix) {
188
+ push(`${trimmed}/${safeSuffix}`);
198
189
  }
199
- return { text };
190
+ return candidates;
200
191
  }
201
192
 
202
- function normalizeResponsesInputToChatMessages(input) {
203
- // 支持:
204
- // - string
205
- // - { role, content }(单条 message)
206
- // - { type:"input_text"|"input_image", ... }(单个 block)
207
- // - [{ role, content: [{type:"input_text"|"input_image", ...}] }]
208
- // - [{ type:"input_text"|"input_image", ... }](视为单条 user 消息)
209
- if (typeof input === 'string') {
210
- return [{ role: 'user', content: input }];
193
+ async function proxyRequestJsonWithFallbackUrls(baseUrl, pathSuffix, options = {}) {
194
+ const urls = buildUpstreamUrlCandidates(baseUrl, pathSuffix);
195
+ if (urls.length === 0) {
196
+ return { ok: false, error: 'failed to build upstream URL' };
211
197
  }
212
- if (input && typeof input === 'object' && !Array.isArray(input)) {
213
- if (typeof input.role === 'string' && input.content != null) {
214
- const role = input.role.trim() || 'user';
215
- const content = Array.isArray(input.content)
216
- ? toChatContent(input.content)
217
- : input.content;
218
- return content ? [{ role, content }] : [];
198
+ let lastResult = null;
199
+ for (let index = 0; index < urls.length; index += 1) {
200
+ const result = await proxyRequestJson(urls[index], options);
201
+ lastResult = result;
202
+ if (!result.ok) {
203
+ return result;
219
204
  }
220
- // 单个 block:{type:"input_text"|"input_image", ...}
221
- if (typeof input.type === 'string') {
222
- const content = toChatContent([input]);
223
- return content ? [{ role: 'user', content }] : [];
205
+ if (!(result.status === 404 || result.status === 405)) {
206
+ return result;
224
207
  }
225
- return [];
226
208
  }
227
- if (!Array.isArray(input)) {
228
- return [];
209
+ return lastResult || { ok: false, error: 'failed to build upstream URL' };
210
+ }
211
+
212
+ function stringifyJsonValue(value, fallback = '') {
213
+ if (typeof value === 'string') return value;
214
+ if (value == null) return fallback;
215
+ try {
216
+ return JSON.stringify(value);
217
+ } catch (_) {
218
+ return fallback;
229
219
  }
220
+ }
230
221
 
222
+ function normalizeChatUsageToResponsesUsage(usage) {
223
+ if (!usage || typeof usage !== 'object' || Array.isArray(usage)) return undefined;
224
+ const pickNumber = (...keys) => {
225
+ for (const key of keys) {
226
+ if (Number.isFinite(usage[key])) return usage[key];
227
+ }
228
+ return undefined;
229
+ };
230
+ const inputTokens = pickNumber('input_tokens', 'prompt_tokens');
231
+ const outputTokens = pickNumber('output_tokens', 'completion_tokens');
232
+ const totalTokens = pickNumber('total_tokens');
233
+ const result = {};
234
+ if (inputTokens != null) result.input_tokens = inputTokens;
235
+ if (outputTokens != null) result.output_tokens = outputTokens;
236
+ if (totalTokens != null) result.total_tokens = totalTokens;
237
+ if (usage.input_tokens_details && typeof usage.input_tokens_details === 'object') {
238
+ result.input_tokens_details = usage.input_tokens_details;
239
+ } else if (usage.prompt_tokens_details && typeof usage.prompt_tokens_details === 'object') {
240
+ result.input_tokens_details = usage.prompt_tokens_details;
241
+ }
242
+ if (usage.output_tokens_details && typeof usage.output_tokens_details === 'object') {
243
+ result.output_tokens_details = usage.output_tokens_details;
244
+ } else if (usage.completion_tokens_details && typeof usage.completion_tokens_details === 'object') {
245
+ result.output_tokens_details = usage.completion_tokens_details;
246
+ }
247
+ return Object.keys(result).length > 0 ? result : usage;
248
+ }
249
+
250
+ function mapChatFinishReasonToResponses(choice) {
251
+ const finishReason = choice && typeof choice === 'object' && typeof choice.finish_reason === 'string'
252
+ ? choice.finish_reason
253
+ : '';
254
+ if (finishReason === 'length') {
255
+ return { status: 'incomplete', incomplete_details: { reason: 'max_output_tokens' } };
256
+ }
257
+ if (finishReason === 'content_filter') {
258
+ return { status: 'incomplete', incomplete_details: { reason: 'content_filter' } };
259
+ }
260
+ return { status: 'completed' };
261
+ }
262
+
263
+ function normalizeChatMessageContentToResponsesContent(content, refusal = '') {
264
+ const blocks = [];
265
+ const pushText = (text) => {
266
+ if (typeof text === 'string' && text) {
267
+ blocks.push({ type: 'output_text', text });
268
+ }
269
+ };
270
+ if (typeof content === 'string') {
271
+ pushText(content);
272
+ } else if (Array.isArray(content)) {
273
+ for (const item of content) {
274
+ if (!item) continue;
275
+ if (typeof item === 'string') {
276
+ pushText(item);
277
+ continue;
278
+ }
279
+ if (typeof item !== 'object') continue;
280
+ const type = typeof item.type === 'string' ? item.type : '';
281
+ if ((type === 'text' || type === 'output_text') && typeof item.text === 'string') {
282
+ pushText(item.text);
283
+ continue;
284
+ }
285
+ if (typeof item.content === 'string') {
286
+ pushText(item.content);
287
+ }
288
+ }
289
+ }
290
+ if (typeof refusal === 'string' && refusal) {
291
+ blocks.push({ type: 'refusal', refusal });
292
+ }
293
+ return blocks;
294
+ }
295
+
296
+ function buildResponsesPayloadFromChatCompletion(payload, fallbackModel = '') {
297
+ const base = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
298
+ const choice = Array.isArray(base.choices) ? base.choices[0] : null;
299
+ const message = choice && typeof choice === 'object' && choice.message && typeof choice.message === 'object'
300
+ ? choice.message
301
+ : {};
302
+ const output = [];
303
+ const messageContent = normalizeChatMessageContentToResponsesContent(message.content, message.refusal);
304
+ if (messageContent.length > 0 || !Array.isArray(message.tool_calls) || message.tool_calls.length === 0) {
305
+ output.push({
306
+ type: 'message',
307
+ role: 'assistant',
308
+ content: messageContent.length > 0 ? messageContent : [{ type: 'output_text', text: '' }]
309
+ });
310
+ }
311
+ if (Array.isArray(message.tool_calls)) {
312
+ for (const toolCall of message.tool_calls) {
313
+ if (!toolCall || typeof toolCall !== 'object') continue;
314
+ const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : {};
315
+ const name = typeof fn.name === 'string' ? fn.name : '';
316
+ if (!name) continue;
317
+ output.push({
318
+ type: 'function_call',
319
+ call_id: typeof toolCall.id === 'string' && toolCall.id ? toolCall.id : `call_${crypto.randomBytes(8).toString('hex')}`,
320
+ name,
321
+ arguments: stringifyJsonValue(fn.arguments, '{}')
322
+ });
323
+ }
324
+ }
325
+ const finish = mapChatFinishReasonToResponses(choice);
326
+ return ensureResponseMetadata({
327
+ id: typeof base.id === 'string' ? base.id : undefined,
328
+ model: typeof base.model === 'string' ? base.model : fallbackModel,
329
+ status: finish.status,
330
+ ...(finish.incomplete_details ? { incomplete_details: finish.incomplete_details } : {}),
331
+ output,
332
+ usage: normalizeChatUsageToResponsesUsage(base.usage)
333
+ });
334
+ }
335
+
336
+ function normalizeResponsesInputToChatMessages(input) {
337
+ // 参考 cc-switch 的 Responses 转换形态:message content 保持为消息,function_call /
338
+ // function_call_output 提升为 OpenAI Chat 的 assistant tool_calls / tool 消息。
231
339
  const toChatContent = (blocks) => {
232
340
  if (!Array.isArray(blocks)) return '';
233
341
  const out = [];
234
342
  for (const block of blocks) {
235
343
  if (!block || typeof block !== 'object') continue;
236
344
  const type = typeof block.type === 'string' ? block.type : '';
237
- if (type === 'input_text' && typeof block.text === 'string') {
345
+ if ((type === 'input_text' || type === 'output_text' || type === 'text') && typeof block.text === 'string') {
238
346
  out.push({ type: 'text', text: block.text });
239
347
  continue;
240
348
  }
349
+ if (type === 'refusal' && typeof block.refusal === 'string') {
350
+ out.push({ type: 'text', text: block.refusal });
351
+ continue;
352
+ }
241
353
  if (type === 'input_image') {
242
354
  const raw = block.image_url != null ? block.image_url : block.imageUrl;
243
355
  const url = typeof raw === 'string'
@@ -248,11 +360,6 @@ function createBuiltinProxyRuntimeController(deps = {}) {
248
360
  }
249
361
  continue;
250
362
  }
251
- // 容错:兼容已是 chat content 的 {type:"text"} / {type:"image_url"}
252
- if (type === 'text' && typeof block.text === 'string') {
253
- out.push({ type: 'text', text: block.text });
254
- continue;
255
- }
256
363
  if (type === 'image_url' && block.image_url) {
257
364
  out.push({ type: 'image_url', image_url: block.image_url });
258
365
  }
@@ -261,26 +368,67 @@ function createBuiltinProxyRuntimeController(deps = {}) {
261
368
  return out;
262
369
  };
263
370
 
264
- const messages = [];
265
- for (const item of input) {
266
- if (!item || typeof item !== 'object') continue;
371
+ const messageFromResponsesItem = (item) => {
372
+ if (!item || typeof item !== 'object') return null;
373
+ const type = typeof item.type === 'string' ? item.type : '';
374
+ if (type === 'function_call') {
375
+ const name = typeof item.name === 'string' ? item.name : '';
376
+ if (!name) return null;
377
+ return {
378
+ role: 'assistant',
379
+ content: null,
380
+ tool_calls: [{
381
+ id: typeof item.call_id === 'string' && item.call_id ? item.call_id : (typeof item.id === 'string' ? item.id : `call_${crypto.randomBytes(8).toString('hex')}`),
382
+ type: 'function',
383
+ function: {
384
+ name,
385
+ arguments: stringifyJsonValue(item.arguments, '{}')
386
+ }
387
+ }]
388
+ };
389
+ }
390
+ if (type === 'function_call_output') {
391
+ const callId = typeof item.call_id === 'string' ? item.call_id : '';
392
+ return {
393
+ role: 'tool',
394
+ tool_call_id: callId,
395
+ content: stringifyJsonValue(item.output, '')
396
+ };
397
+ }
267
398
  if (typeof item.role === 'string' && item.content != null) {
268
399
  const role = item.role.trim() || 'user';
269
400
  const content = Array.isArray(item.content)
270
401
  ? toChatContent(item.content)
271
402
  : item.content;
272
- if (content) {
273
- messages.push({ role, content });
274
- }
275
- continue;
403
+ return content || content === null ? { role, content } : null;
404
+ }
405
+ if (type) {
406
+ const content = toChatContent([item]);
407
+ return content ? { role: 'user', content } : null;
276
408
  }
409
+ return null;
410
+ };
411
+
412
+ if (typeof input === 'string') {
413
+ return [{ role: 'user', content: input }];
414
+ }
415
+ if (input && typeof input === 'object' && !Array.isArray(input)) {
416
+ const message = messageFromResponsesItem(input);
417
+ return message ? [message] : [];
418
+ }
419
+ if (!Array.isArray(input)) {
420
+ return [];
277
421
  }
278
422
 
423
+ const messages = [];
424
+ for (const item of input) {
425
+ const message = messageFromResponsesItem(item);
426
+ if (message) messages.push(message);
427
+ }
279
428
  if (messages.length > 0) {
280
429
  return messages;
281
430
  }
282
431
 
283
- // 退化:把 input array 当作单条 user content blocks
284
432
  const fallbackContent = toChatContent(input);
285
433
  if (fallbackContent) {
286
434
  return [{ role: 'user', content: fallbackContent }];
@@ -288,6 +436,99 @@ function createBuiltinProxyRuntimeController(deps = {}) {
288
436
  return [];
289
437
  }
290
438
 
439
+ function normalizeResponsesToolsToChatTools(tools) {
440
+ if (!Array.isArray(tools)) return tools;
441
+ return tools
442
+ .map((tool) => {
443
+ if (!tool || typeof tool !== 'object') return null;
444
+ if (tool.type !== 'function') return tool;
445
+ const sourceFn = tool.function && typeof tool.function === 'object' && !Array.isArray(tool.function)
446
+ ? tool.function
447
+ : {};
448
+ const name = typeof sourceFn.name === 'string' && sourceFn.name.trim()
449
+ ? sourceFn.name.trim()
450
+ : (typeof tool.name === 'string' ? tool.name.trim() : '');
451
+ if (!name) return null;
452
+ const description = typeof sourceFn.description === 'string'
453
+ ? sourceFn.description
454
+ : (typeof tool.description === 'string' ? tool.description : undefined);
455
+ const parameters = sourceFn.parameters && typeof sourceFn.parameters === 'object' && !Array.isArray(sourceFn.parameters)
456
+ ? sourceFn.parameters
457
+ : (tool.parameters && typeof tool.parameters === 'object' && !Array.isArray(tool.parameters) ? tool.parameters : {});
458
+ const strict = typeof sourceFn.strict === 'boolean'
459
+ ? sourceFn.strict
460
+ : (typeof tool.strict === 'boolean' ? tool.strict : undefined);
461
+ const fn = { name, parameters };
462
+ if (description !== undefined) fn.description = description;
463
+ if (strict !== undefined) fn.strict = strict;
464
+ return { type: 'function', function: fn };
465
+ })
466
+ .filter(Boolean);
467
+ }
468
+
469
+ function normalizeResponsesToolChoiceToChatToolChoice(toolChoice) {
470
+ if (!toolChoice || typeof toolChoice !== 'object' || Array.isArray(toolChoice)) return toolChoice;
471
+ if (toolChoice.type === 'function' && typeof toolChoice.name === 'string') {
472
+ return { type: 'function', function: { name: toolChoice.name } };
473
+ }
474
+ return toolChoice;
475
+ }
476
+
477
+ function buildChatCompletionsBodyFromResponsesPayload(payload) {
478
+ const source = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
479
+ const messages = normalizeResponsesInputToChatMessages(source.input);
480
+ const instructions = typeof source.instructions === 'string' ? source.instructions.trim() : '';
481
+ if (instructions) {
482
+ messages.unshift({ role: 'system', content: instructions });
483
+ }
484
+
485
+ const chatBody = {
486
+ model: typeof source.model === 'string' ? source.model : '',
487
+ messages,
488
+ stream: false
489
+ };
490
+
491
+ const passthroughKeys = [
492
+ 'frequency_penalty',
493
+ 'presence_penalty',
494
+ 'response_format',
495
+ 'stop',
496
+ 'temperature',
497
+ 'top_p',
498
+ 'tools',
499
+ 'tool_choice',
500
+ 'logprobs',
501
+ 'top_logprobs',
502
+ 'kbs',
503
+ 'is_online',
504
+ 'user',
505
+ 'seed',
506
+ 'n',
507
+ 'modalities',
508
+ 'audio',
509
+ 'reasoning_effort'
510
+ ];
511
+ for (const key of passthroughKeys) {
512
+ if (Object.prototype.hasOwnProperty.call(source, key)) {
513
+ if (key === 'tools') {
514
+ chatBody[key] = normalizeResponsesToolsToChatTools(source[key]);
515
+ } else if (key === 'tool_choice') {
516
+ chatBody[key] = normalizeResponsesToolChoiceToChatToolChoice(source[key]);
517
+ } else {
518
+ chatBody[key] = source[key];
519
+ }
520
+ }
521
+ }
522
+
523
+ if (Object.prototype.hasOwnProperty.call(source, 'max_tokens')) {
524
+ chatBody.max_tokens = source.max_tokens;
525
+ } else if (source.max_output_tokens != null) {
526
+ chatBody.max_tokens = source.max_output_tokens;
527
+ }
528
+
529
+ return chatBody;
530
+ }
531
+
291
532
  function ensureResponseMetadata(payload) {
292
533
  const base = payload && typeof payload === 'object' && !Array.isArray(payload) ? payload : {};
293
534
  const id = typeof base.id === 'string' && base.id.trim()
@@ -761,15 +1002,12 @@ function createBuiltinProxyRuntimeController(deps = {}) {
761
1002
  'X-Codexmate-Proxy': '1'
762
1003
  };
763
1004
 
764
- const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
765
- const upstreamResponses = upstreamResponsesUrl
766
- ? await proxyRequestJson(upstreamResponsesUrl, {
767
- method: 'POST',
768
- headers: commonHeaders,
769
- timeoutMs,
770
- body: { ...payload, stream: false }
771
- })
772
- : { ok: false, error: 'failed to build upstream URL' };
1005
+ const upstreamResponses = await proxyRequestJsonWithFallbackUrls(upstream.baseUrl, 'responses', {
1006
+ method: 'POST',
1007
+ headers: commonHeaders,
1008
+ timeoutMs,
1009
+ body: { ...payload, stream: false }
1010
+ });
773
1011
 
774
1012
  // 优先走上游 /responses(如果支持)。若上游报错且不是“端点不支持”,则直接透传错误。
775
1013
  if (upstreamResponses.ok && upstreamResponses.status >= 200 && upstreamResponses.status < 300) {
@@ -812,24 +1050,9 @@ function createBuiltinProxyRuntimeController(deps = {}) {
812
1050
  }
813
1051
 
814
1052
  const model = typeof payload.model === 'string' ? payload.model : '';
815
- const messages = normalizeResponsesInputToChatMessages(payload.input);
816
- const chatBody = {
817
- model,
818
- messages,
819
- stream: false
820
- };
821
- if (payload.max_output_tokens != null && chatBody.max_tokens == null) {
822
- chatBody.max_tokens = payload.max_output_tokens;
823
- }
1053
+ const chatBody = buildChatCompletionsBodyFromResponsesPayload(payload);
824
1054
 
825
- const upstreamChatUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
826
- if (!upstreamChatUrl) {
827
- res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
828
- res.end(JSON.stringify({ error: 'failed to build upstream URL' }));
829
- return;
830
- }
831
-
832
- const upstreamChat = await proxyRequestJson(upstreamChatUrl, {
1055
+ const upstreamChat = await proxyRequestJsonWithFallbackUrls(upstream.baseUrl, 'chat/completions', {
833
1056
  method: 'POST',
834
1057
  headers: commonHeaders,
835
1058
  timeoutMs,
@@ -841,6 +1064,12 @@ function createBuiltinProxyRuntimeController(deps = {}) {
841
1064
  return;
842
1065
  }
843
1066
 
1067
+ if (upstreamChat.status >= 400) {
1068
+ res.writeHead(upstreamChat.status, { 'Content-Type': 'application/json; charset=utf-8' });
1069
+ res.end(upstreamChat.bodyText || JSON.stringify({ error: 'Upstream error' }));
1070
+ return;
1071
+ }
1072
+
844
1073
  const chatJson = parseJsonOrError(upstreamChat.bodyText);
845
1074
  if (chatJson.error) {
846
1075
  res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
@@ -848,16 +1077,7 @@ function createBuiltinProxyRuntimeController(deps = {}) {
848
1077
  return;
849
1078
  }
850
1079
 
851
- const { text } = extractChatCompletionResult(chatJson.value);
852
- const responsesPayload = ensureResponseMetadata({
853
- model,
854
- output: [{
855
- type: 'message',
856
- role: 'assistant',
857
- content: [{ type: 'output_text', text }]
858
- }],
859
- usage: chatJson.value && chatJson.value.usage ? chatJson.value.usage : undefined
860
- });
1080
+ const responsesPayload = buildResponsesPayloadFromChatCompletion(chatJson.value, model);
861
1081
 
862
1082
  if (wantsStream) {
863
1083
  res.writeHead(200, {