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.
- package/README.md +11 -3
- package/README.zh.md +10 -2
- package/cli/builtin-proxy.js +315 -95
- package/cli/openai-bridge.js +99 -5
- package/cli/session-convert-args.js +65 -0
- package/cli/session-convert-io.js +82 -0
- package/cli/session-convert.js +43 -0
- package/cli.js +547 -32
- package/package.json +74 -74
- package/web-ui/app.js +24 -2
- package/web-ui/logic.session-convert.mjs +70 -0
- package/web-ui/logic.sessions.mjs +151 -0
- package/web-ui/modules/app.computed.dashboard.mjs +44 -1
- package/web-ui/modules/app.computed.session.mjs +336 -12
- package/web-ui/modules/app.methods.claude-config.mjs +11 -1
- package/web-ui/modules/app.methods.codex-config.mjs +76 -0
- package/web-ui/modules/app.methods.navigation.mjs +51 -3
- package/web-ui/modules/app.methods.session-actions.mjs +55 -3
- package/web-ui/modules/app.methods.session-browser.mjs +270 -3
- package/web-ui/modules/app.methods.session-timeline.mjs +34 -3
- package/web-ui/modules/app.methods.session-trash.mjs +16 -1
- package/web-ui/modules/app.methods.startup-claude.mjs +234 -125
- package/web-ui/modules/i18n.dict.mjs +76 -0
- package/web-ui/partials/index/panel-config-claude.html +12 -4
- package/web-ui/partials/index/panel-sessions.html +33 -10
- package/web-ui/partials/index/panel-settings.html +16 -0
- package/web-ui/partials/index/panel-usage.html +95 -85
- package/web-ui/session-helpers.mjs +3 -0
- package/web-ui/styles/base-theme.css +29 -25
- package/web-ui/styles/layout-shell.css +1 -1
- package/web-ui/styles/navigation-panels.css +9 -9
- package/web-ui/styles/sessions-list.css +17 -0
- package/web-ui/styles/sessions-toolbar-trash.css +62 -0
- package/web-ui/styles/sessions-usage.css +211 -83
- package/web-ui/styles/settings-panel.css +19 -0
package/README.md
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
|
-
|
|
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
|
|
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
|
[](https://github.com/SakuraByteCore/codexmate/actions/workflows/release.yml)
|
|
10
10
|
[](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
|
-
|
|
7
|
+
**本地优先的 CLI + Web UI:直接写入你的本地配置与会话文件,内置 Usage 统计,并提供可审计、可回滚的变更保护。**
|
|
8
8
|
|
|
9
9
|
[](https://github.com/SakuraByteCore/codexmate/actions/workflows/release.yml)
|
|
10
10
|
[](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`
|
package/cli/builtin-proxy.js
CHANGED
|
@@ -174,70 +174,182 @@ function createBuiltinProxyRuntimeController(deps = {}) {
|
|
|
174
174
|
});
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
-
function
|
|
178
|
-
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
|
190
|
+
return candidates;
|
|
200
191
|
}
|
|
201
192
|
|
|
202
|
-
function
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
-
|
|
221
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
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
|
|
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
|
|
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
|
|
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, {
|