codexmate 0.0.25 → 0.0.26
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 +416 -413
- package/README.zh.md +349 -346
- package/cli/agents-files.js +224 -224
- package/cli/archive-helpers.js +446 -446
- package/cli/auth-profiles.js +375 -375
- package/cli/builtin-proxy.js +1079 -1079
- package/cli/claude-proxy.js +1022 -1022
- package/cli/config-bootstrap.js +384 -384
- package/cli/config-health.js +338 -338
- package/cli/doctor-core.js +903 -903
- package/cli/import-skills-url.js +356 -356
- package/cli/openai-bridge.js +997 -997
- package/cli/openclaw-config.js +629 -629
- 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/session-usage.concurrent.js +28 -28
- package/cli/session-usage.js +118 -118
- package/cli/session-usage.models.js +176 -176
- package/cli/skills.js +1141 -1141
- package/cli/zip-commands.js +510 -510
- package/cli.js +15218 -14736
- package/lib/automation.js +404 -404
- package/lib/cli-file-utils.js +151 -151
- package/lib/cli-models-utils.js +379 -379
- package/lib/cli-network-utils.js +190 -190
- package/lib/cli-path-utils.js +85 -85
- package/lib/cli-session-utils.js +121 -121
- package/lib/cli-sessions.js +417 -417
- package/lib/cli-utils.js +155 -155
- package/lib/download-artifacts.js +92 -92
- package/lib/mcp-stdio.js +453 -453
- package/lib/task-orchestrator.js +869 -869
- package/lib/text-diff.js +303 -303
- package/lib/workflow-engine.js +340 -340
- package/package.json +74 -74
- package/plugins/README.md +20 -20
- package/plugins/README.zh-CN.md +20 -20
- package/plugins/prompt-templates/comment-polish/index.mjs +25 -25
- package/plugins/prompt-templates/computed.mjs +253 -253
- package/plugins/prompt-templates/index.mjs +8 -8
- package/plugins/prompt-templates/manifest.mjs +15 -15
- package/plugins/prompt-templates/methods.mjs +619 -619
- package/plugins/prompt-templates/overview.mjs +90 -90
- package/plugins/prompt-templates/ownership.mjs +19 -19
- package/plugins/prompt-templates/rule-ack/index.mjs +21 -21
- package/plugins/prompt-templates/storage.mjs +64 -64
- package/plugins/registry.mjs +16 -16
- package/web-ui/app.js +625 -612
- package/web-ui/index.html +35 -35
- package/web-ui/logic.agents-diff.mjs +386 -386
- package/web-ui/logic.claude.mjs +168 -168
- package/web-ui/logic.mjs +5 -5
- package/web-ui/logic.runtime.mjs +128 -128
- package/web-ui/logic.session-convert.mjs +70 -0
- package/web-ui/logic.sessions.mjs +709 -614
- package/web-ui/modules/api.mjs +90 -90
- package/web-ui/modules/app.computed.dashboard.mjs +171 -128
- package/web-ui/modules/app.computed.index.mjs +17 -17
- package/web-ui/modules/app.computed.main-tabs.mjs +205 -205
- package/web-ui/modules/app.computed.session.mjs +946 -670
- package/web-ui/modules/app.constants.mjs +15 -15
- package/web-ui/modules/app.methods.agents.mjs +632 -632
- package/web-ui/modules/app.methods.claude-config.mjs +179 -174
- package/web-ui/modules/app.methods.codex-config.mjs +860 -784
- package/web-ui/modules/app.methods.index.mjs +92 -92
- package/web-ui/modules/app.methods.install.mjs +205 -205
- package/web-ui/modules/app.methods.navigation.mjs +743 -695
- package/web-ui/modules/app.methods.openclaw-core.mjs +814 -814
- package/web-ui/modules/app.methods.openclaw-editing.mjs +372 -372
- package/web-ui/modules/app.methods.openclaw-persist.mjs +369 -369
- package/web-ui/modules/app.methods.providers.mjs +404 -404
- package/web-ui/modules/app.methods.runtime.mjs +345 -345
- package/web-ui/modules/app.methods.session-actions.mjs +596 -544
- package/web-ui/modules/app.methods.session-browser.mjs +985 -722
- package/web-ui/modules/app.methods.session-timeline.mjs +479 -448
- package/web-ui/modules/app.methods.session-trash.mjs +424 -424
- package/web-ui/modules/app.methods.startup-claude.mjs +522 -417
- package/web-ui/modules/app.methods.task-orchestration.mjs +556 -556
- package/web-ui/modules/config-mode.computed.mjs +124 -124
- package/web-ui/modules/config-template-confirm-pref.mjs +33 -33
- package/web-ui/modules/i18n.dict.mjs +2113 -2055
- package/web-ui/modules/i18n.mjs +56 -56
- package/web-ui/modules/plugins.computed.mjs +3 -3
- package/web-ui/modules/plugins.methods.mjs +3 -3
- package/web-ui/modules/plugins.storage.mjs +11 -11
- package/web-ui/modules/sessions-filters-url.mjs +85 -85
- package/web-ui/modules/skills.computed.mjs +107 -107
- package/web-ui/modules/skills.methods.mjs +481 -481
- package/web-ui/partials/index/layout-footer.html +13 -13
- package/web-ui/partials/index/layout-header.html +475 -475
- package/web-ui/partials/index/modal-config-template-agents.html +174 -174
- package/web-ui/partials/index/modal-confirm-toast.html +32 -32
- package/web-ui/partials/index/modal-health-check.html +45 -45
- package/web-ui/partials/index/modal-openclaw-config.html +280 -280
- package/web-ui/partials/index/modal-skills.html +200 -200
- package/web-ui/partials/index/modals-basic.html +165 -165
- package/web-ui/partials/index/panel-config-claude.html +184 -179
- package/web-ui/partials/index/panel-config-codex.html +283 -283
- package/web-ui/partials/index/panel-config-openclaw.html +83 -83
- package/web-ui/partials/index/panel-dashboard.html +186 -186
- package/web-ui/partials/index/panel-docs.html +147 -147
- package/web-ui/partials/index/panel-market.html +177 -177
- package/web-ui/partials/index/panel-orchestration.html +391 -391
- package/web-ui/partials/index/panel-plugins.html +279 -279
- package/web-ui/partials/index/panel-sessions.html +326 -303
- package/web-ui/partials/index/panel-settings.html +258 -258
- package/web-ui/partials/index/panel-usage.html +342 -361
- package/web-ui/res/json5.min.js +1 -1
- package/web-ui/res/vue.global.prod.js +13 -13
- package/web-ui/session-helpers.mjs +576 -573
- package/web-ui/source-bundle.cjs +233 -233
- package/web-ui/styles/base-theme.css +268 -264
- package/web-ui/styles/controls-forms.css +423 -423
- package/web-ui/styles/dashboard.css +274 -274
- package/web-ui/styles/docs-panel.css +247 -247
- package/web-ui/styles/feedback.css +108 -108
- package/web-ui/styles/health-check-dialog.css +144 -144
- package/web-ui/styles/layout-shell.css +603 -603
- package/web-ui/styles/modals-core.css +464 -464
- package/web-ui/styles/navigation-panels.css +390 -390
- package/web-ui/styles/openclaw-structured.css +266 -266
- package/web-ui/styles/plugins-panel.css +523 -523
- package/web-ui/styles/responsive.css +454 -454
- package/web-ui/styles/sessions-list.css +415 -398
- package/web-ui/styles/sessions-preview.css +411 -411
- package/web-ui/styles/sessions-toolbar-trash.css +330 -268
- package/web-ui/styles/sessions-usage.css +945 -912
- package/web-ui/styles/settings-panel.css +166 -166
- package/web-ui/styles/skills-list.css +303 -303
- package/web-ui/styles/skills-market.css +406 -406
- package/web-ui/styles/task-orchestration.css +822 -822
- package/web-ui/styles/titles-cards.css +408 -408
- package/web-ui/styles.css +21 -21
- package/web-ui.html +17 -17
package/cli/openai-bridge.js
CHANGED
|
@@ -1,997 +1,997 @@
|
|
|
1
|
-
const http = require('http');
|
|
2
|
-
const https = require('https');
|
|
3
|
-
const crypto = require('crypto');
|
|
4
|
-
const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils');
|
|
5
|
-
const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils');
|
|
6
|
-
|
|
7
|
-
const DEFAULT_BRIDGE_TOKEN = 'codexmate';
|
|
8
|
-
const SETTINGS_VERSION = 1;
|
|
9
|
-
|
|
10
|
-
function normalizeText(value) {
|
|
11
|
-
return typeof value === 'string' ? value.trim() : '';
|
|
12
|
-
}
|
|
13
|
-
|
|
14
|
-
function normalizeProviderName(value) {
|
|
15
|
-
// Provider name validation is done elsewhere; keep this conservative.
|
|
16
|
-
return normalizeText(value);
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function normalizeOpenaiUpstreamBaseUrl(rawValue) {
|
|
20
|
-
const normalized = normalizeBaseUrl(rawValue);
|
|
21
|
-
if (!normalized) return '';
|
|
22
|
-
try {
|
|
23
|
-
const parsed = new URL(normalized);
|
|
24
|
-
let pathname = String(parsed.pathname || '').replace(/\/+$/g, '');
|
|
25
|
-
|
|
26
|
-
// If user accidentally pasted a full endpoint, strip it back to the base URL.
|
|
27
|
-
// Keep direct provider routes (e.g. /project/ym) intact.
|
|
28
|
-
pathname = pathname
|
|
29
|
-
.replace(/\/v1\/chat\/completions$/i, '/v1')
|
|
30
|
-
.replace(/\/chat\/completions$/i, '')
|
|
31
|
-
.replace(/\/v1\/responses$/i, '/v1')
|
|
32
|
-
.replace(/\/responses$/i, '')
|
|
33
|
-
.replace(/\/v1\/models$/i, '/v1')
|
|
34
|
-
.replace(/\/models$/i, '');
|
|
35
|
-
|
|
36
|
-
// Normalize empty/root path.
|
|
37
|
-
if (pathname === '/') pathname = '';
|
|
38
|
-
|
|
39
|
-
const rebuilt = `${parsed.origin}${pathname}`;
|
|
40
|
-
return normalizeBaseUrl(rebuilt);
|
|
41
|
-
} catch (_) {
|
|
42
|
-
return normalized;
|
|
43
|
-
}
|
|
44
|
-
}
|
|
45
|
-
|
|
46
|
-
function normalizeUpstreamEntry(entry) {
|
|
47
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
48
|
-
return null;
|
|
49
|
-
}
|
|
50
|
-
const baseUrl = normalizeOpenaiUpstreamBaseUrl(entry.baseUrl || entry.base_url || '');
|
|
51
|
-
const apiKey = normalizeText(entry.apiKey || entry.api_key || entry.key || '');
|
|
52
|
-
const headersRaw = entry.headers || entry.extraHeaders || entry.extra_headers || null;
|
|
53
|
-
const headers = normalizeHeadersMap(headersRaw);
|
|
54
|
-
if (!baseUrl || !isValidHttpUrl(baseUrl)) {
|
|
55
|
-
return null;
|
|
56
|
-
}
|
|
57
|
-
return { baseUrl, apiKey, headers };
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
function normalizeHeadersMap(value) {
|
|
61
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
62
|
-
return {};
|
|
63
|
-
}
|
|
64
|
-
const forbidden = new Set([
|
|
65
|
-
'authorization',
|
|
66
|
-
'host',
|
|
67
|
-
'content-length',
|
|
68
|
-
'connection',
|
|
69
|
-
'transfer-encoding',
|
|
70
|
-
'keep-alive',
|
|
71
|
-
'proxy-authenticate',
|
|
72
|
-
'proxy-authorization',
|
|
73
|
-
'te',
|
|
74
|
-
'trailer',
|
|
75
|
-
'upgrade'
|
|
76
|
-
]);
|
|
77
|
-
const result = {};
|
|
78
|
-
for (const [rawKey, rawVal] of Object.entries(value)) {
|
|
79
|
-
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
|
|
80
|
-
if (!key) continue;
|
|
81
|
-
const lower = key.toLowerCase();
|
|
82
|
-
if (forbidden.has(lower)) continue;
|
|
83
|
-
if (typeof rawVal !== 'string') continue;
|
|
84
|
-
result[key] = rawVal;
|
|
85
|
-
}
|
|
86
|
-
return result;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
function readOpenaiBridgeSettings(filePath) {
|
|
90
|
-
const parsed = readJsonFile(filePath, null);
|
|
91
|
-
const providers = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
92
|
-
? parsed.providers
|
|
93
|
-
: null;
|
|
94
|
-
const providerMap = providers && typeof providers === 'object' && !Array.isArray(providers)
|
|
95
|
-
? providers
|
|
96
|
-
: {};
|
|
97
|
-
return {
|
|
98
|
-
version: SETTINGS_VERSION,
|
|
99
|
-
providers: providerMap
|
|
100
|
-
};
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, apiKey, headers) {
|
|
104
|
-
const name = normalizeProviderName(providerName);
|
|
105
|
-
const baseUrl = normalizeOpenaiUpstreamBaseUrl(upstreamBaseUrl);
|
|
106
|
-
const key = normalizeText(apiKey);
|
|
107
|
-
const nextHeaders = normalizeHeadersMap(headers);
|
|
108
|
-
|
|
109
|
-
if (!name) {
|
|
110
|
-
return { error: 'Provider name is required' };
|
|
111
|
-
}
|
|
112
|
-
if (!baseUrl || !isValidHttpUrl(baseUrl)) {
|
|
113
|
-
return { error: 'Upstream base URL is invalid' };
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const settings = readOpenaiBridgeSettings(filePath);
|
|
117
|
-
const existing = settings && settings.providers ? settings.providers[name] : null;
|
|
118
|
-
const existingHeaders = existing && typeof existing === 'object' && !Array.isArray(existing)
|
|
119
|
-
? normalizeHeadersMap(existing.headers || existing.extraHeaders || existing.extra_headers || null)
|
|
120
|
-
: {};
|
|
121
|
-
const next = {
|
|
122
|
-
version: SETTINGS_VERSION,
|
|
123
|
-
providers: {
|
|
124
|
-
...(settings.providers || {}),
|
|
125
|
-
[name]: {
|
|
126
|
-
baseUrl,
|
|
127
|
-
apiKey: key,
|
|
128
|
-
headers: Object.keys(nextHeaders).length ? nextHeaders : existingHeaders
|
|
129
|
-
}
|
|
130
|
-
}
|
|
131
|
-
};
|
|
132
|
-
writeJsonAtomic(filePath, next);
|
|
133
|
-
return { success: true };
|
|
134
|
-
}
|
|
135
|
-
|
|
136
|
-
function resolveOpenaiBridgeUpstream(filePath, providerName) {
|
|
137
|
-
const name = normalizeProviderName(providerName);
|
|
138
|
-
if (!name) return { error: 'Provider name is required' };
|
|
139
|
-
const settings = readOpenaiBridgeSettings(filePath);
|
|
140
|
-
const entry = settings.providers ? settings.providers[name] : null;
|
|
141
|
-
const normalized = normalizeUpstreamEntry(entry);
|
|
142
|
-
if (!normalized) {
|
|
143
|
-
return { error: `OpenAI 转换未配置: ${name}` };
|
|
144
|
-
}
|
|
145
|
-
return { provider: name, ...normalized };
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
function extractAuthorizationToken(req) {
|
|
149
|
-
const header = typeof req.headers.authorization === 'string' ? req.headers.authorization.trim() : '';
|
|
150
|
-
if (!header) return '';
|
|
151
|
-
if (/^bearer\s+/i.test(header)) {
|
|
152
|
-
return header.replace(/^bearer\s+/i, '').trim();
|
|
153
|
-
}
|
|
154
|
-
return header;
|
|
155
|
-
}
|
|
156
|
-
|
|
157
|
-
function readRequestBody(req, maxBytes) {
|
|
158
|
-
return new Promise((resolve) => {
|
|
159
|
-
let body = '';
|
|
160
|
-
let size = 0;
|
|
161
|
-
let aborted = false;
|
|
162
|
-
req.on('data', (chunk) => {
|
|
163
|
-
if (aborted) return;
|
|
164
|
-
size += chunk.length;
|
|
165
|
-
if (Number.isFinite(maxBytes) && maxBytes > 0 && size > maxBytes) {
|
|
166
|
-
aborted = true;
|
|
167
|
-
try { req.destroy(); } catch (_) {}
|
|
168
|
-
resolve({ error: '请求体过大' });
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
|
-
body += chunk;
|
|
172
|
-
});
|
|
173
|
-
req.on('end', () => {
|
|
174
|
-
if (aborted) return;
|
|
175
|
-
resolve({ body });
|
|
176
|
-
});
|
|
177
|
-
req.on('error', (err) => resolve({ error: err && err.message ? err.message : 'request failed' }));
|
|
178
|
-
});
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
function parseJsonOrError(text) {
|
|
182
|
-
if (typeof text !== 'string' || !text.trim()) {
|
|
183
|
-
return { value: null, error: 'empty body' };
|
|
184
|
-
}
|
|
185
|
-
try {
|
|
186
|
-
return { value: JSON.parse(text), error: '' };
|
|
187
|
-
} catch (e) {
|
|
188
|
-
return { value: null, error: e && e.message ? e.message : 'invalid json' };
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function extractChatCompletionResult(payload) {
|
|
193
|
-
if (!payload || typeof payload !== 'object') return { text: '', toolCalls: [] };
|
|
194
|
-
const choice = Array.isArray(payload.choices) ? payload.choices[0] : null;
|
|
195
|
-
const message = choice && typeof choice === 'object' ? choice.message : null;
|
|
196
|
-
const toolCalls = message && typeof message === 'object' && Array.isArray(message.tool_calls)
|
|
197
|
-
? message.tool_calls
|
|
198
|
-
: [];
|
|
199
|
-
const content = message && typeof message === 'object' ? message.content : '';
|
|
200
|
-
let text = '';
|
|
201
|
-
if (typeof content === 'string') {
|
|
202
|
-
text = content;
|
|
203
|
-
} else if (Array.isArray(content)) {
|
|
204
|
-
text = content
|
|
205
|
-
.map((item) => {
|
|
206
|
-
if (!item) return '';
|
|
207
|
-
if (typeof item === 'string') return item;
|
|
208
|
-
if (typeof item === 'object') {
|
|
209
|
-
if (typeof item.text === 'string') return item.text;
|
|
210
|
-
if (typeof item.content === 'string') return item.content;
|
|
211
|
-
}
|
|
212
|
-
return '';
|
|
213
|
-
})
|
|
214
|
-
.filter(Boolean)
|
|
215
|
-
.join('');
|
|
216
|
-
}
|
|
217
|
-
return { text, toolCalls };
|
|
218
|
-
}
|
|
219
|
-
|
|
220
|
-
function normalizeResponsesInputToChatMessages(input) {
|
|
221
|
-
// 支持:
|
|
222
|
-
// - string
|
|
223
|
-
// - { role, content }(单条 message)
|
|
224
|
-
// - { type:"input_text"|"input_image", ... }(单个 block)
|
|
225
|
-
// - [{ role, content: [...] }](messages array)
|
|
226
|
-
// - [{ type:"input_text"|"input_image", ... }](blocks array -> 单条 user 消息)
|
|
227
|
-
if (typeof input === 'string') {
|
|
228
|
-
return [{ role: 'user', content: input }];
|
|
229
|
-
}
|
|
230
|
-
|
|
231
|
-
const toChatContent = (blocks) => {
|
|
232
|
-
if (!Array.isArray(blocks)) return '';
|
|
233
|
-
const out = [];
|
|
234
|
-
for (const block of blocks) {
|
|
235
|
-
if (!block || typeof block !== 'object') continue;
|
|
236
|
-
const type = typeof block.type === 'string' ? block.type : '';
|
|
237
|
-
if ((type === 'input_text' || type === 'output_text') && typeof block.text === 'string') {
|
|
238
|
-
out.push({ type: 'text', text: block.text });
|
|
239
|
-
continue;
|
|
240
|
-
}
|
|
241
|
-
if ((type === 'reasoning' || type === 'reasoning_text' || type === 'reasoning_content') && typeof block.text === 'string') {
|
|
242
|
-
out.push({ type: 'text', text: block.text });
|
|
243
|
-
continue;
|
|
244
|
-
}
|
|
245
|
-
if (type === 'input_image') {
|
|
246
|
-
const raw = block.image_url != null ? block.image_url : block.imageUrl;
|
|
247
|
-
const url = typeof raw === 'string'
|
|
248
|
-
? raw
|
|
249
|
-
: (raw && typeof raw === 'object' && typeof raw.url === 'string' ? raw.url : '');
|
|
250
|
-
if (url) {
|
|
251
|
-
out.push({ type: 'image_url', image_url: { url } });
|
|
252
|
-
}
|
|
253
|
-
continue;
|
|
254
|
-
}
|
|
255
|
-
// 容错:兼容已是 chat content 的 {type:"text"} / {type:"image_url"}
|
|
256
|
-
if (type === 'text' && typeof block.text === 'string') {
|
|
257
|
-
out.push({ type: 'text', text: block.text });
|
|
258
|
-
continue;
|
|
259
|
-
}
|
|
260
|
-
if (type === 'image_url' && block.image_url) {
|
|
261
|
-
out.push({ type: 'image_url', image_url: block.image_url });
|
|
262
|
-
continue;
|
|
263
|
-
}
|
|
264
|
-
const text = typeof block.text === 'string'
|
|
265
|
-
? block.text
|
|
266
|
-
: (typeof block.content === 'string' ? block.content : '');
|
|
267
|
-
if (text) {
|
|
268
|
-
out.push({ type: 'text', text });
|
|
269
|
-
continue;
|
|
270
|
-
}
|
|
271
|
-
try {
|
|
272
|
-
const raw = JSON.stringify(block);
|
|
273
|
-
if (raw) {
|
|
274
|
-
out.push({ type: 'text', text: raw.slice(0, 4000) });
|
|
275
|
-
}
|
|
276
|
-
} catch (_) {}
|
|
277
|
-
}
|
|
278
|
-
if (out.length === 0) return '';
|
|
279
|
-
return out;
|
|
280
|
-
};
|
|
281
|
-
|
|
282
|
-
const toRole = (value) => {
|
|
283
|
-
const roleRaw = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
284
|
-
return roleRaw === 'assistant' ? 'assistant' : (roleRaw === 'system' ? 'system' : 'user');
|
|
285
|
-
};
|
|
286
|
-
|
|
287
|
-
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
|
288
|
-
if (typeof input.role === 'string' && input.content != null) {
|
|
289
|
-
const role = toRole(input.role);
|
|
290
|
-
const content = Array.isArray(input.content)
|
|
291
|
-
? toChatContent(input.content)
|
|
292
|
-
: input.content;
|
|
293
|
-
return content ? [{ role, content }] : [];
|
|
294
|
-
}
|
|
295
|
-
if (typeof input.type === 'string') {
|
|
296
|
-
const content = toChatContent([input]);
|
|
297
|
-
return content ? [{ role: 'user', content }] : [];
|
|
298
|
-
}
|
|
299
|
-
return [];
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
if (!Array.isArray(input)) {
|
|
303
|
-
return [];
|
|
304
|
-
}
|
|
305
|
-
|
|
306
|
-
const messages = [];
|
|
307
|
-
for (const item of input) {
|
|
308
|
-
if (!item || typeof item !== 'object') continue;
|
|
309
|
-
|
|
310
|
-
// Tool calls (Responses): { type: "function_call", call_id, name, arguments }
|
|
311
|
-
// Chat Completions equivalent: assistant message with tool_calls
|
|
312
|
-
if (typeof item.type === 'string' && item.type === 'function_call') {
|
|
313
|
-
const callId = typeof item.call_id === 'string' ? item.call_id.trim() : '';
|
|
314
|
-
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
|
315
|
-
const args = typeof item.arguments === 'string' ? item.arguments : '';
|
|
316
|
-
if (callId && name) {
|
|
317
|
-
messages.push({
|
|
318
|
-
role: 'assistant',
|
|
319
|
-
tool_calls: [{
|
|
320
|
-
id: callId,
|
|
321
|
-
type: 'function',
|
|
322
|
-
function: { name, arguments: args || '' }
|
|
323
|
-
}]
|
|
324
|
-
});
|
|
325
|
-
}
|
|
326
|
-
continue;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
// Tool results (Responses): { type: "function_call_output", call_id, output }
|
|
330
|
-
// Chat Completions equivalent: { role: "tool", tool_call_id, content }
|
|
331
|
-
if (typeof item.type === 'string' && item.type === 'function_call_output') {
|
|
332
|
-
const toolCallId = typeof item.call_id === 'string' ? item.call_id.trim() : '';
|
|
333
|
-
let content = item.output;
|
|
334
|
-
if (typeof content !== 'string') {
|
|
335
|
-
try {
|
|
336
|
-
content = JSON.stringify(content);
|
|
337
|
-
} catch (_) {
|
|
338
|
-
content = String(content ?? '');
|
|
339
|
-
}
|
|
340
|
-
}
|
|
341
|
-
if (toolCallId) {
|
|
342
|
-
messages.push({ role: 'tool', tool_call_id: toolCallId, content: String(content || '') });
|
|
343
|
-
}
|
|
344
|
-
continue;
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
// message form
|
|
348
|
-
if (typeof item.role === 'string' && item.content != null) {
|
|
349
|
-
const role = toRole(item.role);
|
|
350
|
-
const content = Array.isArray(item.content)
|
|
351
|
-
? toChatContent(item.content)
|
|
352
|
-
: item.content;
|
|
353
|
-
if (content) {
|
|
354
|
-
messages.push({ role, content });
|
|
355
|
-
}
|
|
356
|
-
continue;
|
|
357
|
-
}
|
|
358
|
-
}
|
|
359
|
-
|
|
360
|
-
if (messages.length > 0) {
|
|
361
|
-
return messages;
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// 退化:把 input array 当作单条 user content blocks
|
|
365
|
-
const fallbackContent = toChatContent(input);
|
|
366
|
-
if (fallbackContent) {
|
|
367
|
-
return [{ role: 'user', content: fallbackContent }];
|
|
368
|
-
}
|
|
369
|
-
return [];
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
function convertResponsesRequestToChatCompletions(payload) {
|
|
373
|
-
const body = payload && typeof payload === 'object' ? payload : {};
|
|
374
|
-
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
|
375
|
-
if (!model) {
|
|
376
|
-
return { error: 'responses 请求缺少 model' };
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
const messages = [];
|
|
380
|
-
// Align with Maxx/CLIProxyAPI style: map "instructions" to a leading system message.
|
|
381
|
-
if (typeof body.instructions === 'string' && body.instructions.trim()) {
|
|
382
|
-
messages.push({ role: 'system', content: body.instructions.trim() });
|
|
383
|
-
}
|
|
384
|
-
messages.push(...normalizeResponsesInputToChatMessages(body.input));
|
|
385
|
-
if (!messages.length) {
|
|
386
|
-
// codex sometimes sends empty input for probes; tolerate.
|
|
387
|
-
messages.push({ role: 'user', content: '' });
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
const maxOutputTokens = Number.parseInt(String(body.max_output_tokens), 10);
|
|
391
|
-
const stream = body.stream === true;
|
|
392
|
-
|
|
393
|
-
const chat = {
|
|
394
|
-
model,
|
|
395
|
-
messages,
|
|
396
|
-
stream: false,
|
|
397
|
-
temperature: Number.isFinite(body.temperature) ? Number(body.temperature) : undefined,
|
|
398
|
-
top_p: Number.isFinite(body.top_p) ? Number(body.top_p) : undefined,
|
|
399
|
-
max_tokens: Number.isFinite(maxOutputTokens) && maxOutputTokens > 0 ? maxOutputTokens : undefined
|
|
400
|
-
};
|
|
401
|
-
if (Array.isArray(body.stop) && body.stop.length) {
|
|
402
|
-
chat.stop = body.stop.filter((item) => typeof item === 'string' && item.trim());
|
|
403
|
-
}
|
|
404
|
-
// Best-effort: pass through tool definitions (most OpenAI-compatible providers accept these fields).
|
|
405
|
-
if (Array.isArray(body.tools) && body.tools.length) {
|
|
406
|
-
chat.tools = body.tools;
|
|
407
|
-
}
|
|
408
|
-
if (body.tool_choice !== undefined) {
|
|
409
|
-
chat.tool_choice = body.tool_choice;
|
|
410
|
-
}
|
|
411
|
-
if (body.response_format !== undefined) {
|
|
412
|
-
chat.response_format = body.response_format;
|
|
413
|
-
}
|
|
414
|
-
if (body.metadata !== undefined) {
|
|
415
|
-
chat.metadata = body.metadata;
|
|
416
|
-
}
|
|
417
|
-
|
|
418
|
-
// Remove undefined keys
|
|
419
|
-
Object.keys(chat).forEach((key) => chat[key] === undefined && delete chat[key]);
|
|
420
|
-
|
|
421
|
-
return { chat, streamRequested: stream };
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
function buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamPayload) {
|
|
425
|
-
const responseId = `resp_${crypto.randomBytes(10).toString('hex')}`;
|
|
426
|
-
const usage = upstreamPayload && upstreamPayload.usage && typeof upstreamPayload.usage === 'object'
|
|
427
|
-
? upstreamPayload.usage
|
|
428
|
-
: null;
|
|
429
|
-
const createdAt = Math.floor(Date.now() / 1000);
|
|
430
|
-
const output = [];
|
|
431
|
-
const trimmedText = typeof text === 'string' ? text : '';
|
|
432
|
-
if (trimmedText) {
|
|
433
|
-
output.push({
|
|
434
|
-
id: `msg_${crypto.randomBytes(8).toString('hex')}`,
|
|
435
|
-
type: 'message',
|
|
436
|
-
role: 'assistant',
|
|
437
|
-
content: [{ type: 'output_text', text: trimmedText }]
|
|
438
|
-
});
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
// Convert chat.completions tool_calls into Responses-style function_call output items.
|
|
442
|
-
// This is important for Codex, which appends function_call + function_call_output back into `input`.
|
|
443
|
-
if (Array.isArray(toolCalls)) {
|
|
444
|
-
for (const call of toolCalls) {
|
|
445
|
-
if (!call || typeof call !== 'object') continue;
|
|
446
|
-
const callId = typeof call.id === 'string' && call.id.trim() ? call.id.trim() : `call_${crypto.randomBytes(8).toString('hex')}`;
|
|
447
|
-
const fn = call.function && typeof call.function === 'object' ? call.function : {};
|
|
448
|
-
const name = typeof fn.name === 'string' ? fn.name : '';
|
|
449
|
-
const args = typeof fn.arguments === 'string' ? fn.arguments : '';
|
|
450
|
-
if (!name) continue;
|
|
451
|
-
output.push({
|
|
452
|
-
type: 'function_call',
|
|
453
|
-
call_id: callId,
|
|
454
|
-
name,
|
|
455
|
-
arguments: args
|
|
456
|
-
});
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
|
|
460
|
-
const payload = {
|
|
461
|
-
id: responseId,
|
|
462
|
-
object: 'response',
|
|
463
|
-
model,
|
|
464
|
-
created_at: createdAt,
|
|
465
|
-
status: 'completed',
|
|
466
|
-
output,
|
|
467
|
-
output_text: trimmedText
|
|
468
|
-
};
|
|
469
|
-
|
|
470
|
-
if (usage) {
|
|
471
|
-
// Map chat.completions usage -> responses usage shape when possible.
|
|
472
|
-
const promptTokens = Number.isFinite(usage.prompt_tokens) ? Number(usage.prompt_tokens) : null;
|
|
473
|
-
const completionTokens = Number.isFinite(usage.completion_tokens) ? Number(usage.completion_tokens) : null;
|
|
474
|
-
const totalTokens = Number.isFinite(usage.total_tokens) ? Number(usage.total_tokens) : null;
|
|
475
|
-
if (promptTokens !== null || completionTokens !== null || totalTokens !== null) {
|
|
476
|
-
payload.usage = {
|
|
477
|
-
input_tokens: promptTokens ?? undefined,
|
|
478
|
-
output_tokens: completionTokens ?? undefined,
|
|
479
|
-
total_tokens: totalTokens ?? undefined
|
|
480
|
-
};
|
|
481
|
-
Object.keys(payload.usage).forEach((key) => payload.usage[key] === undefined && delete payload.usage[key]);
|
|
482
|
-
} else {
|
|
483
|
-
payload.usage = usage;
|
|
484
|
-
}
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
return payload;
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
function ensureResponseMetadata(response) {
|
|
491
|
-
const payload = response && typeof response === 'object' ? response : {};
|
|
492
|
-
if (typeof payload.created_at !== 'number') {
|
|
493
|
-
payload.created_at = Math.floor(Date.now() / 1000);
|
|
494
|
-
}
|
|
495
|
-
if (typeof payload.status !== 'string' || !payload.status.trim()) {
|
|
496
|
-
payload.status = 'completed';
|
|
497
|
-
}
|
|
498
|
-
if (!Array.isArray(payload.output)) {
|
|
499
|
-
payload.output = [];
|
|
500
|
-
}
|
|
501
|
-
return payload;
|
|
502
|
-
}
|
|
503
|
-
|
|
504
|
-
function sendResponsesSse(res, responsePayload) {
|
|
505
|
-
const response = ensureResponseMetadata(responsePayload);
|
|
506
|
-
const responseId = typeof response.id === 'string' && response.id.trim()
|
|
507
|
-
? response.id.trim()
|
|
508
|
-
: `resp_${crypto.randomBytes(10).toString('hex')}`;
|
|
509
|
-
const model = typeof response.model === 'string' ? response.model : '';
|
|
510
|
-
|
|
511
|
-
let sequence = 0;
|
|
512
|
-
const nextSeq = () => {
|
|
513
|
-
sequence += 1;
|
|
514
|
-
return sequence;
|
|
515
|
-
};
|
|
516
|
-
|
|
517
|
-
writeSse(res, 'response.created', {
|
|
518
|
-
type: 'response.created',
|
|
519
|
-
response: {
|
|
520
|
-
id: responseId,
|
|
521
|
-
model,
|
|
522
|
-
created_at: response.created_at
|
|
523
|
-
}
|
|
524
|
-
});
|
|
525
|
-
|
|
526
|
-
const output = Array.isArray(response.output) ? response.output : [];
|
|
527
|
-
for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
|
|
528
|
-
const item = output[outputIndex];
|
|
529
|
-
if (!item || typeof item !== 'object') continue;
|
|
530
|
-
const itemType = typeof item.type === 'string' ? item.type : '';
|
|
531
|
-
const itemId = typeof item.id === 'string' && item.id.trim()
|
|
532
|
-
? item.id.trim()
|
|
533
|
-
: (typeof item.call_id === 'string' && item.call_id.trim() ? item.call_id.trim() : `item_${crypto.randomBytes(8).toString('hex')}`);
|
|
534
|
-
|
|
535
|
-
// Emit item added so Codex can anchor subsequent deltas by output_index/content_index/item_id.
|
|
536
|
-
writeSse(res, 'response.output_item.added', {
|
|
537
|
-
type: 'response.output_item.added',
|
|
538
|
-
output_index: outputIndex,
|
|
539
|
-
item: { ...item, id: itemId }
|
|
540
|
-
});
|
|
541
|
-
|
|
542
|
-
if (itemType === 'message') {
|
|
543
|
-
const content = Array.isArray(item.content) ? item.content : [];
|
|
544
|
-
for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
|
|
545
|
-
const block = content[contentIndex];
|
|
546
|
-
if (!block || typeof block !== 'object') continue;
|
|
547
|
-
if (block.type !== 'output_text') continue;
|
|
548
|
-
const text = typeof block.text === 'string' ? block.text : '';
|
|
549
|
-
if (text) {
|
|
550
|
-
writeSse(res, 'response.output_text.delta', {
|
|
551
|
-
type: 'response.output_text.delta',
|
|
552
|
-
item_id: itemId,
|
|
553
|
-
output_index: outputIndex,
|
|
554
|
-
content_index: contentIndex,
|
|
555
|
-
delta: text,
|
|
556
|
-
sequence_number: nextSeq()
|
|
557
|
-
});
|
|
558
|
-
}
|
|
559
|
-
writeSse(res, 'response.output_text.done', {
|
|
560
|
-
type: 'response.output_text.done',
|
|
561
|
-
item_id: itemId,
|
|
562
|
-
output_index: outputIndex,
|
|
563
|
-
content_index: contentIndex,
|
|
564
|
-
text,
|
|
565
|
-
sequence_number: nextSeq()
|
|
566
|
-
});
|
|
567
|
-
}
|
|
568
|
-
}
|
|
569
|
-
|
|
570
|
-
// Emit item done for all item types (message/function_call/etc).
|
|
571
|
-
writeSse(res, 'response.output_item.done', {
|
|
572
|
-
type: 'response.output_item.done',
|
|
573
|
-
output_index: outputIndex,
|
|
574
|
-
item: { ...item, id: itemId },
|
|
575
|
-
sequence_number: nextSeq()
|
|
576
|
-
});
|
|
577
|
-
}
|
|
578
|
-
|
|
579
|
-
writeSse(res, 'response.completed', { type: 'response.completed', response });
|
|
580
|
-
writeSse(res, 'done', '[DONE]');
|
|
581
|
-
}
|
|
582
|
-
|
|
583
|
-
function extractResponsesOutputText(payload) {
|
|
584
|
-
if (!payload || typeof payload !== 'object') return '';
|
|
585
|
-
const output = Array.isArray(payload.output) ? payload.output : [];
|
|
586
|
-
for (const item of output) {
|
|
587
|
-
if (!item || typeof item !== 'object') continue;
|
|
588
|
-
if (item.type !== 'message') continue;
|
|
589
|
-
const content = Array.isArray(item.content) ? item.content : [];
|
|
590
|
-
for (const block of content) {
|
|
591
|
-
if (!block || typeof block !== 'object') continue;
|
|
592
|
-
if (block.type !== 'output_text') continue;
|
|
593
|
-
if (typeof block.text === 'string') return block.text;
|
|
594
|
-
}
|
|
595
|
-
}
|
|
596
|
-
if (typeof payload.output_text === 'string') return payload.output_text;
|
|
597
|
-
return '';
|
|
598
|
-
}
|
|
599
|
-
|
|
600
|
-
function toUpstreamNonStreamingResponsesPayload(payload) {
|
|
601
|
-
const body = payload && typeof payload === 'object' ? payload : {};
|
|
602
|
-
return { ...body, stream: false };
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
function shouldFallbackFromUpstreamResponses(status, bodyText) {
|
|
606
|
-
if (!Number.isFinite(status)) return false;
|
|
607
|
-
// Common "unsupported" status codes for a route.
|
|
608
|
-
if (status === 404 || status === 405 || status === 501) return true;
|
|
609
|
-
|
|
610
|
-
// Some OpenAI-compatible gateways respond with 500 + "not implemented" (e.g. convert_request_failed)
|
|
611
|
-
// instead of 404/405 for unsupported endpoints. In that case we can safely fallback to chat/completions.
|
|
612
|
-
const text = String(bodyText || '');
|
|
613
|
-
if (!text) return false;
|
|
614
|
-
if (/not implemented/i.test(text)) return true;
|
|
615
|
-
if (/convert_request_failed/i.test(text)) return true;
|
|
616
|
-
if (/unknown (endpoint|route)/i.test(text)) return true;
|
|
617
|
-
if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
|
|
618
|
-
if (/does not support.*responses/i.test(text)) return true;
|
|
619
|
-
|
|
620
|
-
// Best-effort parse for structured error codes.
|
|
621
|
-
try {
|
|
622
|
-
const parsed = JSON.parse(text);
|
|
623
|
-
const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
|
|
624
|
-
const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
|
|
625
|
-
if (code === 'convert_request_failed') return true;
|
|
626
|
-
if (/not implemented/i.test(msg)) return true;
|
|
627
|
-
if (/unknown (endpoint|route)/i.test(msg)) return true;
|
|
628
|
-
if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
|
|
629
|
-
if (/does not support.*responses/i.test(msg)) return true;
|
|
630
|
-
} catch (_) {}
|
|
631
|
-
|
|
632
|
-
return false;
|
|
633
|
-
}
|
|
634
|
-
|
|
635
|
-
function isLoopbackAddress(address) {
|
|
636
|
-
if (!address) return false;
|
|
637
|
-
const value = String(address);
|
|
638
|
-
return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
|
|
639
|
-
}
|
|
640
|
-
|
|
641
|
-
function writeSse(res, eventName, dataObj) {
|
|
642
|
-
if (eventName) {
|
|
643
|
-
res.write(`event: ${eventName}\n`);
|
|
644
|
-
}
|
|
645
|
-
if (dataObj === '[DONE]') {
|
|
646
|
-
res.write('data: [DONE]\n\n');
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
|
-
res.write(`data: ${JSON.stringify(dataObj)}\n\n`);
|
|
650
|
-
}
|
|
651
|
-
|
|
652
|
-
async function proxyRequestJson(targetUrl, options = {}) {
|
|
653
|
-
const parsed = new URL(targetUrl);
|
|
654
|
-
const transport = parsed.protocol === 'https:' ? https : http;
|
|
655
|
-
const bodyText = options.body ? JSON.stringify(options.body) : '';
|
|
656
|
-
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
|
|
657
|
-
? Math.floor(options.maxBytes)
|
|
658
|
-
: 0;
|
|
659
|
-
const headers = {
|
|
660
|
-
'Accept': 'application/json',
|
|
661
|
-
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
662
|
-
...(options.headers || {})
|
|
663
|
-
};
|
|
664
|
-
if (options.body) {
|
|
665
|
-
headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
|
|
666
|
-
}
|
|
667
|
-
|
|
668
|
-
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
669
|
-
? Math.max(1000, Number(options.timeoutMs))
|
|
670
|
-
: 30000;
|
|
671
|
-
return new Promise((resolve) => {
|
|
672
|
-
let settled = false;
|
|
673
|
-
const finish = (value) => {
|
|
674
|
-
if (settled) return;
|
|
675
|
-
settled = true;
|
|
676
|
-
resolve(value);
|
|
677
|
-
};
|
|
678
|
-
const req = transport.request({
|
|
679
|
-
protocol: parsed.protocol,
|
|
680
|
-
hostname: parsed.hostname,
|
|
681
|
-
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
682
|
-
method: options.method || 'GET',
|
|
683
|
-
path: `${parsed.pathname}${parsed.search}`,
|
|
684
|
-
headers,
|
|
685
|
-
agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
|
|
686
|
-
}, (upstreamRes) => {
|
|
687
|
-
const chunks = [];
|
|
688
|
-
let size = 0;
|
|
689
|
-
upstreamRes.on('data', (chunk) => {
|
|
690
|
-
if (!chunk) return;
|
|
691
|
-
if (maxBytes > 0) {
|
|
692
|
-
size += chunk.length;
|
|
693
|
-
if (size > maxBytes) {
|
|
694
|
-
chunks.length = 0;
|
|
695
|
-
try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
|
|
696
|
-
try { req.destroy(new Error('response too large')); } catch (_) {}
|
|
697
|
-
finish({ ok: false, error: 'response too large' });
|
|
698
|
-
return;
|
|
699
|
-
}
|
|
700
|
-
}
|
|
701
|
-
chunks.push(chunk);
|
|
702
|
-
});
|
|
703
|
-
upstreamRes.on('end', () => {
|
|
704
|
-
const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
|
|
705
|
-
finish({
|
|
706
|
-
ok: true,
|
|
707
|
-
status: upstreamRes.statusCode || 0,
|
|
708
|
-
headers: upstreamRes.headers || {},
|
|
709
|
-
bodyText: text
|
|
710
|
-
});
|
|
711
|
-
});
|
|
712
|
-
});
|
|
713
|
-
req.setTimeout(timeoutMs, () => {
|
|
714
|
-
try { req.destroy(new Error('timeout')); } catch (_) {}
|
|
715
|
-
finish({ ok: false, error: 'timeout' });
|
|
716
|
-
});
|
|
717
|
-
req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
|
|
718
|
-
if (bodyText) {
|
|
719
|
-
req.write(bodyText);
|
|
720
|
-
}
|
|
721
|
-
req.end();
|
|
722
|
-
});
|
|
723
|
-
}
|
|
724
|
-
|
|
725
|
-
function createOpenaiBridgeHttpHandler(options = {}) {
|
|
726
|
-
const settingsFile = options.settingsFile;
|
|
727
|
-
const expectedTokenRaw = typeof options.expectedToken === 'string' ? options.expectedToken.trim() : '';
|
|
728
|
-
const expectedToken = Object.prototype.hasOwnProperty.call(options, 'expectedToken')
|
|
729
|
-
? expectedTokenRaw
|
|
730
|
-
: (expectedTokenRaw || DEFAULT_BRIDGE_TOKEN);
|
|
731
|
-
const maxBodySize = Number.isFinite(options.maxBodySize) ? options.maxBodySize : 0;
|
|
732
|
-
const httpAgent = options.httpAgent;
|
|
733
|
-
const httpsAgent = options.httpsAgent;
|
|
734
|
-
const maxUpstreamBytes = Number.isFinite(options.maxUpstreamBytes) && options.maxUpstreamBytes > 0
|
|
735
|
-
? Math.floor(options.maxUpstreamBytes)
|
|
736
|
-
: Math.max(16 * 1024 * 1024, maxBodySize > 0 ? maxBodySize * 4 : 0);
|
|
737
|
-
|
|
738
|
-
if (!settingsFile) {
|
|
739
|
-
throw new Error('createOpenaiBridgeHttpHandler 缺少 settingsFile');
|
|
740
|
-
}
|
|
741
|
-
|
|
742
|
-
const matchPath = (requestPath) => {
|
|
743
|
-
const normalized = String(requestPath || '');
|
|
744
|
-
const prefix = '/bridge/openai/';
|
|
745
|
-
if (!normalized.startsWith(prefix)) return null;
|
|
746
|
-
const rest = normalized.slice(prefix.length);
|
|
747
|
-
const [provider, ...tail] = rest.split('/').filter((part) => part.length > 0);
|
|
748
|
-
if (!provider) return null;
|
|
749
|
-
const tailPath = '/' + tail.join('/');
|
|
750
|
-
if (!tailPath.startsWith('/v1')) return null;
|
|
751
|
-
const suffix = tailPath === '/v1' ? '' : tailPath.replace(/^\/v1\/?/, '');
|
|
752
|
-
return { provider, suffix };
|
|
753
|
-
};
|
|
754
|
-
|
|
755
|
-
const handler = (req, res) => {
|
|
756
|
-
let parsedUrl;
|
|
757
|
-
try {
|
|
758
|
-
parsedUrl = new URL(req.url || '/', 'http://localhost');
|
|
759
|
-
} catch (_) {
|
|
760
|
-
return false;
|
|
761
|
-
}
|
|
762
|
-
const match = matchPath(parsedUrl.pathname || '/');
|
|
763
|
-
if (!match) return false;
|
|
764
|
-
|
|
765
|
-
void (async () => {
|
|
766
|
-
try {
|
|
767
|
-
const token = extractAuthorizationToken(req);
|
|
768
|
-
// 兼容:某些客户端在自定义 base_url 时可能不带 Authorization。
|
|
769
|
-
// 为避免在 LAN 暴露无鉴权的代理,这里仅允许 loopback 连接缺省 token。
|
|
770
|
-
const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
|
|
771
|
-
const isLoopback = isLoopbackAddress(remoteAddr);
|
|
772
|
-
if (!isLoopback && !expectedToken) {
|
|
773
|
-
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
774
|
-
res.end(JSON.stringify({ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)' }));
|
|
775
|
-
return;
|
|
776
|
-
}
|
|
777
|
-
if (!token && !isLoopback) {
|
|
778
|
-
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
779
|
-
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
780
|
-
return;
|
|
781
|
-
}
|
|
782
|
-
// loopback 上的本地代理:允许客户端携带任意 Authorization(例如 Codex 会附带 provider apiKey)。
|
|
783
|
-
// 非 loopback 时仍强制校验 expectedToken,避免局域网被未授权调用。
|
|
784
|
-
if (!isLoopback && token && token !== expectedToken) {
|
|
785
|
-
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
786
|
-
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
787
|
-
return;
|
|
788
|
-
}
|
|
789
|
-
|
|
790
|
-
const upstream = resolveOpenaiBridgeUpstream(settingsFile, match.provider);
|
|
791
|
-
if (upstream.error) {
|
|
792
|
-
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
793
|
-
res.end(JSON.stringify({ error: upstream.error }));
|
|
794
|
-
return;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
const suffix = match.suffix || '';
|
|
798
|
-
const normalizedSuffix = suffix.replace(/^\/+/, '');
|
|
799
|
-
|
|
800
|
-
const authHeader = upstream.apiKey
|
|
801
|
-
? (/^bearer\s+/i.test(upstream.apiKey) ? upstream.apiKey : `Bearer ${upstream.apiKey}`)
|
|
802
|
-
: '';
|
|
803
|
-
const upstreamHeaders = upstream && upstream.headers && typeof upstream.headers === 'object' && !Array.isArray(upstream.headers)
|
|
804
|
-
? upstream.headers
|
|
805
|
-
: {};
|
|
806
|
-
|
|
807
|
-
if (!normalizedSuffix || normalizedSuffix === 'models') {
|
|
808
|
-
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
|
809
|
-
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
810
|
-
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
811
|
-
return;
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
const url = joinApiUrl(upstream.baseUrl, 'models');
|
|
815
|
-
const result = await proxyRequestJson(url, {
|
|
816
|
-
method: 'GET',
|
|
817
|
-
headers: {
|
|
818
|
-
...(authHeader ? { Authorization: authHeader } : {}),
|
|
819
|
-
...upstreamHeaders
|
|
820
|
-
},
|
|
821
|
-
maxBytes: maxUpstreamBytes,
|
|
822
|
-
httpAgent,
|
|
823
|
-
httpsAgent
|
|
824
|
-
});
|
|
825
|
-
if (!result.ok) {
|
|
826
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
827
|
-
res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` }));
|
|
828
|
-
return;
|
|
829
|
-
}
|
|
830
|
-
res.writeHead(result.status || 502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
831
|
-
res.end(result.bodyText || '');
|
|
832
|
-
return;
|
|
833
|
-
}
|
|
834
|
-
|
|
835
|
-
if (normalizedSuffix !== 'responses') {
|
|
836
|
-
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
837
|
-
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
838
|
-
return;
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
if ((req.method || 'GET').toUpperCase() !== 'POST') {
|
|
842
|
-
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
843
|
-
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
844
|
-
return;
|
|
845
|
-
}
|
|
846
|
-
|
|
847
|
-
const { body, error: bodyErr } = await readRequestBody(req, maxBodySize);
|
|
848
|
-
if (bodyErr) {
|
|
849
|
-
res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
850
|
-
res.end(JSON.stringify({ error: bodyErr }));
|
|
851
|
-
return;
|
|
852
|
-
}
|
|
853
|
-
const parsed = parseJsonOrError(body);
|
|
854
|
-
if (parsed.error) {
|
|
855
|
-
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
856
|
-
res.end(JSON.stringify({ error: `Invalid JSON: ${parsed.error}` }));
|
|
857
|
-
return;
|
|
858
|
-
}
|
|
859
|
-
|
|
860
|
-
const responsesRequest = parsed.value;
|
|
861
|
-
const streamRequested = !!(responsesRequest && typeof responsesRequest === 'object' && responsesRequest.stream === true);
|
|
862
|
-
const acceptHeader = req && req.headers ? (req.headers.accept || req.headers.Accept || '') : '';
|
|
863
|
-
const wantsSse = /text\/event-stream/i.test(String(acceptHeader || ''));
|
|
864
|
-
|
|
865
|
-
// Maxx-style behavior: prefer upstream /responses if supported.
|
|
866
|
-
// Fallback to /chat/completions conversion when upstream does not implement /responses (404/405).
|
|
867
|
-
const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
|
|
868
|
-
const upstreamResponsesResult = await proxyRequestJson(upstreamResponsesUrl, {
|
|
869
|
-
method: 'POST',
|
|
870
|
-
body: toUpstreamNonStreamingResponsesPayload(responsesRequest),
|
|
871
|
-
headers: {
|
|
872
|
-
...(authHeader ? { Authorization: authHeader } : {}),
|
|
873
|
-
...upstreamHeaders
|
|
874
|
-
},
|
|
875
|
-
maxBytes: maxUpstreamBytes,
|
|
876
|
-
httpAgent,
|
|
877
|
-
httpsAgent
|
|
878
|
-
});
|
|
879
|
-
|
|
880
|
-
if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) {
|
|
881
|
-
const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText);
|
|
882
|
-
if (upstreamJson.error) {
|
|
883
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
884
|
-
res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
|
|
885
|
-
return;
|
|
886
|
-
}
|
|
887
|
-
const upstreamPayload = upstreamJson.value;
|
|
888
|
-
if (streamRequested && wantsSse) {
|
|
889
|
-
res.writeHead(200, {
|
|
890
|
-
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
891
|
-
'Cache-Control': 'no-cache',
|
|
892
|
-
'Connection': 'keep-alive',
|
|
893
|
-
'X-Accel-Buffering': 'no'
|
|
894
|
-
});
|
|
895
|
-
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
896
|
-
sendResponsesSse(res, upstreamPayload);
|
|
897
|
-
res.end();
|
|
898
|
-
return;
|
|
899
|
-
}
|
|
900
|
-
|
|
901
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
902
|
-
res.end(JSON.stringify(ensureResponseMetadata(upstreamPayload)));
|
|
903
|
-
return;
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 400) {
|
|
907
|
-
if (!shouldFallbackFromUpstreamResponses(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
|
|
908
|
-
res.writeHead(upstreamResponsesResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
909
|
-
res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
|
|
910
|
-
return;
|
|
911
|
-
}
|
|
912
|
-
// fallthrough to chat/completions conversion
|
|
913
|
-
}
|
|
914
|
-
|
|
915
|
-
if (!upstreamResponsesResult.ok) {
|
|
916
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
917
|
-
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResponsesResult.error}` }));
|
|
918
|
-
return;
|
|
919
|
-
}
|
|
920
|
-
|
|
921
|
-
const converted = convertResponsesRequestToChatCompletions(responsesRequest);
|
|
922
|
-
if (converted.error) {
|
|
923
|
-
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
924
|
-
res.end(JSON.stringify({ error: converted.error }));
|
|
925
|
-
return;
|
|
926
|
-
}
|
|
927
|
-
|
|
928
|
-
const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
|
|
929
|
-
const upstreamResult = await proxyRequestJson(upstreamUrl, {
|
|
930
|
-
method: 'POST',
|
|
931
|
-
body: converted.chat,
|
|
932
|
-
headers: {
|
|
933
|
-
...(authHeader ? { Authorization: authHeader } : {}),
|
|
934
|
-
...upstreamHeaders
|
|
935
|
-
},
|
|
936
|
-
maxBytes: maxUpstreamBytes,
|
|
937
|
-
httpAgent,
|
|
938
|
-
httpsAgent
|
|
939
|
-
});
|
|
940
|
-
if (!upstreamResult.ok) {
|
|
941
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
942
|
-
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));
|
|
943
|
-
return;
|
|
944
|
-
}
|
|
945
|
-
|
|
946
|
-
const upstreamJson = parseJsonOrError(upstreamResult.bodyText);
|
|
947
|
-
if (upstreamResult.status >= 400) {
|
|
948
|
-
res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
949
|
-
res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
|
|
950
|
-
return;
|
|
951
|
-
}
|
|
952
|
-
if (upstreamJson.error) {
|
|
953
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
954
|
-
res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
|
|
955
|
-
return;
|
|
956
|
-
}
|
|
957
|
-
|
|
958
|
-
const model = typeof converted.chat.model === 'string' ? converted.chat.model : '';
|
|
959
|
-
const extracted = extractChatCompletionResult(upstreamJson.value);
|
|
960
|
-
const text = extracted && typeof extracted.text === 'string' ? extracted.text : '';
|
|
961
|
-
const toolCalls = extracted && Array.isArray(extracted.toolCalls) ? extracted.toolCalls : [];
|
|
962
|
-
const responsesPayload = buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamJson.value);
|
|
963
|
-
|
|
964
|
-
if (converted.streamRequested && wantsSse) {
|
|
965
|
-
res.writeHead(200, {
|
|
966
|
-
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
967
|
-
'Cache-Control': 'no-cache',
|
|
968
|
-
'Connection': 'keep-alive',
|
|
969
|
-
'X-Accel-Buffering': 'no'
|
|
970
|
-
});
|
|
971
|
-
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
972
|
-
sendResponsesSse(res, responsesPayload);
|
|
973
|
-
res.end();
|
|
974
|
-
return;
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
978
|
-
res.end(JSON.stringify(ensureResponseMetadata(responsesPayload)));
|
|
979
|
-
} catch (e) {
|
|
980
|
-
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
981
|
-
res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' }));
|
|
982
|
-
}
|
|
983
|
-
})();
|
|
984
|
-
|
|
985
|
-
return true;
|
|
986
|
-
};
|
|
987
|
-
|
|
988
|
-
handler.matchPath = matchPath;
|
|
989
|
-
return handler;
|
|
990
|
-
}
|
|
991
|
-
|
|
992
|
-
module.exports = {
|
|
993
|
-
readOpenaiBridgeSettings,
|
|
994
|
-
upsertOpenaiBridgeProvider,
|
|
995
|
-
resolveOpenaiBridgeUpstream,
|
|
996
|
-
createOpenaiBridgeHttpHandler
|
|
997
|
-
};
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils');
|
|
5
|
+
const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils');
|
|
6
|
+
|
|
7
|
+
const DEFAULT_BRIDGE_TOKEN = 'codexmate';
|
|
8
|
+
const SETTINGS_VERSION = 1;
|
|
9
|
+
|
|
10
|
+
function normalizeText(value) {
|
|
11
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function normalizeProviderName(value) {
|
|
15
|
+
// Provider name validation is done elsewhere; keep this conservative.
|
|
16
|
+
return normalizeText(value);
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeOpenaiUpstreamBaseUrl(rawValue) {
|
|
20
|
+
const normalized = normalizeBaseUrl(rawValue);
|
|
21
|
+
if (!normalized) return '';
|
|
22
|
+
try {
|
|
23
|
+
const parsed = new URL(normalized);
|
|
24
|
+
let pathname = String(parsed.pathname || '').replace(/\/+$/g, '');
|
|
25
|
+
|
|
26
|
+
// If user accidentally pasted a full endpoint, strip it back to the base URL.
|
|
27
|
+
// Keep direct provider routes (e.g. /project/ym) intact.
|
|
28
|
+
pathname = pathname
|
|
29
|
+
.replace(/\/v1\/chat\/completions$/i, '/v1')
|
|
30
|
+
.replace(/\/chat\/completions$/i, '')
|
|
31
|
+
.replace(/\/v1\/responses$/i, '/v1')
|
|
32
|
+
.replace(/\/responses$/i, '')
|
|
33
|
+
.replace(/\/v1\/models$/i, '/v1')
|
|
34
|
+
.replace(/\/models$/i, '');
|
|
35
|
+
|
|
36
|
+
// Normalize empty/root path.
|
|
37
|
+
if (pathname === '/') pathname = '';
|
|
38
|
+
|
|
39
|
+
const rebuilt = `${parsed.origin}${pathname}`;
|
|
40
|
+
return normalizeBaseUrl(rebuilt);
|
|
41
|
+
} catch (_) {
|
|
42
|
+
return normalized;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function normalizeUpstreamEntry(entry) {
|
|
47
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
const baseUrl = normalizeOpenaiUpstreamBaseUrl(entry.baseUrl || entry.base_url || '');
|
|
51
|
+
const apiKey = normalizeText(entry.apiKey || entry.api_key || entry.key || '');
|
|
52
|
+
const headersRaw = entry.headers || entry.extraHeaders || entry.extra_headers || null;
|
|
53
|
+
const headers = normalizeHeadersMap(headersRaw);
|
|
54
|
+
if (!baseUrl || !isValidHttpUrl(baseUrl)) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
return { baseUrl, apiKey, headers };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeHeadersMap(value) {
|
|
61
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
62
|
+
return {};
|
|
63
|
+
}
|
|
64
|
+
const forbidden = new Set([
|
|
65
|
+
'authorization',
|
|
66
|
+
'host',
|
|
67
|
+
'content-length',
|
|
68
|
+
'connection',
|
|
69
|
+
'transfer-encoding',
|
|
70
|
+
'keep-alive',
|
|
71
|
+
'proxy-authenticate',
|
|
72
|
+
'proxy-authorization',
|
|
73
|
+
'te',
|
|
74
|
+
'trailer',
|
|
75
|
+
'upgrade'
|
|
76
|
+
]);
|
|
77
|
+
const result = {};
|
|
78
|
+
for (const [rawKey, rawVal] of Object.entries(value)) {
|
|
79
|
+
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
|
|
80
|
+
if (!key) continue;
|
|
81
|
+
const lower = key.toLowerCase();
|
|
82
|
+
if (forbidden.has(lower)) continue;
|
|
83
|
+
if (typeof rawVal !== 'string') continue;
|
|
84
|
+
result[key] = rawVal;
|
|
85
|
+
}
|
|
86
|
+
return result;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function readOpenaiBridgeSettings(filePath) {
|
|
90
|
+
const parsed = readJsonFile(filePath, null);
|
|
91
|
+
const providers = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
92
|
+
? parsed.providers
|
|
93
|
+
: null;
|
|
94
|
+
const providerMap = providers && typeof providers === 'object' && !Array.isArray(providers)
|
|
95
|
+
? providers
|
|
96
|
+
: {};
|
|
97
|
+
return {
|
|
98
|
+
version: SETTINGS_VERSION,
|
|
99
|
+
providers: providerMap
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, apiKey, headers) {
|
|
104
|
+
const name = normalizeProviderName(providerName);
|
|
105
|
+
const baseUrl = normalizeOpenaiUpstreamBaseUrl(upstreamBaseUrl);
|
|
106
|
+
const key = normalizeText(apiKey);
|
|
107
|
+
const nextHeaders = normalizeHeadersMap(headers);
|
|
108
|
+
|
|
109
|
+
if (!name) {
|
|
110
|
+
return { error: 'Provider name is required' };
|
|
111
|
+
}
|
|
112
|
+
if (!baseUrl || !isValidHttpUrl(baseUrl)) {
|
|
113
|
+
return { error: 'Upstream base URL is invalid' };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const settings = readOpenaiBridgeSettings(filePath);
|
|
117
|
+
const existing = settings && settings.providers ? settings.providers[name] : null;
|
|
118
|
+
const existingHeaders = existing && typeof existing === 'object' && !Array.isArray(existing)
|
|
119
|
+
? normalizeHeadersMap(existing.headers || existing.extraHeaders || existing.extra_headers || null)
|
|
120
|
+
: {};
|
|
121
|
+
const next = {
|
|
122
|
+
version: SETTINGS_VERSION,
|
|
123
|
+
providers: {
|
|
124
|
+
...(settings.providers || {}),
|
|
125
|
+
[name]: {
|
|
126
|
+
baseUrl,
|
|
127
|
+
apiKey: key,
|
|
128
|
+
headers: Object.keys(nextHeaders).length ? nextHeaders : existingHeaders
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
writeJsonAtomic(filePath, next);
|
|
133
|
+
return { success: true };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function resolveOpenaiBridgeUpstream(filePath, providerName) {
|
|
137
|
+
const name = normalizeProviderName(providerName);
|
|
138
|
+
if (!name) return { error: 'Provider name is required' };
|
|
139
|
+
const settings = readOpenaiBridgeSettings(filePath);
|
|
140
|
+
const entry = settings.providers ? settings.providers[name] : null;
|
|
141
|
+
const normalized = normalizeUpstreamEntry(entry);
|
|
142
|
+
if (!normalized) {
|
|
143
|
+
return { error: `OpenAI 转换未配置: ${name}` };
|
|
144
|
+
}
|
|
145
|
+
return { provider: name, ...normalized };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function extractAuthorizationToken(req) {
|
|
149
|
+
const header = typeof req.headers.authorization === 'string' ? req.headers.authorization.trim() : '';
|
|
150
|
+
if (!header) return '';
|
|
151
|
+
if (/^bearer\s+/i.test(header)) {
|
|
152
|
+
return header.replace(/^bearer\s+/i, '').trim();
|
|
153
|
+
}
|
|
154
|
+
return header;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function readRequestBody(req, maxBytes) {
|
|
158
|
+
return new Promise((resolve) => {
|
|
159
|
+
let body = '';
|
|
160
|
+
let size = 0;
|
|
161
|
+
let aborted = false;
|
|
162
|
+
req.on('data', (chunk) => {
|
|
163
|
+
if (aborted) return;
|
|
164
|
+
size += chunk.length;
|
|
165
|
+
if (Number.isFinite(maxBytes) && maxBytes > 0 && size > maxBytes) {
|
|
166
|
+
aborted = true;
|
|
167
|
+
try { req.destroy(); } catch (_) {}
|
|
168
|
+
resolve({ error: '请求体过大' });
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
body += chunk;
|
|
172
|
+
});
|
|
173
|
+
req.on('end', () => {
|
|
174
|
+
if (aborted) return;
|
|
175
|
+
resolve({ body });
|
|
176
|
+
});
|
|
177
|
+
req.on('error', (err) => resolve({ error: err && err.message ? err.message : 'request failed' }));
|
|
178
|
+
});
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function parseJsonOrError(text) {
|
|
182
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
183
|
+
return { value: null, error: 'empty body' };
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
return { value: JSON.parse(text), error: '' };
|
|
187
|
+
} catch (e) {
|
|
188
|
+
return { value: null, error: e && e.message ? e.message : 'invalid json' };
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function extractChatCompletionResult(payload) {
|
|
193
|
+
if (!payload || typeof payload !== 'object') return { text: '', toolCalls: [] };
|
|
194
|
+
const choice = Array.isArray(payload.choices) ? payload.choices[0] : null;
|
|
195
|
+
const message = choice && typeof choice === 'object' ? choice.message : null;
|
|
196
|
+
const toolCalls = message && typeof message === 'object' && Array.isArray(message.tool_calls)
|
|
197
|
+
? message.tool_calls
|
|
198
|
+
: [];
|
|
199
|
+
const content = message && typeof message === 'object' ? message.content : '';
|
|
200
|
+
let text = '';
|
|
201
|
+
if (typeof content === 'string') {
|
|
202
|
+
text = content;
|
|
203
|
+
} else if (Array.isArray(content)) {
|
|
204
|
+
text = content
|
|
205
|
+
.map((item) => {
|
|
206
|
+
if (!item) return '';
|
|
207
|
+
if (typeof item === 'string') return item;
|
|
208
|
+
if (typeof item === 'object') {
|
|
209
|
+
if (typeof item.text === 'string') return item.text;
|
|
210
|
+
if (typeof item.content === 'string') return item.content;
|
|
211
|
+
}
|
|
212
|
+
return '';
|
|
213
|
+
})
|
|
214
|
+
.filter(Boolean)
|
|
215
|
+
.join('');
|
|
216
|
+
}
|
|
217
|
+
return { text, toolCalls };
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function normalizeResponsesInputToChatMessages(input) {
|
|
221
|
+
// 支持:
|
|
222
|
+
// - string
|
|
223
|
+
// - { role, content }(单条 message)
|
|
224
|
+
// - { type:"input_text"|"input_image", ... }(单个 block)
|
|
225
|
+
// - [{ role, content: [...] }](messages array)
|
|
226
|
+
// - [{ type:"input_text"|"input_image", ... }](blocks array -> 单条 user 消息)
|
|
227
|
+
if (typeof input === 'string') {
|
|
228
|
+
return [{ role: 'user', content: input }];
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
const toChatContent = (blocks) => {
|
|
232
|
+
if (!Array.isArray(blocks)) return '';
|
|
233
|
+
const out = [];
|
|
234
|
+
for (const block of blocks) {
|
|
235
|
+
if (!block || typeof block !== 'object') continue;
|
|
236
|
+
const type = typeof block.type === 'string' ? block.type : '';
|
|
237
|
+
if ((type === 'input_text' || type === 'output_text') && typeof block.text === 'string') {
|
|
238
|
+
out.push({ type: 'text', text: block.text });
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
if ((type === 'reasoning' || type === 'reasoning_text' || type === 'reasoning_content') && typeof block.text === 'string') {
|
|
242
|
+
out.push({ type: 'text', text: block.text });
|
|
243
|
+
continue;
|
|
244
|
+
}
|
|
245
|
+
if (type === 'input_image') {
|
|
246
|
+
const raw = block.image_url != null ? block.image_url : block.imageUrl;
|
|
247
|
+
const url = typeof raw === 'string'
|
|
248
|
+
? raw
|
|
249
|
+
: (raw && typeof raw === 'object' && typeof raw.url === 'string' ? raw.url : '');
|
|
250
|
+
if (url) {
|
|
251
|
+
out.push({ type: 'image_url', image_url: { url } });
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
// 容错:兼容已是 chat content 的 {type:"text"} / {type:"image_url"}
|
|
256
|
+
if (type === 'text' && typeof block.text === 'string') {
|
|
257
|
+
out.push({ type: 'text', text: block.text });
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if (type === 'image_url' && block.image_url) {
|
|
261
|
+
out.push({ type: 'image_url', image_url: block.image_url });
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
const text = typeof block.text === 'string'
|
|
265
|
+
? block.text
|
|
266
|
+
: (typeof block.content === 'string' ? block.content : '');
|
|
267
|
+
if (text) {
|
|
268
|
+
out.push({ type: 'text', text });
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
try {
|
|
272
|
+
const raw = JSON.stringify(block);
|
|
273
|
+
if (raw) {
|
|
274
|
+
out.push({ type: 'text', text: raw.slice(0, 4000) });
|
|
275
|
+
}
|
|
276
|
+
} catch (_) {}
|
|
277
|
+
}
|
|
278
|
+
if (out.length === 0) return '';
|
|
279
|
+
return out;
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
const toRole = (value) => {
|
|
283
|
+
const roleRaw = typeof value === 'string' ? value.trim().toLowerCase() : '';
|
|
284
|
+
return roleRaw === 'assistant' ? 'assistant' : (roleRaw === 'system' ? 'system' : 'user');
|
|
285
|
+
};
|
|
286
|
+
|
|
287
|
+
if (input && typeof input === 'object' && !Array.isArray(input)) {
|
|
288
|
+
if (typeof input.role === 'string' && input.content != null) {
|
|
289
|
+
const role = toRole(input.role);
|
|
290
|
+
const content = Array.isArray(input.content)
|
|
291
|
+
? toChatContent(input.content)
|
|
292
|
+
: input.content;
|
|
293
|
+
return content ? [{ role, content }] : [];
|
|
294
|
+
}
|
|
295
|
+
if (typeof input.type === 'string') {
|
|
296
|
+
const content = toChatContent([input]);
|
|
297
|
+
return content ? [{ role: 'user', content }] : [];
|
|
298
|
+
}
|
|
299
|
+
return [];
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!Array.isArray(input)) {
|
|
303
|
+
return [];
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
const messages = [];
|
|
307
|
+
for (const item of input) {
|
|
308
|
+
if (!item || typeof item !== 'object') continue;
|
|
309
|
+
|
|
310
|
+
// Tool calls (Responses): { type: "function_call", call_id, name, arguments }
|
|
311
|
+
// Chat Completions equivalent: assistant message with tool_calls
|
|
312
|
+
if (typeof item.type === 'string' && item.type === 'function_call') {
|
|
313
|
+
const callId = typeof item.call_id === 'string' ? item.call_id.trim() : '';
|
|
314
|
+
const name = typeof item.name === 'string' ? item.name.trim() : '';
|
|
315
|
+
const args = typeof item.arguments === 'string' ? item.arguments : '';
|
|
316
|
+
if (callId && name) {
|
|
317
|
+
messages.push({
|
|
318
|
+
role: 'assistant',
|
|
319
|
+
tool_calls: [{
|
|
320
|
+
id: callId,
|
|
321
|
+
type: 'function',
|
|
322
|
+
function: { name, arguments: args || '' }
|
|
323
|
+
}]
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
continue;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Tool results (Responses): { type: "function_call_output", call_id, output }
|
|
330
|
+
// Chat Completions equivalent: { role: "tool", tool_call_id, content }
|
|
331
|
+
if (typeof item.type === 'string' && item.type === 'function_call_output') {
|
|
332
|
+
const toolCallId = typeof item.call_id === 'string' ? item.call_id.trim() : '';
|
|
333
|
+
let content = item.output;
|
|
334
|
+
if (typeof content !== 'string') {
|
|
335
|
+
try {
|
|
336
|
+
content = JSON.stringify(content);
|
|
337
|
+
} catch (_) {
|
|
338
|
+
content = String(content ?? '');
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
if (toolCallId) {
|
|
342
|
+
messages.push({ role: 'tool', tool_call_id: toolCallId, content: String(content || '') });
|
|
343
|
+
}
|
|
344
|
+
continue;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
// message form
|
|
348
|
+
if (typeof item.role === 'string' && item.content != null) {
|
|
349
|
+
const role = toRole(item.role);
|
|
350
|
+
const content = Array.isArray(item.content)
|
|
351
|
+
? toChatContent(item.content)
|
|
352
|
+
: item.content;
|
|
353
|
+
if (content) {
|
|
354
|
+
messages.push({ role, content });
|
|
355
|
+
}
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
if (messages.length > 0) {
|
|
361
|
+
return messages;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// 退化:把 input array 当作单条 user content blocks
|
|
365
|
+
const fallbackContent = toChatContent(input);
|
|
366
|
+
if (fallbackContent) {
|
|
367
|
+
return [{ role: 'user', content: fallbackContent }];
|
|
368
|
+
}
|
|
369
|
+
return [];
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function convertResponsesRequestToChatCompletions(payload) {
|
|
373
|
+
const body = payload && typeof payload === 'object' ? payload : {};
|
|
374
|
+
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
|
375
|
+
if (!model) {
|
|
376
|
+
return { error: 'responses 请求缺少 model' };
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const messages = [];
|
|
380
|
+
// Align with Maxx/CLIProxyAPI style: map "instructions" to a leading system message.
|
|
381
|
+
if (typeof body.instructions === 'string' && body.instructions.trim()) {
|
|
382
|
+
messages.push({ role: 'system', content: body.instructions.trim() });
|
|
383
|
+
}
|
|
384
|
+
messages.push(...normalizeResponsesInputToChatMessages(body.input));
|
|
385
|
+
if (!messages.length) {
|
|
386
|
+
// codex sometimes sends empty input for probes; tolerate.
|
|
387
|
+
messages.push({ role: 'user', content: '' });
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
const maxOutputTokens = Number.parseInt(String(body.max_output_tokens), 10);
|
|
391
|
+
const stream = body.stream === true;
|
|
392
|
+
|
|
393
|
+
const chat = {
|
|
394
|
+
model,
|
|
395
|
+
messages,
|
|
396
|
+
stream: false,
|
|
397
|
+
temperature: Number.isFinite(body.temperature) ? Number(body.temperature) : undefined,
|
|
398
|
+
top_p: Number.isFinite(body.top_p) ? Number(body.top_p) : undefined,
|
|
399
|
+
max_tokens: Number.isFinite(maxOutputTokens) && maxOutputTokens > 0 ? maxOutputTokens : undefined
|
|
400
|
+
};
|
|
401
|
+
if (Array.isArray(body.stop) && body.stop.length) {
|
|
402
|
+
chat.stop = body.stop.filter((item) => typeof item === 'string' && item.trim());
|
|
403
|
+
}
|
|
404
|
+
// Best-effort: pass through tool definitions (most OpenAI-compatible providers accept these fields).
|
|
405
|
+
if (Array.isArray(body.tools) && body.tools.length) {
|
|
406
|
+
chat.tools = body.tools;
|
|
407
|
+
}
|
|
408
|
+
if (body.tool_choice !== undefined) {
|
|
409
|
+
chat.tool_choice = body.tool_choice;
|
|
410
|
+
}
|
|
411
|
+
if (body.response_format !== undefined) {
|
|
412
|
+
chat.response_format = body.response_format;
|
|
413
|
+
}
|
|
414
|
+
if (body.metadata !== undefined) {
|
|
415
|
+
chat.metadata = body.metadata;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
// Remove undefined keys
|
|
419
|
+
Object.keys(chat).forEach((key) => chat[key] === undefined && delete chat[key]);
|
|
420
|
+
|
|
421
|
+
return { chat, streamRequested: stream };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamPayload) {
|
|
425
|
+
const responseId = `resp_${crypto.randomBytes(10).toString('hex')}`;
|
|
426
|
+
const usage = upstreamPayload && upstreamPayload.usage && typeof upstreamPayload.usage === 'object'
|
|
427
|
+
? upstreamPayload.usage
|
|
428
|
+
: null;
|
|
429
|
+
const createdAt = Math.floor(Date.now() / 1000);
|
|
430
|
+
const output = [];
|
|
431
|
+
const trimmedText = typeof text === 'string' ? text : '';
|
|
432
|
+
if (trimmedText) {
|
|
433
|
+
output.push({
|
|
434
|
+
id: `msg_${crypto.randomBytes(8).toString('hex')}`,
|
|
435
|
+
type: 'message',
|
|
436
|
+
role: 'assistant',
|
|
437
|
+
content: [{ type: 'output_text', text: trimmedText }]
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Convert chat.completions tool_calls into Responses-style function_call output items.
|
|
442
|
+
// This is important for Codex, which appends function_call + function_call_output back into `input`.
|
|
443
|
+
if (Array.isArray(toolCalls)) {
|
|
444
|
+
for (const call of toolCalls) {
|
|
445
|
+
if (!call || typeof call !== 'object') continue;
|
|
446
|
+
const callId = typeof call.id === 'string' && call.id.trim() ? call.id.trim() : `call_${crypto.randomBytes(8).toString('hex')}`;
|
|
447
|
+
const fn = call.function && typeof call.function === 'object' ? call.function : {};
|
|
448
|
+
const name = typeof fn.name === 'string' ? fn.name : '';
|
|
449
|
+
const args = typeof fn.arguments === 'string' ? fn.arguments : '';
|
|
450
|
+
if (!name) continue;
|
|
451
|
+
output.push({
|
|
452
|
+
type: 'function_call',
|
|
453
|
+
call_id: callId,
|
|
454
|
+
name,
|
|
455
|
+
arguments: args
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
const payload = {
|
|
461
|
+
id: responseId,
|
|
462
|
+
object: 'response',
|
|
463
|
+
model,
|
|
464
|
+
created_at: createdAt,
|
|
465
|
+
status: 'completed',
|
|
466
|
+
output,
|
|
467
|
+
output_text: trimmedText
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
if (usage) {
|
|
471
|
+
// Map chat.completions usage -> responses usage shape when possible.
|
|
472
|
+
const promptTokens = Number.isFinite(usage.prompt_tokens) ? Number(usage.prompt_tokens) : null;
|
|
473
|
+
const completionTokens = Number.isFinite(usage.completion_tokens) ? Number(usage.completion_tokens) : null;
|
|
474
|
+
const totalTokens = Number.isFinite(usage.total_tokens) ? Number(usage.total_tokens) : null;
|
|
475
|
+
if (promptTokens !== null || completionTokens !== null || totalTokens !== null) {
|
|
476
|
+
payload.usage = {
|
|
477
|
+
input_tokens: promptTokens ?? undefined,
|
|
478
|
+
output_tokens: completionTokens ?? undefined,
|
|
479
|
+
total_tokens: totalTokens ?? undefined
|
|
480
|
+
};
|
|
481
|
+
Object.keys(payload.usage).forEach((key) => payload.usage[key] === undefined && delete payload.usage[key]);
|
|
482
|
+
} else {
|
|
483
|
+
payload.usage = usage;
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
return payload;
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
function ensureResponseMetadata(response) {
|
|
491
|
+
const payload = response && typeof response === 'object' ? response : {};
|
|
492
|
+
if (typeof payload.created_at !== 'number') {
|
|
493
|
+
payload.created_at = Math.floor(Date.now() / 1000);
|
|
494
|
+
}
|
|
495
|
+
if (typeof payload.status !== 'string' || !payload.status.trim()) {
|
|
496
|
+
payload.status = 'completed';
|
|
497
|
+
}
|
|
498
|
+
if (!Array.isArray(payload.output)) {
|
|
499
|
+
payload.output = [];
|
|
500
|
+
}
|
|
501
|
+
return payload;
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function sendResponsesSse(res, responsePayload) {
|
|
505
|
+
const response = ensureResponseMetadata(responsePayload);
|
|
506
|
+
const responseId = typeof response.id === 'string' && response.id.trim()
|
|
507
|
+
? response.id.trim()
|
|
508
|
+
: `resp_${crypto.randomBytes(10).toString('hex')}`;
|
|
509
|
+
const model = typeof response.model === 'string' ? response.model : '';
|
|
510
|
+
|
|
511
|
+
let sequence = 0;
|
|
512
|
+
const nextSeq = () => {
|
|
513
|
+
sequence += 1;
|
|
514
|
+
return sequence;
|
|
515
|
+
};
|
|
516
|
+
|
|
517
|
+
writeSse(res, 'response.created', {
|
|
518
|
+
type: 'response.created',
|
|
519
|
+
response: {
|
|
520
|
+
id: responseId,
|
|
521
|
+
model,
|
|
522
|
+
created_at: response.created_at
|
|
523
|
+
}
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
const output = Array.isArray(response.output) ? response.output : [];
|
|
527
|
+
for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
|
|
528
|
+
const item = output[outputIndex];
|
|
529
|
+
if (!item || typeof item !== 'object') continue;
|
|
530
|
+
const itemType = typeof item.type === 'string' ? item.type : '';
|
|
531
|
+
const itemId = typeof item.id === 'string' && item.id.trim()
|
|
532
|
+
? item.id.trim()
|
|
533
|
+
: (typeof item.call_id === 'string' && item.call_id.trim() ? item.call_id.trim() : `item_${crypto.randomBytes(8).toString('hex')}`);
|
|
534
|
+
|
|
535
|
+
// Emit item added so Codex can anchor subsequent deltas by output_index/content_index/item_id.
|
|
536
|
+
writeSse(res, 'response.output_item.added', {
|
|
537
|
+
type: 'response.output_item.added',
|
|
538
|
+
output_index: outputIndex,
|
|
539
|
+
item: { ...item, id: itemId }
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
if (itemType === 'message') {
|
|
543
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
544
|
+
for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
|
|
545
|
+
const block = content[contentIndex];
|
|
546
|
+
if (!block || typeof block !== 'object') continue;
|
|
547
|
+
if (block.type !== 'output_text') continue;
|
|
548
|
+
const text = typeof block.text === 'string' ? block.text : '';
|
|
549
|
+
if (text) {
|
|
550
|
+
writeSse(res, 'response.output_text.delta', {
|
|
551
|
+
type: 'response.output_text.delta',
|
|
552
|
+
item_id: itemId,
|
|
553
|
+
output_index: outputIndex,
|
|
554
|
+
content_index: contentIndex,
|
|
555
|
+
delta: text,
|
|
556
|
+
sequence_number: nextSeq()
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
writeSse(res, 'response.output_text.done', {
|
|
560
|
+
type: 'response.output_text.done',
|
|
561
|
+
item_id: itemId,
|
|
562
|
+
output_index: outputIndex,
|
|
563
|
+
content_index: contentIndex,
|
|
564
|
+
text,
|
|
565
|
+
sequence_number: nextSeq()
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
// Emit item done for all item types (message/function_call/etc).
|
|
571
|
+
writeSse(res, 'response.output_item.done', {
|
|
572
|
+
type: 'response.output_item.done',
|
|
573
|
+
output_index: outputIndex,
|
|
574
|
+
item: { ...item, id: itemId },
|
|
575
|
+
sequence_number: nextSeq()
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
writeSse(res, 'response.completed', { type: 'response.completed', response });
|
|
580
|
+
writeSse(res, 'done', '[DONE]');
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
function extractResponsesOutputText(payload) {
|
|
584
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
585
|
+
const output = Array.isArray(payload.output) ? payload.output : [];
|
|
586
|
+
for (const item of output) {
|
|
587
|
+
if (!item || typeof item !== 'object') continue;
|
|
588
|
+
if (item.type !== 'message') continue;
|
|
589
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
590
|
+
for (const block of content) {
|
|
591
|
+
if (!block || typeof block !== 'object') continue;
|
|
592
|
+
if (block.type !== 'output_text') continue;
|
|
593
|
+
if (typeof block.text === 'string') return block.text;
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
if (typeof payload.output_text === 'string') return payload.output_text;
|
|
597
|
+
return '';
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function toUpstreamNonStreamingResponsesPayload(payload) {
|
|
601
|
+
const body = payload && typeof payload === 'object' ? payload : {};
|
|
602
|
+
return { ...body, stream: false };
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
function shouldFallbackFromUpstreamResponses(status, bodyText) {
|
|
606
|
+
if (!Number.isFinite(status)) return false;
|
|
607
|
+
// Common "unsupported" status codes for a route.
|
|
608
|
+
if (status === 404 || status === 405 || status === 501) return true;
|
|
609
|
+
|
|
610
|
+
// Some OpenAI-compatible gateways respond with 500 + "not implemented" (e.g. convert_request_failed)
|
|
611
|
+
// instead of 404/405 for unsupported endpoints. In that case we can safely fallback to chat/completions.
|
|
612
|
+
const text = String(bodyText || '');
|
|
613
|
+
if (!text) return false;
|
|
614
|
+
if (/not implemented/i.test(text)) return true;
|
|
615
|
+
if (/convert_request_failed/i.test(text)) return true;
|
|
616
|
+
if (/unknown (endpoint|route)/i.test(text)) return true;
|
|
617
|
+
if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
|
|
618
|
+
if (/does not support.*responses/i.test(text)) return true;
|
|
619
|
+
|
|
620
|
+
// Best-effort parse for structured error codes.
|
|
621
|
+
try {
|
|
622
|
+
const parsed = JSON.parse(text);
|
|
623
|
+
const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
|
|
624
|
+
const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
|
|
625
|
+
if (code === 'convert_request_failed') return true;
|
|
626
|
+
if (/not implemented/i.test(msg)) return true;
|
|
627
|
+
if (/unknown (endpoint|route)/i.test(msg)) return true;
|
|
628
|
+
if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
|
|
629
|
+
if (/does not support.*responses/i.test(msg)) return true;
|
|
630
|
+
} catch (_) {}
|
|
631
|
+
|
|
632
|
+
return false;
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
function isLoopbackAddress(address) {
|
|
636
|
+
if (!address) return false;
|
|
637
|
+
const value = String(address);
|
|
638
|
+
return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function writeSse(res, eventName, dataObj) {
|
|
642
|
+
if (eventName) {
|
|
643
|
+
res.write(`event: ${eventName}\n`);
|
|
644
|
+
}
|
|
645
|
+
if (dataObj === '[DONE]') {
|
|
646
|
+
res.write('data: [DONE]\n\n');
|
|
647
|
+
return;
|
|
648
|
+
}
|
|
649
|
+
res.write(`data: ${JSON.stringify(dataObj)}\n\n`);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
async function proxyRequestJson(targetUrl, options = {}) {
|
|
653
|
+
const parsed = new URL(targetUrl);
|
|
654
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
655
|
+
const bodyText = options.body ? JSON.stringify(options.body) : '';
|
|
656
|
+
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
|
|
657
|
+
? Math.floor(options.maxBytes)
|
|
658
|
+
: 0;
|
|
659
|
+
const headers = {
|
|
660
|
+
'Accept': 'application/json',
|
|
661
|
+
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
662
|
+
...(options.headers || {})
|
|
663
|
+
};
|
|
664
|
+
if (options.body) {
|
|
665
|
+
headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
669
|
+
? Math.max(1000, Number(options.timeoutMs))
|
|
670
|
+
: 30000;
|
|
671
|
+
return new Promise((resolve) => {
|
|
672
|
+
let settled = false;
|
|
673
|
+
const finish = (value) => {
|
|
674
|
+
if (settled) return;
|
|
675
|
+
settled = true;
|
|
676
|
+
resolve(value);
|
|
677
|
+
};
|
|
678
|
+
const req = transport.request({
|
|
679
|
+
protocol: parsed.protocol,
|
|
680
|
+
hostname: parsed.hostname,
|
|
681
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
682
|
+
method: options.method || 'GET',
|
|
683
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
684
|
+
headers,
|
|
685
|
+
agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
|
|
686
|
+
}, (upstreamRes) => {
|
|
687
|
+
const chunks = [];
|
|
688
|
+
let size = 0;
|
|
689
|
+
upstreamRes.on('data', (chunk) => {
|
|
690
|
+
if (!chunk) return;
|
|
691
|
+
if (maxBytes > 0) {
|
|
692
|
+
size += chunk.length;
|
|
693
|
+
if (size > maxBytes) {
|
|
694
|
+
chunks.length = 0;
|
|
695
|
+
try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
|
|
696
|
+
try { req.destroy(new Error('response too large')); } catch (_) {}
|
|
697
|
+
finish({ ok: false, error: 'response too large' });
|
|
698
|
+
return;
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
chunks.push(chunk);
|
|
702
|
+
});
|
|
703
|
+
upstreamRes.on('end', () => {
|
|
704
|
+
const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
|
|
705
|
+
finish({
|
|
706
|
+
ok: true,
|
|
707
|
+
status: upstreamRes.statusCode || 0,
|
|
708
|
+
headers: upstreamRes.headers || {},
|
|
709
|
+
bodyText: text
|
|
710
|
+
});
|
|
711
|
+
});
|
|
712
|
+
});
|
|
713
|
+
req.setTimeout(timeoutMs, () => {
|
|
714
|
+
try { req.destroy(new Error('timeout')); } catch (_) {}
|
|
715
|
+
finish({ ok: false, error: 'timeout' });
|
|
716
|
+
});
|
|
717
|
+
req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
|
|
718
|
+
if (bodyText) {
|
|
719
|
+
req.write(bodyText);
|
|
720
|
+
}
|
|
721
|
+
req.end();
|
|
722
|
+
});
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
function createOpenaiBridgeHttpHandler(options = {}) {
|
|
726
|
+
const settingsFile = options.settingsFile;
|
|
727
|
+
const expectedTokenRaw = typeof options.expectedToken === 'string' ? options.expectedToken.trim() : '';
|
|
728
|
+
const expectedToken = Object.prototype.hasOwnProperty.call(options, 'expectedToken')
|
|
729
|
+
? expectedTokenRaw
|
|
730
|
+
: (expectedTokenRaw || DEFAULT_BRIDGE_TOKEN);
|
|
731
|
+
const maxBodySize = Number.isFinite(options.maxBodySize) ? options.maxBodySize : 0;
|
|
732
|
+
const httpAgent = options.httpAgent;
|
|
733
|
+
const httpsAgent = options.httpsAgent;
|
|
734
|
+
const maxUpstreamBytes = Number.isFinite(options.maxUpstreamBytes) && options.maxUpstreamBytes > 0
|
|
735
|
+
? Math.floor(options.maxUpstreamBytes)
|
|
736
|
+
: Math.max(16 * 1024 * 1024, maxBodySize > 0 ? maxBodySize * 4 : 0);
|
|
737
|
+
|
|
738
|
+
if (!settingsFile) {
|
|
739
|
+
throw new Error('createOpenaiBridgeHttpHandler 缺少 settingsFile');
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
const matchPath = (requestPath) => {
|
|
743
|
+
const normalized = String(requestPath || '');
|
|
744
|
+
const prefix = '/bridge/openai/';
|
|
745
|
+
if (!normalized.startsWith(prefix)) return null;
|
|
746
|
+
const rest = normalized.slice(prefix.length);
|
|
747
|
+
const [provider, ...tail] = rest.split('/').filter((part) => part.length > 0);
|
|
748
|
+
if (!provider) return null;
|
|
749
|
+
const tailPath = '/' + tail.join('/');
|
|
750
|
+
if (!tailPath.startsWith('/v1')) return null;
|
|
751
|
+
const suffix = tailPath === '/v1' ? '' : tailPath.replace(/^\/v1\/?/, '');
|
|
752
|
+
return { provider, suffix };
|
|
753
|
+
};
|
|
754
|
+
|
|
755
|
+
const handler = (req, res) => {
|
|
756
|
+
let parsedUrl;
|
|
757
|
+
try {
|
|
758
|
+
parsedUrl = new URL(req.url || '/', 'http://localhost');
|
|
759
|
+
} catch (_) {
|
|
760
|
+
return false;
|
|
761
|
+
}
|
|
762
|
+
const match = matchPath(parsedUrl.pathname || '/');
|
|
763
|
+
if (!match) return false;
|
|
764
|
+
|
|
765
|
+
void (async () => {
|
|
766
|
+
try {
|
|
767
|
+
const token = extractAuthorizationToken(req);
|
|
768
|
+
// 兼容:某些客户端在自定义 base_url 时可能不带 Authorization。
|
|
769
|
+
// 为避免在 LAN 暴露无鉴权的代理,这里仅允许 loopback 连接缺省 token。
|
|
770
|
+
const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
|
|
771
|
+
const isLoopback = isLoopbackAddress(remoteAddr);
|
|
772
|
+
if (!isLoopback && !expectedToken) {
|
|
773
|
+
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
774
|
+
res.end(JSON.stringify({ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)' }));
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
if (!token && !isLoopback) {
|
|
778
|
+
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
779
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
780
|
+
return;
|
|
781
|
+
}
|
|
782
|
+
// loopback 上的本地代理:允许客户端携带任意 Authorization(例如 Codex 会附带 provider apiKey)。
|
|
783
|
+
// 非 loopback 时仍强制校验 expectedToken,避免局域网被未授权调用。
|
|
784
|
+
if (!isLoopback && token && token !== expectedToken) {
|
|
785
|
+
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
786
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
787
|
+
return;
|
|
788
|
+
}
|
|
789
|
+
|
|
790
|
+
const upstream = resolveOpenaiBridgeUpstream(settingsFile, match.provider);
|
|
791
|
+
if (upstream.error) {
|
|
792
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
793
|
+
res.end(JSON.stringify({ error: upstream.error }));
|
|
794
|
+
return;
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
const suffix = match.suffix || '';
|
|
798
|
+
const normalizedSuffix = suffix.replace(/^\/+/, '');
|
|
799
|
+
|
|
800
|
+
const authHeader = upstream.apiKey
|
|
801
|
+
? (/^bearer\s+/i.test(upstream.apiKey) ? upstream.apiKey : `Bearer ${upstream.apiKey}`)
|
|
802
|
+
: '';
|
|
803
|
+
const upstreamHeaders = upstream && upstream.headers && typeof upstream.headers === 'object' && !Array.isArray(upstream.headers)
|
|
804
|
+
? upstream.headers
|
|
805
|
+
: {};
|
|
806
|
+
|
|
807
|
+
if (!normalizedSuffix || normalizedSuffix === 'models') {
|
|
808
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
|
809
|
+
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
810
|
+
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
const url = joinApiUrl(upstream.baseUrl, 'models');
|
|
815
|
+
const result = await proxyRequestJson(url, {
|
|
816
|
+
method: 'GET',
|
|
817
|
+
headers: {
|
|
818
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
819
|
+
...upstreamHeaders
|
|
820
|
+
},
|
|
821
|
+
maxBytes: maxUpstreamBytes,
|
|
822
|
+
httpAgent,
|
|
823
|
+
httpsAgent
|
|
824
|
+
});
|
|
825
|
+
if (!result.ok) {
|
|
826
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
827
|
+
res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` }));
|
|
828
|
+
return;
|
|
829
|
+
}
|
|
830
|
+
res.writeHead(result.status || 502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
831
|
+
res.end(result.bodyText || '');
|
|
832
|
+
return;
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
if (normalizedSuffix !== 'responses') {
|
|
836
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
837
|
+
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') {
|
|
842
|
+
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
843
|
+
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
const { body, error: bodyErr } = await readRequestBody(req, maxBodySize);
|
|
848
|
+
if (bodyErr) {
|
|
849
|
+
res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
850
|
+
res.end(JSON.stringify({ error: bodyErr }));
|
|
851
|
+
return;
|
|
852
|
+
}
|
|
853
|
+
const parsed = parseJsonOrError(body);
|
|
854
|
+
if (parsed.error) {
|
|
855
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
856
|
+
res.end(JSON.stringify({ error: `Invalid JSON: ${parsed.error}` }));
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
const responsesRequest = parsed.value;
|
|
861
|
+
const streamRequested = !!(responsesRequest && typeof responsesRequest === 'object' && responsesRequest.stream === true);
|
|
862
|
+
const acceptHeader = req && req.headers ? (req.headers.accept || req.headers.Accept || '') : '';
|
|
863
|
+
const wantsSse = /text\/event-stream/i.test(String(acceptHeader || ''));
|
|
864
|
+
|
|
865
|
+
// Maxx-style behavior: prefer upstream /responses if supported.
|
|
866
|
+
// Fallback to /chat/completions conversion when upstream does not implement /responses (404/405).
|
|
867
|
+
const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
|
|
868
|
+
const upstreamResponsesResult = await proxyRequestJson(upstreamResponsesUrl, {
|
|
869
|
+
method: 'POST',
|
|
870
|
+
body: toUpstreamNonStreamingResponsesPayload(responsesRequest),
|
|
871
|
+
headers: {
|
|
872
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
873
|
+
...upstreamHeaders
|
|
874
|
+
},
|
|
875
|
+
maxBytes: maxUpstreamBytes,
|
|
876
|
+
httpAgent,
|
|
877
|
+
httpsAgent
|
|
878
|
+
});
|
|
879
|
+
|
|
880
|
+
if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) {
|
|
881
|
+
const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText);
|
|
882
|
+
if (upstreamJson.error) {
|
|
883
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
884
|
+
res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
const upstreamPayload = upstreamJson.value;
|
|
888
|
+
if (streamRequested && wantsSse) {
|
|
889
|
+
res.writeHead(200, {
|
|
890
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
891
|
+
'Cache-Control': 'no-cache',
|
|
892
|
+
'Connection': 'keep-alive',
|
|
893
|
+
'X-Accel-Buffering': 'no'
|
|
894
|
+
});
|
|
895
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
896
|
+
sendResponsesSse(res, upstreamPayload);
|
|
897
|
+
res.end();
|
|
898
|
+
return;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
902
|
+
res.end(JSON.stringify(ensureResponseMetadata(upstreamPayload)));
|
|
903
|
+
return;
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 400) {
|
|
907
|
+
if (!shouldFallbackFromUpstreamResponses(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
|
|
908
|
+
res.writeHead(upstreamResponsesResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
909
|
+
res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
|
|
910
|
+
return;
|
|
911
|
+
}
|
|
912
|
+
// fallthrough to chat/completions conversion
|
|
913
|
+
}
|
|
914
|
+
|
|
915
|
+
if (!upstreamResponsesResult.ok) {
|
|
916
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
917
|
+
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResponsesResult.error}` }));
|
|
918
|
+
return;
|
|
919
|
+
}
|
|
920
|
+
|
|
921
|
+
const converted = convertResponsesRequestToChatCompletions(responsesRequest);
|
|
922
|
+
if (converted.error) {
|
|
923
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
924
|
+
res.end(JSON.stringify({ error: converted.error }));
|
|
925
|
+
return;
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
|
|
929
|
+
const upstreamResult = await proxyRequestJson(upstreamUrl, {
|
|
930
|
+
method: 'POST',
|
|
931
|
+
body: converted.chat,
|
|
932
|
+
headers: {
|
|
933
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
934
|
+
...upstreamHeaders
|
|
935
|
+
},
|
|
936
|
+
maxBytes: maxUpstreamBytes,
|
|
937
|
+
httpAgent,
|
|
938
|
+
httpsAgent
|
|
939
|
+
});
|
|
940
|
+
if (!upstreamResult.ok) {
|
|
941
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
942
|
+
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
const upstreamJson = parseJsonOrError(upstreamResult.bodyText);
|
|
947
|
+
if (upstreamResult.status >= 400) {
|
|
948
|
+
res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
949
|
+
res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
if (upstreamJson.error) {
|
|
953
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
954
|
+
res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
const model = typeof converted.chat.model === 'string' ? converted.chat.model : '';
|
|
959
|
+
const extracted = extractChatCompletionResult(upstreamJson.value);
|
|
960
|
+
const text = extracted && typeof extracted.text === 'string' ? extracted.text : '';
|
|
961
|
+
const toolCalls = extracted && Array.isArray(extracted.toolCalls) ? extracted.toolCalls : [];
|
|
962
|
+
const responsesPayload = buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamJson.value);
|
|
963
|
+
|
|
964
|
+
if (converted.streamRequested && wantsSse) {
|
|
965
|
+
res.writeHead(200, {
|
|
966
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
967
|
+
'Cache-Control': 'no-cache',
|
|
968
|
+
'Connection': 'keep-alive',
|
|
969
|
+
'X-Accel-Buffering': 'no'
|
|
970
|
+
});
|
|
971
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
972
|
+
sendResponsesSse(res, responsesPayload);
|
|
973
|
+
res.end();
|
|
974
|
+
return;
|
|
975
|
+
}
|
|
976
|
+
|
|
977
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
978
|
+
res.end(JSON.stringify(ensureResponseMetadata(responsesPayload)));
|
|
979
|
+
} catch (e) {
|
|
980
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
981
|
+
res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' }));
|
|
982
|
+
}
|
|
983
|
+
})();
|
|
984
|
+
|
|
985
|
+
return true;
|
|
986
|
+
};
|
|
987
|
+
|
|
988
|
+
handler.matchPath = matchPath;
|
|
989
|
+
return handler;
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
module.exports = {
|
|
993
|
+
readOpenaiBridgeSettings,
|
|
994
|
+
upsertOpenaiBridgeProvider,
|
|
995
|
+
resolveOpenaiBridgeUpstream,
|
|
996
|
+
createOpenaiBridgeHttpHandler
|
|
997
|
+
};
|