codexmate 0.0.40 → 0.0.41
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 +168 -156
- package/README.zh.md +168 -156
- package/cli/agents-files.js +230 -230
- package/cli/analytics-export-args.js +68 -68
- package/cli/archive-helpers.js +453 -453
- package/cli/auth-profiles.js +375 -375
- package/cli/builtin-proxy.js +2144 -2144
- package/cli/claude-proxy.js +1022 -1022
- package/cli/config-bootstrap.js +407 -407
- package/cli/config-health.js +454 -454
- package/cli/doctor-core.js +903 -903
- package/cli/import-skills-url.js +356 -356
- package/cli/local-bridge.js +556 -556
- package/cli/openai-bridge.js +1984 -1984
- package/cli/openclaw-config.js +629 -629
- package/cli/session-convert-args.js +69 -69
- package/cli/session-convert-io.js +82 -82
- package/cli/session-convert.js +150 -150
- package/cli/session-usage.concurrent.js +28 -28
- package/cli/session-usage.js +304 -304
- package/cli/session-usage.models.js +176 -176
- package/cli/skills.js +1141 -1141
- package/cli/update.js +171 -171
- package/cli/zip-commands.js +510 -510
- package/cli.js +16458 -16341
- package/lib/automation.js +404 -404
- package/lib/cli-file-utils.js +151 -151
- package/lib/cli-models-utils.js +440 -440
- 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 +427 -427
- package/lib/cli-utils.js +155 -155
- package/lib/cli-webhook.js +154 -154
- 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/win-tray.js +119 -119
- package/lib/workflow-engine.js +340 -340
- package/package.json +77 -77
- 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 +311 -311
- package/plugins/prompt-templates/index.mjs +8 -8
- package/plugins/prompt-templates/manifest.mjs +18 -18
- package/plugins/prompt-templates/methods.mjs +553 -553
- package/plugins/prompt-templates/overview.mjs +91 -91
- 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 +695 -695
- package/web-ui/index.html +37 -37
- package/web-ui/logic.agents-diff.mjs +386 -386
- package/web-ui/logic.claude.mjs +172 -172
- package/web-ui/logic.codex.mjs +69 -69
- package/web-ui/logic.mjs +5 -5
- package/web-ui/logic.runtime.mjs +128 -128
- package/web-ui/logic.session-convert.mjs +70 -70
- package/web-ui/logic.sessions.mjs +782 -782
- package/web-ui/modules/api.mjs +90 -90
- package/web-ui/modules/app.computed.dashboard.mjs +252 -252
- package/web-ui/modules/app.computed.index.mjs +17 -17
- package/web-ui/modules/app.computed.main-tabs.mjs +214 -214
- package/web-ui/modules/app.computed.session.mjs +876 -876
- package/web-ui/modules/app.constants.mjs +15 -15
- package/web-ui/modules/app.methods.agents.mjs +651 -651
- package/web-ui/modules/app.methods.claude-config.mjs +412 -412
- package/web-ui/modules/app.methods.codex-config.mjs +869 -869
- package/web-ui/modules/app.methods.index.mjs +96 -96
- package/web-ui/modules/app.methods.install.mjs +205 -205
- package/web-ui/modules/app.methods.navigation.mjs +804 -804
- package/web-ui/modules/app.methods.openclaw-core.mjs +814 -814
- package/web-ui/modules/app.methods.openclaw-editing.mjs +420 -420
- package/web-ui/modules/app.methods.openclaw-persist.mjs +375 -375
- package/web-ui/modules/app.methods.providers.mjs +601 -601
- package/web-ui/modules/app.methods.runtime.mjs +420 -420
- package/web-ui/modules/app.methods.session-actions.mjs +591 -591
- package/web-ui/modules/app.methods.session-browser.mjs +1018 -1018
- package/web-ui/modules/app.methods.session-timeline.mjs +479 -479
- package/web-ui/modules/app.methods.session-trash.mjs +468 -468
- package/web-ui/modules/app.methods.startup-claude.mjs +554 -554
- package/web-ui/modules/app.methods.task-orchestration.mjs +556 -556
- package/web-ui/modules/app.methods.tool-config-permissions.mjs +87 -87
- package/web-ui/modules/app.methods.webhook.mjs +87 -87
- 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/locales/en.mjs +1140 -1140
- package/web-ui/modules/i18n/locales/ja.mjs +1130 -1130
- package/web-ui/modules/i18n/locales/vi.mjs +239 -239
- package/web-ui/modules/i18n/locales/zh.mjs +1143 -1143
- package/web-ui/modules/i18n.dict.mjs +14 -14
- package/web-ui/modules/i18n.mjs +111 -111
- 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/provider-url-display.mjs +17 -17
- package/web-ui/modules/sessions-filters-url.mjs +138 -138
- package/web-ui/modules/skills.computed.mjs +107 -107
- package/web-ui/modules/skills.methods.mjs +513 -513
- package/web-ui/partials/index/layout-footer.html +13 -13
- package/web-ui/partials/index/layout-header.html +478 -478
- package/web-ui/partials/index/modal-config-template-agents.html +185 -185
- 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 +344 -344
- package/web-ui/partials/index/modal-skills.html +200 -200
- package/web-ui/partials/index/modal-webhook.html +42 -42
- package/web-ui/partials/index/modals-basic.html +263 -263
- package/web-ui/partials/index/panel-config-claude.html +187 -187
- package/web-ui/partials/index/panel-config-codex.html +205 -205
- package/web-ui/partials/index/panel-config-openclaw.html +89 -89
- package/web-ui/partials/index/panel-dashboard.html +171 -171
- package/web-ui/partials/index/panel-docs.html +114 -114
- package/web-ui/partials/index/panel-market.html +104 -104
- package/web-ui/partials/index/panel-orchestration.html +391 -391
- package/web-ui/partials/index/panel-plugins.html +253 -253
- package/web-ui/partials/index/panel-sessions.html +319 -319
- package/web-ui/partials/index/panel-settings.html +181 -181
- package/web-ui/partials/index/panel-trash.html +82 -82
- package/web-ui/partials/index/panel-usage.html +181 -181
- package/web-ui/res/json5.min.js +1 -1
- package/web-ui/res/vue.global.prod.js +13 -13
- package/web-ui/res/vue.runtime.global.prod.js +7 -7
- package/web-ui/res/web-ui-render.precompiled.js +7666 -7666
- package/web-ui/session-helpers.mjs +602 -602
- package/web-ui/source-bundle.cjs +305 -305
- package/web-ui/styles/base-theme.css +291 -291
- package/web-ui/styles/bridge-pool.css +266 -266
- package/web-ui/styles/controls-forms.css +532 -532
- package/web-ui/styles/dashboard.css +438 -438
- package/web-ui/styles/docs-panel.css +245 -245
- 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 +711 -711
- package/web-ui/styles/modals-core.css +499 -499
- package/web-ui/styles/navigation-panels.css +399 -399
- package/web-ui/styles/openclaw-structured.css +616 -616
- package/web-ui/styles/plugins-panel.css +564 -564
- package/web-ui/styles/responsive.css +501 -501
- package/web-ui/styles/sessions-list.css +683 -683
- package/web-ui/styles/sessions-preview.css +407 -407
- package/web-ui/styles/sessions-toolbar-trash.css +518 -518
- package/web-ui/styles/sessions-usage.css +849 -849
- package/web-ui/styles/settings-panel.css +419 -419
- package/web-ui/styles/skills-list.css +305 -305
- package/web-ui/styles/skills-market.css +723 -723
- package/web-ui/styles/task-orchestration.css +822 -822
- package/web-ui/styles/titles-cards.css +486 -486
- package/web-ui/styles/trash-panel.css +90 -90
- package/web-ui/styles/webhook.css +115 -115
- package/web-ui/styles.css +24 -24
- package/web-ui.html +17 -17
package/cli/openai-bridge.js
CHANGED
|
@@ -1,1984 +1,1984 @@
|
|
|
1
|
-
const http = require('http');
|
|
2
|
-
const https = require('https');
|
|
3
|
-
const crypto = require('crypto');
|
|
4
|
-
const { StringDecoder } = require('string_decoder');
|
|
5
|
-
const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils');
|
|
6
|
-
const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils');
|
|
7
|
-
|
|
8
|
-
const DEFAULT_BRIDGE_TOKEN = crypto.randomBytes(16).toString('hex');
|
|
9
|
-
const SETTINGS_VERSION = 1;
|
|
10
|
-
// 推理模型 reasoning 阶段可能长时间无字节输出,需匹配 codex 的 stream_idle_timeout_ms=300000。
|
|
11
|
-
const STREAM_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
12
|
-
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
13
|
-
const RESPONSES_UNSUPPORTED_TTL_MS = 30 * 60 * 1000;
|
|
14
|
-
|
|
15
|
-
function normalizeText(value) {
|
|
16
|
-
return typeof value === 'string' ? value.trim() : '';
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function normalizeProviderName(value) {
|
|
20
|
-
// Provider name validation is done elsewhere; keep this conservative.
|
|
21
|
-
return normalizeText(value);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function normalizeOpenaiUpstreamBaseUrl(rawValue) {
|
|
25
|
-
const normalized = normalizeBaseUrl(rawValue);
|
|
26
|
-
if (!normalized) return '';
|
|
27
|
-
try {
|
|
28
|
-
const parsed = new URL(normalized);
|
|
29
|
-
let pathname = String(parsed.pathname || '').replace(/\/+$/g, '');
|
|
30
|
-
|
|
31
|
-
// If user accidentally pasted a full endpoint, strip it back to the base URL.
|
|
32
|
-
// Keep direct provider routes (e.g. /project/ym) intact.
|
|
33
|
-
pathname = pathname
|
|
34
|
-
.replace(/\/v1\/chat\/completions$/i, '/v1')
|
|
35
|
-
.replace(/\/chat\/completions$/i, '')
|
|
36
|
-
.replace(/\/v1\/responses$/i, '/v1')
|
|
37
|
-
.replace(/\/responses$/i, '')
|
|
38
|
-
.replace(/\/v1\/models$/i, '/v1')
|
|
39
|
-
.replace(/\/models$/i, '');
|
|
40
|
-
|
|
41
|
-
// Normalize empty/root path.
|
|
42
|
-
if (pathname === '/') pathname = '';
|
|
43
|
-
|
|
44
|
-
const rebuilt = `${parsed.origin}${pathname}`;
|
|
45
|
-
return normalizeBaseUrl(rebuilt);
|
|
46
|
-
} catch (_) {
|
|
47
|
-
return normalized;
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
function normalizeUpstreamEntry(entry) {
|
|
52
|
-
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
53
|
-
return null;
|
|
54
|
-
}
|
|
55
|
-
const baseUrl = normalizeOpenaiUpstreamBaseUrl(entry.baseUrl || entry.base_url || '');
|
|
56
|
-
const apiKey = normalizeText(entry.apiKey || entry.api_key || entry.key || '');
|
|
57
|
-
const headersRaw = entry.headers || entry.extraHeaders || entry.extra_headers || null;
|
|
58
|
-
const headers = normalizeHeadersMap(headersRaw);
|
|
59
|
-
if (!baseUrl || !isValidHttpUrl(baseUrl)) {
|
|
60
|
-
return null;
|
|
61
|
-
}
|
|
62
|
-
return { baseUrl, apiKey, headers };
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
function normalizeHeadersMap(value) {
|
|
66
|
-
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
67
|
-
return {};
|
|
68
|
-
}
|
|
69
|
-
const forbidden = new Set([
|
|
70
|
-
'authorization',
|
|
71
|
-
'host',
|
|
72
|
-
'content-length',
|
|
73
|
-
'connection',
|
|
74
|
-
'transfer-encoding',
|
|
75
|
-
'keep-alive',
|
|
76
|
-
'proxy-authenticate',
|
|
77
|
-
'proxy-authorization',
|
|
78
|
-
'te',
|
|
79
|
-
'trailer',
|
|
80
|
-
'upgrade'
|
|
81
|
-
]);
|
|
82
|
-
const result = {};
|
|
83
|
-
for (const [rawKey, rawVal] of Object.entries(value)) {
|
|
84
|
-
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
|
|
85
|
-
if (!key) continue;
|
|
86
|
-
const lower = key.toLowerCase();
|
|
87
|
-
if (forbidden.has(lower)) continue;
|
|
88
|
-
if (typeof rawVal !== 'string') continue;
|
|
89
|
-
result[key] = rawVal;
|
|
90
|
-
}
|
|
91
|
-
return result;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function readOpenaiBridgeSettings(filePath) {
|
|
95
|
-
const parsed = readJsonFile(filePath, null);
|
|
96
|
-
const providers = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
97
|
-
? parsed.providers
|
|
98
|
-
: null;
|
|
99
|
-
const providerMap = providers && typeof providers === 'object' && !Array.isArray(providers)
|
|
100
|
-
? providers
|
|
101
|
-
: {};
|
|
102
|
-
return {
|
|
103
|
-
version: SETTINGS_VERSION,
|
|
104
|
-
providers: providerMap
|
|
105
|
-
};
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, apiKey, headers) {
|
|
109
|
-
const name = normalizeProviderName(providerName);
|
|
110
|
-
const baseUrl = normalizeOpenaiUpstreamBaseUrl(upstreamBaseUrl);
|
|
111
|
-
const key = normalizeText(apiKey);
|
|
112
|
-
const nextHeaders = normalizeHeadersMap(headers);
|
|
113
|
-
|
|
114
|
-
if (!name) {
|
|
115
|
-
return { error: 'Provider name is required' };
|
|
116
|
-
}
|
|
117
|
-
if (!baseUrl || !isValidHttpUrl(baseUrl)) {
|
|
118
|
-
return { error: 'Upstream base URL is invalid' };
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
const settings = readOpenaiBridgeSettings(filePath);
|
|
122
|
-
const existing = settings && settings.providers ? settings.providers[name] : null;
|
|
123
|
-
const existingHeaders = existing && typeof existing === 'object' && !Array.isArray(existing)
|
|
124
|
-
? normalizeHeadersMap(existing.headers || existing.extraHeaders || existing.extra_headers || null)
|
|
125
|
-
: {};
|
|
126
|
-
const next = {
|
|
127
|
-
version: SETTINGS_VERSION,
|
|
128
|
-
providers: {
|
|
129
|
-
...(settings.providers || {}),
|
|
130
|
-
[name]: {
|
|
131
|
-
baseUrl,
|
|
132
|
-
apiKey: key,
|
|
133
|
-
headers: Object.keys(nextHeaders).length ? nextHeaders : existingHeaders
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
writeJsonAtomic(filePath, next);
|
|
138
|
-
return { success: true };
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
function resolveOpenaiBridgeUpstream(filePath, providerName) {
|
|
142
|
-
const name = normalizeProviderName(providerName);
|
|
143
|
-
if (!name) return { error: 'Provider name is required' };
|
|
144
|
-
const settings = readOpenaiBridgeSettings(filePath);
|
|
145
|
-
const entry = settings.providers ? settings.providers[name] : null;
|
|
146
|
-
const normalized = normalizeUpstreamEntry(entry);
|
|
147
|
-
if (!normalized) {
|
|
148
|
-
return { error: `OpenAI 转换未配置: ${name}` };
|
|
149
|
-
}
|
|
150
|
-
return { provider: name, ...normalized };
|
|
151
|
-
}
|
|
152
|
-
|
|
153
|
-
function extractAuthorizationToken(req) {
|
|
154
|
-
const header = typeof req.headers.authorization === 'string' ? req.headers.authorization.trim() : '';
|
|
155
|
-
if (!header) return '';
|
|
156
|
-
if (/^bearer\s+/i.test(header)) {
|
|
157
|
-
return header.replace(/^bearer\s+/i, '').trim();
|
|
158
|
-
}
|
|
159
|
-
return header;
|
|
160
|
-
}
|
|
161
|
-
|
|
162
|
-
function readRequestBody(req, maxBytes) {
|
|
163
|
-
return new Promise((resolve) => {
|
|
164
|
-
let body = '';
|
|
165
|
-
let size = 0;
|
|
166
|
-
let aborted = false;
|
|
167
|
-
req.on('data', (chunk) => {
|
|
168
|
-
if (aborted) return;
|
|
169
|
-
size += chunk.length;
|
|
170
|
-
if (Number.isFinite(maxBytes) && maxBytes > 0 && size > maxBytes) {
|
|
171
|
-
aborted = true;
|
|
172
|
-
try { req.destroy(); } catch (_) {}
|
|
173
|
-
resolve({ error: '请求体过大' });
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
body += chunk;
|
|
177
|
-
});
|
|
178
|
-
req.on('end', () => {
|
|
179
|
-
if (aborted) return;
|
|
180
|
-
resolve({ body });
|
|
181
|
-
});
|
|
182
|
-
req.on('error', (err) => resolve({ error: err && err.message ? err.message : 'request failed' }));
|
|
183
|
-
});
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
function parseJsonOrError(text) {
|
|
187
|
-
if (typeof text !== 'string' || !text.trim()) {
|
|
188
|
-
return { value: null, error: 'empty body' };
|
|
189
|
-
}
|
|
190
|
-
try {
|
|
191
|
-
return { value: JSON.parse(text), error: '' };
|
|
192
|
-
} catch (e) {
|
|
193
|
-
return { value: null, error: e && e.message ? e.message : 'invalid json' };
|
|
194
|
-
}
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function extractChatCompletionResult(payload) {
|
|
198
|
-
if (!payload || typeof payload !== 'object') return { text: '', toolCalls: [] };
|
|
199
|
-
const choice = Array.isArray(payload.choices) ? payload.choices[0] : null;
|
|
200
|
-
const message = choice && typeof choice === 'object' ? choice.message : null;
|
|
201
|
-
const toolCalls = message && typeof message === 'object' && Array.isArray(message.tool_calls)
|
|
202
|
-
? message.tool_calls
|
|
203
|
-
: [];
|
|
204
|
-
const content = message && typeof message === 'object' ? message.content : '';
|
|
205
|
-
let text = '';
|
|
206
|
-
if (typeof content === 'string') {
|
|
207
|
-
text = content;
|
|
208
|
-
} else if (Array.isArray(content)) {
|
|
209
|
-
text = content
|
|
210
|
-
.map((item) => {
|
|
211
|
-
if (!item) return '';
|
|
212
|
-
if (typeof item === 'string') return item;
|
|
213
|
-
if (typeof item === 'object') {
|
|
214
|
-
if (typeof item.text === 'string') return item.text;
|
|
215
|
-
if (typeof item.content === 'string') return item.content;
|
|
216
|
-
}
|
|
217
|
-
return '';
|
|
218
|
-
})
|
|
219
|
-
.filter(Boolean)
|
|
220
|
-
.join('');
|
|
221
|
-
}
|
|
222
|
-
return { text, toolCalls };
|
|
223
|
-
}
|
|
224
|
-
|
|
225
|
-
function stringifyJsonValue(value, fallback = '') {
|
|
226
|
-
if (typeof value === 'string') return value;
|
|
227
|
-
if (value == null) return fallback;
|
|
228
|
-
try {
|
|
229
|
-
return JSON.stringify(value);
|
|
230
|
-
} catch (_) {
|
|
231
|
-
return fallback;
|
|
232
|
-
}
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
function parseJsonValueOrNull(value) {
|
|
236
|
-
if (typeof value !== 'string') return null;
|
|
237
|
-
const text = value.trim();
|
|
238
|
-
if (!text) return null;
|
|
239
|
-
try {
|
|
240
|
-
return JSON.parse(text);
|
|
241
|
-
} catch (_) {
|
|
242
|
-
return null;
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
function isRecord(value) {
|
|
247
|
-
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
function asTrimmedString(value) {
|
|
251
|
-
return typeof value === 'string' ? value.trim() : '';
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
function cloneJsonValue(value) {
|
|
255
|
-
if (Array.isArray(value)) return value.map((item) => cloneJsonValue(item));
|
|
256
|
-
if (isRecord(value)) {
|
|
257
|
-
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]));
|
|
258
|
-
}
|
|
259
|
-
return value;
|
|
260
|
-
}
|
|
261
|
-
|
|
262
|
-
function normalizeResponsesToolOutput(value) {
|
|
263
|
-
if (typeof value === 'string') return value;
|
|
264
|
-
if (value == null) return '';
|
|
265
|
-
return stringifyJsonValue(value, '');
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
function normalizeOpenAiToolArguments(value) {
|
|
269
|
-
if (typeof value === 'string') return value;
|
|
270
|
-
if (value == null) return '{}';
|
|
271
|
-
return stringifyJsonValue(value, '{}');
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
function normalizeInputFileBlock(item) {
|
|
275
|
-
if (!isRecord(item)) return null;
|
|
276
|
-
const file = isRecord(item.file) ? item.file : item;
|
|
277
|
-
const out = {};
|
|
278
|
-
const fileId = asTrimmedString(file.file_id || file.id);
|
|
279
|
-
const filename = asTrimmedString(file.filename || file.name);
|
|
280
|
-
const fileData = asTrimmedString(file.file_data || file.data);
|
|
281
|
-
const mimeType = asTrimmedString(file.mime_type || file.media_type);
|
|
282
|
-
if (fileId) out.file_id = fileId;
|
|
283
|
-
if (filename) out.filename = filename;
|
|
284
|
-
if (fileData) out.file_data = fileData;
|
|
285
|
-
if (mimeType) out.mime_type = mimeType;
|
|
286
|
-
return Object.keys(out).length > 0 ? out : null;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
function normalizeResponsesContentBlockForChat(item) {
|
|
290
|
-
if (typeof item === 'string') return item.trim() ? item : null;
|
|
291
|
-
if (!isRecord(item)) return null;
|
|
292
|
-
|
|
293
|
-
const type = asTrimmedString(item.type).toLowerCase();
|
|
294
|
-
if (!type) {
|
|
295
|
-
const text = asTrimmedString(item.text || item.content || item.output_text);
|
|
296
|
-
return text ? { type: 'text', text } : null;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
if (type === 'input_text' || type === 'output_text' || type === 'text' || type === 'summary_text' || type === 'reasoning_text') {
|
|
300
|
-
const text = typeof item.text === 'string' ? item.text : asTrimmedString(item.content || item.output_text);
|
|
301
|
-
return text ? { type: 'text', text } : null;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
if (type === 'refusal' && typeof item.refusal === 'string') {
|
|
305
|
-
return item.refusal ? { type: 'text', text: item.refusal } : null;
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
if (type === 'input_image') {
|
|
309
|
-
const raw = item.image_url != null ? item.image_url : (item.url != null ? item.url : item.imageUrl);
|
|
310
|
-
if (raw === undefined) return null;
|
|
311
|
-
return {
|
|
312
|
-
type: 'image_url',
|
|
313
|
-
image_url: typeof raw === 'string' ? { url: raw } : cloneJsonValue(raw)
|
|
314
|
-
};
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
if (type === 'image_url' && item.image_url !== undefined) {
|
|
318
|
-
return { type: 'image_url', image_url: item.image_url };
|
|
319
|
-
}
|
|
320
|
-
|
|
321
|
-
if (type === 'input_audio') {
|
|
322
|
-
if (item.input_audio !== undefined) return { type: 'input_audio', input_audio: item.input_audio };
|
|
323
|
-
if (item.data !== undefined || item.format !== undefined) {
|
|
324
|
-
return { type: 'input_audio', input_audio: { data: item.data, format: item.format } };
|
|
325
|
-
}
|
|
326
|
-
return null;
|
|
327
|
-
}
|
|
328
|
-
|
|
329
|
-
if (type === 'input_file' || type === 'file') {
|
|
330
|
-
const file = normalizeInputFileBlock(item);
|
|
331
|
-
return file ? { type: 'file', file } : null;
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
|
|
335
|
-
const text = asTrimmedString(item.text || item.content);
|
|
336
|
-
return text ? { type: 'text', text } : null;
|
|
337
|
-
}
|
|
338
|
-
|
|
339
|
-
const text = asTrimmedString(item.text || item.content);
|
|
340
|
-
return text ? { type: 'text', text } : cloneJsonValue(item);
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function toOpenAiMessageContent(content) {
|
|
344
|
-
if (typeof content === 'string') return content;
|
|
345
|
-
if (!Array.isArray(content)) {
|
|
346
|
-
if (isRecord(content)) {
|
|
347
|
-
const single = normalizeResponsesContentBlockForChat(content);
|
|
348
|
-
if (!single) return '';
|
|
349
|
-
return typeof single === 'string' ? single : [single];
|
|
350
|
-
}
|
|
351
|
-
return '';
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const blocks = content
|
|
355
|
-
.map((item) => normalizeResponsesContentBlockForChat(item))
|
|
356
|
-
.filter((item) => !!item);
|
|
357
|
-
|
|
358
|
-
if (blocks.length === 0) return '';
|
|
359
|
-
if (blocks.length === 1 && typeof blocks[0] === 'string') return blocks[0];
|
|
360
|
-
return blocks;
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
const RESPONSES_TOOL_CALL_INPUT_TYPES = new Set(['function_call', 'custom_tool_call', 'mcp_tool_call', 'local_shell_call']);
|
|
364
|
-
const RESPONSES_TOOL_CALL_OUTPUT_TYPES = new Set(['function_call_output', 'custom_tool_call_output', 'mcp_tool_call_output', 'tool_search_output', 'local_shell_call_output']);
|
|
365
|
-
|
|
366
|
-
function stripOrphanedResponsesToolOutputs(input) {
|
|
367
|
-
if (!Array.isArray(input)) return input;
|
|
368
|
-
const seenToolCallIds = new Set();
|
|
369
|
-
const sanitized = [];
|
|
370
|
-
for (const item of input) {
|
|
371
|
-
if (!isRecord(item)) {
|
|
372
|
-
sanitized.push(item);
|
|
373
|
-
continue;
|
|
374
|
-
}
|
|
375
|
-
const type = asTrimmedString(item.type).toLowerCase();
|
|
376
|
-
if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(type)) {
|
|
377
|
-
const callId = asTrimmedString(item.call_id || item.id);
|
|
378
|
-
if (callId) seenToolCallIds.add(callId);
|
|
379
|
-
sanitized.push(item);
|
|
380
|
-
continue;
|
|
381
|
-
}
|
|
382
|
-
if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(type)) {
|
|
383
|
-
const callId = asTrimmedString(item.call_id || item.id);
|
|
384
|
-
if (!callId || !seenToolCallIds.has(callId)) continue;
|
|
385
|
-
sanitized.push(item);
|
|
386
|
-
continue;
|
|
387
|
-
}
|
|
388
|
-
sanitized.push(item);
|
|
389
|
-
}
|
|
390
|
-
return sanitized;
|
|
391
|
-
}
|
|
392
|
-
|
|
393
|
-
function normalizeFreeformToolArguments(value) {
|
|
394
|
-
if (typeof value === 'string') return stringifyJsonValue({ input: value }, '{"input":""}');
|
|
395
|
-
if (value == null) return '{"input":""}';
|
|
396
|
-
if (isRecord(value) && Object.prototype.hasOwnProperty.call(value, 'input')) {
|
|
397
|
-
return stringifyJsonValue(value, '{"input":""}');
|
|
398
|
-
}
|
|
399
|
-
return stringifyJsonValue({ input: normalizeResponsesToolOutput(value) }, '{"input":""}');
|
|
400
|
-
}
|
|
401
|
-
|
|
402
|
-
function toOpenAiToolCall(item, fallbackIndex) {
|
|
403
|
-
if (!isRecord(item)) return null;
|
|
404
|
-
const callId = asTrimmedString(item.call_id || item.id) || `call_${crypto.randomBytes(8).toString('hex')}_${fallbackIndex}`;
|
|
405
|
-
const type = asTrimmedString(item.type).toLowerCase();
|
|
406
|
-
const name = asTrimmedString(item.name)
|
|
407
|
-
|| asTrimmedString(item.server_label)
|
|
408
|
-
|| (type === 'local_shell_call' ? 'local_shell' : '');
|
|
409
|
-
if (!name) return null;
|
|
410
|
-
const rawArguments = item.arguments != null
|
|
411
|
-
? item.arguments
|
|
412
|
-
: (item.input != null ? item.input : (item.action != null ? item.action : item.command));
|
|
413
|
-
const args = (type === 'custom_tool_call' && item.arguments == null)
|
|
414
|
-
? normalizeFreeformToolArguments(rawArguments)
|
|
415
|
-
: normalizeOpenAiToolArguments(rawArguments);
|
|
416
|
-
return {
|
|
417
|
-
id: callId,
|
|
418
|
-
type: 'function',
|
|
419
|
-
function: {
|
|
420
|
-
name,
|
|
421
|
-
arguments: args
|
|
422
|
-
}
|
|
423
|
-
};
|
|
424
|
-
}
|
|
425
|
-
|
|
426
|
-
function hasOpenAiMessageContent(content) {
|
|
427
|
-
return typeof content === 'string'
|
|
428
|
-
? content.trim().length > 0
|
|
429
|
-
: Array.isArray(content) && content.length > 0;
|
|
430
|
-
}
|
|
431
|
-
|
|
432
|
-
function normalizeResponsesInputToChatMessages(input) {
|
|
433
|
-
// Keep the OpenAI bridge in lockstep with the builtin proxy's Responses → Chat shim.
|
|
434
|
-
// Codex long-running tasks append richer Responses history (custom/local_shell/MCP calls)
|
|
435
|
-
// back into `input`; dropping those items makes the next model turn lose tool state and stop early.
|
|
436
|
-
const messages = [];
|
|
437
|
-
const normalizedInput = stripOrphanedResponsesToolOutputs(input);
|
|
438
|
-
let functionCallIndex = 0;
|
|
439
|
-
let pendingToolCalls = [];
|
|
440
|
-
const emittedToolCallIds = new Set();
|
|
441
|
-
|
|
442
|
-
const flushPendingToolCalls = () => {
|
|
443
|
-
if (pendingToolCalls.length <= 0) return;
|
|
444
|
-
for (const toolCall of pendingToolCalls) {
|
|
445
|
-
const callId = asTrimmedString(toolCall.id);
|
|
446
|
-
if (callId) emittedToolCallIds.add(callId);
|
|
447
|
-
}
|
|
448
|
-
messages.push({
|
|
449
|
-
role: 'assistant',
|
|
450
|
-
content: null,
|
|
451
|
-
tool_calls: pendingToolCalls
|
|
452
|
-
});
|
|
453
|
-
pendingToolCalls = [];
|
|
454
|
-
};
|
|
455
|
-
|
|
456
|
-
const pushToolOutputMessage = (callIdRaw, outputRaw) => {
|
|
457
|
-
const toolCallId = asTrimmedString(callIdRaw);
|
|
458
|
-
if (!toolCallId) return;
|
|
459
|
-
messages.push({
|
|
460
|
-
role: 'tool',
|
|
461
|
-
tool_call_id: toolCallId,
|
|
462
|
-
content: normalizeResponsesToolOutput(outputRaw)
|
|
463
|
-
});
|
|
464
|
-
};
|
|
465
|
-
|
|
466
|
-
const processInputItem = (item) => {
|
|
467
|
-
if (typeof item === 'string') {
|
|
468
|
-
flushPendingToolCalls();
|
|
469
|
-
const text = item.trim();
|
|
470
|
-
if (text) messages.push({ role: 'user', content: text });
|
|
471
|
-
return;
|
|
472
|
-
}
|
|
473
|
-
if (!isRecord(item)) return;
|
|
474
|
-
|
|
475
|
-
const itemType = asTrimmedString(item.type).toLowerCase();
|
|
476
|
-
if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(itemType)) {
|
|
477
|
-
const toolCall = toOpenAiToolCall(item, functionCallIndex);
|
|
478
|
-
functionCallIndex += 1;
|
|
479
|
-
if (toolCall) pendingToolCalls.push(toolCall);
|
|
480
|
-
return;
|
|
481
|
-
}
|
|
482
|
-
|
|
483
|
-
if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(itemType)) {
|
|
484
|
-
flushPendingToolCalls();
|
|
485
|
-
const toolCallId = asTrimmedString(item.call_id || item.id);
|
|
486
|
-
if (!toolCallId || !emittedToolCallIds.has(toolCallId)) return;
|
|
487
|
-
pushToolOutputMessage(toolCallId, item.output != null ? item.output : item.content);
|
|
488
|
-
return;
|
|
489
|
-
}
|
|
490
|
-
|
|
491
|
-
if (itemType === 'reasoning') {
|
|
492
|
-
flushPendingToolCalls();
|
|
493
|
-
const reasoningContent = toOpenAiMessageContent(item.summary != null ? item.summary : (item.content != null ? item.content : item));
|
|
494
|
-
const reasoningSignature = asTrimmedString(item.encrypted_content || item.reasoning_signature);
|
|
495
|
-
if (!hasOpenAiMessageContent(reasoningContent) && !reasoningSignature) return;
|
|
496
|
-
const message = { role: 'assistant', content: reasoningContent };
|
|
497
|
-
if (reasoningSignature) message.reasoning_signature = reasoningSignature;
|
|
498
|
-
messages.push(message);
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
|
|
502
|
-
flushPendingToolCalls();
|
|
503
|
-
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
|
504
|
-
const normalizedRole = role === 'developer' ? 'system' : role;
|
|
505
|
-
const content = toOpenAiMessageContent(item.content != null ? item.content : (item.input != null ? item.input : item));
|
|
506
|
-
|
|
507
|
-
if (normalizedRole === 'tool') {
|
|
508
|
-
const toolCallId = asTrimmedString(item.tool_call_id || item.call_id || item.id);
|
|
509
|
-
if (!toolCallId || !emittedToolCallIds.has(toolCallId)) return;
|
|
510
|
-
pushToolOutputMessage(toolCallId, item.content);
|
|
511
|
-
return;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
if (!hasOpenAiMessageContent(content)) return;
|
|
515
|
-
const message = { role: normalizedRole, content };
|
|
516
|
-
const phase = asTrimmedString(item.phase);
|
|
517
|
-
if (phase) message.phase = phase;
|
|
518
|
-
messages.push(message);
|
|
519
|
-
};
|
|
520
|
-
|
|
521
|
-
if (typeof normalizedInput === 'string') {
|
|
522
|
-
const text = normalizedInput.trim();
|
|
523
|
-
if (text) messages.push({ role: 'user', content: text });
|
|
524
|
-
} else if (Array.isArray(normalizedInput)) {
|
|
525
|
-
for (const item of normalizedInput) processInputItem(item);
|
|
526
|
-
} else if (isRecord(normalizedInput)) {
|
|
527
|
-
processInputItem(normalizedInput);
|
|
528
|
-
}
|
|
529
|
-
flushPendingToolCalls();
|
|
530
|
-
return messages;
|
|
531
|
-
}
|
|
532
|
-
|
|
533
|
-
function normalizeFunctionToolForChat(tool) {
|
|
534
|
-
if (!isRecord(tool)) return null;
|
|
535
|
-
const sourceFn = isRecord(tool.function) ? tool.function : tool;
|
|
536
|
-
const name = asTrimmedString(sourceFn.name) || asTrimmedString(tool.name);
|
|
537
|
-
if (!name) return null;
|
|
538
|
-
const fn = { name };
|
|
539
|
-
const description = asTrimmedString(sourceFn.description) || asTrimmedString(tool.description);
|
|
540
|
-
if (description) fn.description = description;
|
|
541
|
-
if (sourceFn.parameters !== undefined) {
|
|
542
|
-
fn.parameters = cloneJsonValue(sourceFn.parameters);
|
|
543
|
-
} else if (tool.parameters !== undefined) {
|
|
544
|
-
fn.parameters = cloneJsonValue(tool.parameters);
|
|
545
|
-
}
|
|
546
|
-
if (typeof sourceFn.strict === 'boolean') {
|
|
547
|
-
fn.strict = sourceFn.strict;
|
|
548
|
-
} else if (typeof tool.strict === 'boolean') {
|
|
549
|
-
fn.strict = tool.strict;
|
|
550
|
-
}
|
|
551
|
-
return { type: 'function', function: fn };
|
|
552
|
-
}
|
|
553
|
-
|
|
554
|
-
function buildLocalShellToolForChat(tool) {
|
|
555
|
-
return {
|
|
556
|
-
type: 'function',
|
|
557
|
-
function: {
|
|
558
|
-
name: asTrimmedString(tool && tool.name) || 'local_shell',
|
|
559
|
-
description: asTrimmedString(tool && tool.description) || 'Run a local shell command and return its output.',
|
|
560
|
-
parameters: {
|
|
561
|
-
type: 'object',
|
|
562
|
-
properties: {
|
|
563
|
-
cmd: { type: 'string', description: 'Shell command to execute.' },
|
|
564
|
-
yield_time_ms: { type: 'number', description: 'Milliseconds to wait before yielding partial output.' },
|
|
565
|
-
max_output_tokens: { type: 'number', description: 'Maximum output tokens to return.' }
|
|
566
|
-
},
|
|
567
|
-
required: ['cmd'],
|
|
568
|
-
additionalProperties: true
|
|
569
|
-
}
|
|
570
|
-
}
|
|
571
|
-
};
|
|
572
|
-
}
|
|
573
|
-
|
|
574
|
-
function buildFreeformToolForChat(tool, fallbackName = 'custom_tool') {
|
|
575
|
-
return {
|
|
576
|
-
type: 'function',
|
|
577
|
-
function: {
|
|
578
|
-
name: asTrimmedString(tool && tool.name) || fallbackName,
|
|
579
|
-
description: asTrimmedString(tool && tool.description) || 'Pass raw freeform input to the local tool.',
|
|
580
|
-
parameters: {
|
|
581
|
-
type: 'object',
|
|
582
|
-
properties: {
|
|
583
|
-
input: { type: 'string', description: 'Raw tool input.' }
|
|
584
|
-
},
|
|
585
|
-
required: ['input'],
|
|
586
|
-
additionalProperties: false
|
|
587
|
-
}
|
|
588
|
-
}
|
|
589
|
-
};
|
|
590
|
-
}
|
|
591
|
-
|
|
592
|
-
const MAX_RESPONSES_TOOL_NAMESPACE_DEPTH = 5;
|
|
593
|
-
|
|
594
|
-
function rememberResponsesToolType(tool, target, depth = 0) {
|
|
595
|
-
if (!isRecord(tool) || !target || depth > MAX_RESPONSES_TOOL_NAMESPACE_DEPTH) return;
|
|
596
|
-
const type = asTrimmedString(tool.type).toLowerCase();
|
|
597
|
-
if (type === 'namespace' && Array.isArray(tool.tools)) {
|
|
598
|
-
for (const inner of tool.tools) rememberResponsesToolType(inner, target, depth + 1);
|
|
599
|
-
return;
|
|
600
|
-
}
|
|
601
|
-
const sourceFn = isRecord(tool.function) ? tool.function : tool;
|
|
602
|
-
const name = asTrimmedString(sourceFn.name) || asTrimmedString(tool.name);
|
|
603
|
-
if (!name) return;
|
|
604
|
-
if (type === 'local_shell') {
|
|
605
|
-
target[name] = 'local_shell_call';
|
|
606
|
-
return;
|
|
607
|
-
}
|
|
608
|
-
if (type === 'custom' || type === 'custom_tool' || (!type && name === 'apply_patch')) {
|
|
609
|
-
target[name] = 'custom_tool_call';
|
|
610
|
-
return;
|
|
611
|
-
}
|
|
612
|
-
if (type === 'function') {
|
|
613
|
-
target[name] = 'function_call';
|
|
614
|
-
}
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
function collectResponsesToolTypesByName(tools) {
|
|
618
|
-
const result = {};
|
|
619
|
-
if (!Array.isArray(tools)) return result;
|
|
620
|
-
for (const tool of tools) rememberResponsesToolType(tool, result);
|
|
621
|
-
return result;
|
|
622
|
-
}
|
|
623
|
-
|
|
624
|
-
function extractFreeformInputFromChatArguments(argumentsText) {
|
|
625
|
-
if (typeof argumentsText !== 'string') return '';
|
|
626
|
-
const parsed = parseJsonValueOrNull(argumentsText);
|
|
627
|
-
if (isRecord(parsed) && Object.prototype.hasOwnProperty.call(parsed, 'input')) {
|
|
628
|
-
return typeof parsed.input === 'string' ? parsed.input : normalizeResponsesToolOutput(parsed.input);
|
|
629
|
-
}
|
|
630
|
-
return argumentsText;
|
|
631
|
-
}
|
|
632
|
-
|
|
633
|
-
function extractLocalShellActionFromChatArguments(argumentsText) {
|
|
634
|
-
const parsed = parseJsonValueOrNull(argumentsText);
|
|
635
|
-
if (isRecord(parsed)) return cloneJsonValue(parsed);
|
|
636
|
-
return { cmd: typeof argumentsText === 'string' ? argumentsText : '' };
|
|
637
|
-
}
|
|
638
|
-
|
|
639
|
-
function buildResponsesToolCallItemFromChatToolCall(toolCall, toolTypesByName = {}) {
|
|
640
|
-
if (!isRecord(toolCall)) return null;
|
|
641
|
-
const fn = isRecord(toolCall.function) ? toolCall.function : {};
|
|
642
|
-
const name = asTrimmedString(fn.name);
|
|
643
|
-
if (!name) return null;
|
|
644
|
-
const callId = asTrimmedString(toolCall.id) || `call_${crypto.randomBytes(8).toString('hex')}`;
|
|
645
|
-
const argumentsText = typeof fn.arguments === 'string' ? fn.arguments : '';
|
|
646
|
-
const responseType = toolTypesByName && toolTypesByName[name] ? toolTypesByName[name] : 'function_call';
|
|
647
|
-
|
|
648
|
-
if (responseType === 'custom_tool_call') {
|
|
649
|
-
return {
|
|
650
|
-
type: 'custom_tool_call',
|
|
651
|
-
call_id: callId,
|
|
652
|
-
name,
|
|
653
|
-
input: extractFreeformInputFromChatArguments(argumentsText)
|
|
654
|
-
};
|
|
655
|
-
}
|
|
656
|
-
if (responseType === 'local_shell_call') {
|
|
657
|
-
return {
|
|
658
|
-
type: 'local_shell_call',
|
|
659
|
-
call_id: callId,
|
|
660
|
-
name,
|
|
661
|
-
action: extractLocalShellActionFromChatArguments(argumentsText)
|
|
662
|
-
};
|
|
663
|
-
}
|
|
664
|
-
return {
|
|
665
|
-
type: 'function_call',
|
|
666
|
-
call_id: callId,
|
|
667
|
-
name,
|
|
668
|
-
arguments: argumentsText
|
|
669
|
-
};
|
|
670
|
-
}
|
|
671
|
-
|
|
672
|
-
function normalizeSingleResponsesToolToChatTools(tool, depth = 0) {
|
|
673
|
-
if (!isRecord(tool) || depth > MAX_RESPONSES_TOOL_NAMESPACE_DEPTH) return [];
|
|
674
|
-
const type = asTrimmedString(tool.type).toLowerCase();
|
|
675
|
-
if (type === 'namespace' && Array.isArray(tool.tools)) {
|
|
676
|
-
return tool.tools.flatMap((inner) => normalizeSingleResponsesToolToChatTools(inner, depth + 1));
|
|
677
|
-
}
|
|
678
|
-
if (type === 'function') {
|
|
679
|
-
const converted = normalizeFunctionToolForChat(tool);
|
|
680
|
-
return converted ? [converted] : [];
|
|
681
|
-
}
|
|
682
|
-
if (type === 'local_shell') {
|
|
683
|
-
return [buildLocalShellToolForChat(tool)];
|
|
684
|
-
}
|
|
685
|
-
const name = asTrimmedString(tool.name);
|
|
686
|
-
if (type === 'custom' || type === 'custom_tool' || (!type && name === 'apply_patch')) {
|
|
687
|
-
return [buildFreeformToolForChat(tool, name || 'custom_tool')];
|
|
688
|
-
}
|
|
689
|
-
return [];
|
|
690
|
-
}
|
|
691
|
-
|
|
692
|
-
function normalizeResponsesToolsToChatTools(tools) {
|
|
693
|
-
if (!Array.isArray(tools)) return tools;
|
|
694
|
-
return tools.flatMap((tool) => normalizeSingleResponsesToolToChatTools(tool));
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
function normalizeResponsesToolChoiceToChatToolChoice(toolChoice) {
|
|
698
|
-
if (toolChoice === undefined) return undefined;
|
|
699
|
-
if (typeof toolChoice === 'string') return toolChoice;
|
|
700
|
-
if (!isRecord(toolChoice)) return toolChoice;
|
|
701
|
-
|
|
702
|
-
const type = asTrimmedString(toolChoice.type).toLowerCase();
|
|
703
|
-
if (type === 'tool' || type === 'function' || type === 'custom' || type === 'custom_tool' || type === 'local_shell') {
|
|
704
|
-
if (isRecord(toolChoice.function) && asTrimmedString(toolChoice.function.name)) return cloneJsonValue(toolChoice);
|
|
705
|
-
const name = asTrimmedString(toolChoice.name) || asTrimmedString(toolChoice.server_label);
|
|
706
|
-
if (!name) return 'required';
|
|
707
|
-
return { type: 'function', function: { name } };
|
|
708
|
-
}
|
|
709
|
-
if (type === 'auto' || type === 'none' || type === 'required') return type;
|
|
710
|
-
return 'auto';
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
function getChatToolChoiceName(toolChoice) {
|
|
714
|
-
if (!isRecord(toolChoice)) return '';
|
|
715
|
-
if (isRecord(toolChoice.function)) return asTrimmedString(toolChoice.function.name);
|
|
716
|
-
return '';
|
|
717
|
-
}
|
|
718
|
-
|
|
719
|
-
function pruneInvalidChatToolChoice(chatBody) {
|
|
720
|
-
if (!isRecord(chatBody) || !Array.isArray(chatBody.tools)) return;
|
|
721
|
-
if (chatBody.tools.length === 0) {
|
|
722
|
-
delete chatBody.tools;
|
|
723
|
-
delete chatBody.tool_choice;
|
|
724
|
-
return;
|
|
725
|
-
}
|
|
726
|
-
const chosenName = getChatToolChoiceName(chatBody.tool_choice);
|
|
727
|
-
if (!chosenName) return;
|
|
728
|
-
const toolNames = new Set(chatBody.tools
|
|
729
|
-
.map((tool) => isRecord(tool) && isRecord(tool.function) ? asTrimmedString(tool.function.name) : '')
|
|
730
|
-
.filter(Boolean));
|
|
731
|
-
if (!toolNames.has(chosenName)) {
|
|
732
|
-
delete chatBody.tool_choice;
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
|
|
736
|
-
function normalizeResponsesToolsForResponsesApi(tools) {
|
|
737
|
-
if (!Array.isArray(tools)) return tools;
|
|
738
|
-
return tools
|
|
739
|
-
.map((tool) => {
|
|
740
|
-
const converted = normalizeFunctionToolForChat(tool);
|
|
741
|
-
if (!converted || !converted.function) return null;
|
|
742
|
-
const out = {
|
|
743
|
-
type: 'function',
|
|
744
|
-
name: converted.function.name
|
|
745
|
-
};
|
|
746
|
-
if (converted.function.description !== undefined) out.description = converted.function.description;
|
|
747
|
-
if (converted.function.parameters !== undefined) out.parameters = converted.function.parameters;
|
|
748
|
-
if (converted.function.strict !== undefined) out.strict = converted.function.strict;
|
|
749
|
-
return out;
|
|
750
|
-
})
|
|
751
|
-
.filter(Boolean);
|
|
752
|
-
}
|
|
753
|
-
|
|
754
|
-
function mergeLeadingSystemMessages(messages, leadingInstructions) {
|
|
755
|
-
const segments = [];
|
|
756
|
-
const seen = new Set();
|
|
757
|
-
const pushSegment = (text) => {
|
|
758
|
-
const trimmed = typeof text === 'string' ? text.trim() : '';
|
|
759
|
-
if (!trimmed || seen.has(trimmed)) return;
|
|
760
|
-
seen.add(trimmed);
|
|
761
|
-
segments.push(trimmed);
|
|
762
|
-
};
|
|
763
|
-
if (typeof leadingInstructions === 'string') {
|
|
764
|
-
pushSegment(leadingInstructions);
|
|
765
|
-
}
|
|
766
|
-
const rest = [];
|
|
767
|
-
for (const msg of messages) {
|
|
768
|
-
if (msg && msg.role === 'system') {
|
|
769
|
-
const content = msg.content;
|
|
770
|
-
if (typeof content === 'string') {
|
|
771
|
-
pushSegment(content);
|
|
772
|
-
} else if (Array.isArray(content)) {
|
|
773
|
-
for (const part of content) {
|
|
774
|
-
if (part && typeof part === 'object' && typeof part.text === 'string') {
|
|
775
|
-
pushSegment(part.text);
|
|
776
|
-
}
|
|
777
|
-
}
|
|
778
|
-
}
|
|
779
|
-
continue;
|
|
780
|
-
}
|
|
781
|
-
rest.push(msg);
|
|
782
|
-
}
|
|
783
|
-
const out = [];
|
|
784
|
-
if (segments.length) {
|
|
785
|
-
out.push({ role: 'system', content: segments.join('\n\n---\n\n') });
|
|
786
|
-
}
|
|
787
|
-
for (const msg of rest) out.push(msg);
|
|
788
|
-
return out;
|
|
789
|
-
}
|
|
790
|
-
|
|
791
|
-
function messageContentAsText(content) {
|
|
792
|
-
if (typeof content === 'string') return content;
|
|
793
|
-
if (!Array.isArray(content)) return '';
|
|
794
|
-
return content
|
|
795
|
-
.map((item) => {
|
|
796
|
-
if (typeof item === 'string') return item;
|
|
797
|
-
if (!isRecord(item)) return '';
|
|
798
|
-
if (typeof item.text === 'string') return item.text;
|
|
799
|
-
if (typeof item.content === 'string') return item.content;
|
|
800
|
-
return '';
|
|
801
|
-
})
|
|
802
|
-
.filter(Boolean)
|
|
803
|
-
.join('\n');
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
function hasRunningCodexExecSession(messages) {
|
|
807
|
-
if (!Array.isArray(messages)) return false;
|
|
808
|
-
return messages.some((message) => {
|
|
809
|
-
if (!isRecord(message) || message.role !== 'tool') return false;
|
|
810
|
-
return /Process running with session ID\s+\d+/i.test(messageContentAsText(message.content));
|
|
811
|
-
});
|
|
812
|
-
}
|
|
813
|
-
|
|
814
|
-
function appendChatFallbackRuntimeInstructions(baseInstructions, rawMessages) {
|
|
815
|
-
const segments = [];
|
|
816
|
-
const base = typeof baseInstructions === 'string' ? baseInstructions.trim() : '';
|
|
817
|
-
if (base) segments.push(base);
|
|
818
|
-
if (hasRunningCodexExecSession(rawMessages)) {
|
|
819
|
-
segments.push('Codex tool output indicates a command is still running ("Process running with session ID ..."). You must call write_stdin with that numeric session_id and empty chars to poll/wait for completion before giving a final answer. Do not merely say that you are waiting.');
|
|
820
|
-
}
|
|
821
|
-
return segments.join('\n\n');
|
|
822
|
-
}
|
|
823
|
-
|
|
824
|
-
function convertResponsesRequestToChatCompletions(payload) {
|
|
825
|
-
const body = payload && typeof payload === 'object' ? payload : {};
|
|
826
|
-
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
|
827
|
-
if (!model) {
|
|
828
|
-
return { error: 'responses 请求缺少 model' };
|
|
829
|
-
}
|
|
830
|
-
|
|
831
|
-
const rawMessages = normalizeResponsesInputToChatMessages(body.input);
|
|
832
|
-
const leadingInstructions = appendChatFallbackRuntimeInstructions(body.instructions, rawMessages);
|
|
833
|
-
// codex 同时下发 body.instructions(内置 prompt)与 input 内 developer/system 消息(AGENTS.md)。
|
|
834
|
-
// 合流为一条领头 system,避免某些上游"只认第一条 system"导致 AGENTS.md 失效。
|
|
835
|
-
const messages = mergeLeadingSystemMessages(rawMessages, leadingInstructions);
|
|
836
|
-
if (!messages.length) {
|
|
837
|
-
// codex sometimes sends empty input for probes; tolerate.
|
|
838
|
-
messages.push({ role: 'user', content: '' });
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
const maxOutputTokens = Number.parseInt(String(body.max_output_tokens), 10);
|
|
842
|
-
const stream = body.stream === true;
|
|
843
|
-
|
|
844
|
-
const chat = {
|
|
845
|
-
model,
|
|
846
|
-
messages,
|
|
847
|
-
stream: false,
|
|
848
|
-
temperature: Number.isFinite(body.temperature) ? Number(body.temperature) : undefined,
|
|
849
|
-
top_p: Number.isFinite(body.top_p) ? Number(body.top_p) : undefined,
|
|
850
|
-
max_tokens: Number.isFinite(maxOutputTokens) && maxOutputTokens > 0 ? maxOutputTokens : undefined
|
|
851
|
-
};
|
|
852
|
-
if (Array.isArray(body.stop) && body.stop.length) {
|
|
853
|
-
chat.stop = body.stop.filter((item) => typeof item === 'string' && item.trim());
|
|
854
|
-
}
|
|
855
|
-
if (Array.isArray(body.tools) && body.tools.length) {
|
|
856
|
-
chat.tools = normalizeResponsesToolsToChatTools(body.tools);
|
|
857
|
-
}
|
|
858
|
-
if (body.tool_choice !== undefined) {
|
|
859
|
-
chat.tool_choice = normalizeResponsesToolChoiceToChatToolChoice(body.tool_choice);
|
|
860
|
-
}
|
|
861
|
-
if (body.response_format !== undefined) {
|
|
862
|
-
chat.response_format = body.response_format;
|
|
863
|
-
}
|
|
864
|
-
if (body.metadata !== undefined) {
|
|
865
|
-
chat.metadata = body.metadata;
|
|
866
|
-
}
|
|
867
|
-
|
|
868
|
-
pruneInvalidChatToolChoice(chat);
|
|
869
|
-
|
|
870
|
-
// Remove undefined keys
|
|
871
|
-
Object.keys(chat).forEach((key) => chat[key] === undefined && delete chat[key]);
|
|
872
|
-
|
|
873
|
-
return { chat, streamRequested: stream, toolTypesByName: collectResponsesToolTypesByName(body.tools) };
|
|
874
|
-
}
|
|
875
|
-
|
|
876
|
-
function buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamPayload, options = {}) {
|
|
877
|
-
const responseId = `resp_${crypto.randomBytes(10).toString('hex')}`;
|
|
878
|
-
const usage = upstreamPayload && upstreamPayload.usage && typeof upstreamPayload.usage === 'object'
|
|
879
|
-
? upstreamPayload.usage
|
|
880
|
-
: null;
|
|
881
|
-
const createdAt = Math.floor(Date.now() / 1000);
|
|
882
|
-
const output = [];
|
|
883
|
-
const trimmedText = typeof text === 'string' ? text : '';
|
|
884
|
-
if (trimmedText) {
|
|
885
|
-
output.push({
|
|
886
|
-
id: `msg_${crypto.randomBytes(8).toString('hex')}`,
|
|
887
|
-
type: 'message',
|
|
888
|
-
role: 'assistant',
|
|
889
|
-
content: [{ type: 'output_text', text: trimmedText }]
|
|
890
|
-
});
|
|
891
|
-
}
|
|
892
|
-
|
|
893
|
-
// Convert chat.completions tool_calls back into the original Responses item type.
|
|
894
|
-
// Treating every call as `function_call` makes Codex built-ins (custom/local_shell)
|
|
895
|
-
// degrade into ordinary chat text instead of executable agent steps.
|
|
896
|
-
if (Array.isArray(toolCalls)) {
|
|
897
|
-
for (const call of toolCalls) {
|
|
898
|
-
const item = buildResponsesToolCallItemFromChatToolCall(call, options.toolTypesByName || {});
|
|
899
|
-
if (item) output.push(item);
|
|
900
|
-
}
|
|
901
|
-
}
|
|
902
|
-
|
|
903
|
-
const payload = {
|
|
904
|
-
id: responseId,
|
|
905
|
-
object: 'response',
|
|
906
|
-
model,
|
|
907
|
-
created_at: createdAt,
|
|
908
|
-
status: 'completed',
|
|
909
|
-
output,
|
|
910
|
-
output_text: trimmedText
|
|
911
|
-
};
|
|
912
|
-
|
|
913
|
-
if (usage) {
|
|
914
|
-
// Map chat.completions usage -> responses usage shape when possible.
|
|
915
|
-
const promptTokens = Number.isFinite(usage.prompt_tokens) ? Number(usage.prompt_tokens) : null;
|
|
916
|
-
const completionTokens = Number.isFinite(usage.completion_tokens) ? Number(usage.completion_tokens) : null;
|
|
917
|
-
const totalTokens = Number.isFinite(usage.total_tokens) ? Number(usage.total_tokens) : null;
|
|
918
|
-
if (promptTokens !== null || completionTokens !== null || totalTokens !== null) {
|
|
919
|
-
payload.usage = {
|
|
920
|
-
input_tokens: promptTokens ?? undefined,
|
|
921
|
-
output_tokens: completionTokens ?? undefined,
|
|
922
|
-
total_tokens: totalTokens ?? undefined
|
|
923
|
-
};
|
|
924
|
-
Object.keys(payload.usage).forEach((key) => payload.usage[key] === undefined && delete payload.usage[key]);
|
|
925
|
-
} else {
|
|
926
|
-
payload.usage = usage;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
return payload;
|
|
931
|
-
}
|
|
932
|
-
|
|
933
|
-
function ensureResponseMetadata(response) {
|
|
934
|
-
const payload = response && typeof response === 'object' ? response : {};
|
|
935
|
-
if (typeof payload.object !== 'string' || !payload.object.trim()) {
|
|
936
|
-
payload.object = 'response';
|
|
937
|
-
}
|
|
938
|
-
if (typeof payload.created_at !== 'number') {
|
|
939
|
-
payload.created_at = Math.floor(Date.now() / 1000);
|
|
940
|
-
}
|
|
941
|
-
if (typeof payload.status !== 'string' || !payload.status.trim()) {
|
|
942
|
-
payload.status = 'completed';
|
|
943
|
-
}
|
|
944
|
-
if (!Array.isArray(payload.output)) {
|
|
945
|
-
payload.output = [];
|
|
946
|
-
}
|
|
947
|
-
return payload;
|
|
948
|
-
}
|
|
949
|
-
|
|
950
|
-
function sendResponsesSse(res, responsePayload) {
|
|
951
|
-
const response = ensureResponseMetadata(responsePayload);
|
|
952
|
-
const responseId = typeof response.id === 'string' && response.id.trim()
|
|
953
|
-
? response.id.trim()
|
|
954
|
-
: `resp_${crypto.randomBytes(10).toString('hex')}`;
|
|
955
|
-
const model = typeof response.model === 'string' ? response.model : '';
|
|
956
|
-
|
|
957
|
-
let sequence = 0;
|
|
958
|
-
const nextSeq = () => {
|
|
959
|
-
sequence += 1;
|
|
960
|
-
return sequence;
|
|
961
|
-
};
|
|
962
|
-
|
|
963
|
-
writeSse(res, 'response.created', {
|
|
964
|
-
type: 'response.created',
|
|
965
|
-
response: {
|
|
966
|
-
id: responseId,
|
|
967
|
-
model,
|
|
968
|
-
created_at: response.created_at
|
|
969
|
-
}
|
|
970
|
-
});
|
|
971
|
-
|
|
972
|
-
const output = Array.isArray(response.output) ? response.output : [];
|
|
973
|
-
for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
|
|
974
|
-
const item = output[outputIndex];
|
|
975
|
-
if (!item || typeof item !== 'object') continue;
|
|
976
|
-
const itemType = typeof item.type === 'string' ? item.type : '';
|
|
977
|
-
const itemId = typeof item.id === 'string' && item.id.trim()
|
|
978
|
-
? item.id.trim()
|
|
979
|
-
: (typeof item.call_id === 'string' && item.call_id.trim() ? item.call_id.trim() : `item_${crypto.randomBytes(8).toString('hex')}`);
|
|
980
|
-
|
|
981
|
-
// Emit item added so Codex can anchor subsequent deltas by output_index/content_index/item_id.
|
|
982
|
-
writeSse(res, 'response.output_item.added', {
|
|
983
|
-
type: 'response.output_item.added',
|
|
984
|
-
output_index: outputIndex,
|
|
985
|
-
item: { ...item, id: itemId }
|
|
986
|
-
});
|
|
987
|
-
|
|
988
|
-
if (itemType === 'message') {
|
|
989
|
-
const content = Array.isArray(item.content) ? item.content : [];
|
|
990
|
-
for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
|
|
991
|
-
const block = content[contentIndex];
|
|
992
|
-
if (!block || typeof block !== 'object') continue;
|
|
993
|
-
if (block.type !== 'output_text') continue;
|
|
994
|
-
const text = typeof block.text === 'string' ? block.text : '';
|
|
995
|
-
if (text) {
|
|
996
|
-
writeSse(res, 'response.output_text.delta', {
|
|
997
|
-
type: 'response.output_text.delta',
|
|
998
|
-
item_id: itemId,
|
|
999
|
-
output_index: outputIndex,
|
|
1000
|
-
content_index: contentIndex,
|
|
1001
|
-
delta: text,
|
|
1002
|
-
sequence_number: nextSeq()
|
|
1003
|
-
});
|
|
1004
|
-
}
|
|
1005
|
-
writeSse(res, 'response.output_text.done', {
|
|
1006
|
-
type: 'response.output_text.done',
|
|
1007
|
-
item_id: itemId,
|
|
1008
|
-
output_index: outputIndex,
|
|
1009
|
-
content_index: contentIndex,
|
|
1010
|
-
text,
|
|
1011
|
-
sequence_number: nextSeq()
|
|
1012
|
-
});
|
|
1013
|
-
}
|
|
1014
|
-
}
|
|
1015
|
-
|
|
1016
|
-
// Emit item done for all item types (message/function_call/etc).
|
|
1017
|
-
writeSse(res, 'response.output_item.done', {
|
|
1018
|
-
type: 'response.output_item.done',
|
|
1019
|
-
output_index: outputIndex,
|
|
1020
|
-
item: { ...item, id: itemId },
|
|
1021
|
-
sequence_number: nextSeq()
|
|
1022
|
-
});
|
|
1023
|
-
}
|
|
1024
|
-
|
|
1025
|
-
writeSse(res, 'response.completed', { type: 'response.completed', response });
|
|
1026
|
-
writeSse(res, 'done', '[DONE]');
|
|
1027
|
-
}
|
|
1028
|
-
|
|
1029
|
-
function extractResponsesOutputText(payload) {
|
|
1030
|
-
if (!payload || typeof payload !== 'object') return '';
|
|
1031
|
-
const output = Array.isArray(payload.output) ? payload.output : [];
|
|
1032
|
-
for (const item of output) {
|
|
1033
|
-
if (!item || typeof item !== 'object') continue;
|
|
1034
|
-
if (item.type !== 'message') continue;
|
|
1035
|
-
const content = Array.isArray(item.content) ? item.content : [];
|
|
1036
|
-
for (const block of content) {
|
|
1037
|
-
if (!block || typeof block !== 'object') continue;
|
|
1038
|
-
if (block.type !== 'output_text') continue;
|
|
1039
|
-
if (typeof block.text === 'string') return block.text;
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
if (typeof payload.output_text === 'string') return payload.output_text;
|
|
1043
|
-
return '';
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
function toUpstreamNonStreamingResponsesPayload(payload) {
|
|
1047
|
-
const body = payload && typeof payload === 'object' ? payload : {};
|
|
1048
|
-
const normalized = { ...body, stream: false };
|
|
1049
|
-
if (Array.isArray(body.tools)) {
|
|
1050
|
-
normalized.tools = normalizeResponsesToolsForResponsesApi(body.tools);
|
|
1051
|
-
}
|
|
1052
|
-
return normalized;
|
|
1053
|
-
}
|
|
1054
|
-
|
|
1055
|
-
function shouldFallbackFromUpstreamResponses(status, bodyText) {
|
|
1056
|
-
if (!Number.isFinite(status)) return false;
|
|
1057
|
-
// Common "unsupported" status codes for a route.
|
|
1058
|
-
if (status === 404 || status === 405 || status === 501) return true;
|
|
1059
|
-
|
|
1060
|
-
// Some OpenAI-compatible gateways respond with 500 + "not implemented" (e.g. convert_request_failed)
|
|
1061
|
-
// instead of 404/405 for unsupported endpoints. In that case we can safely fallback to chat/completions.
|
|
1062
|
-
const text = String(bodyText || '');
|
|
1063
|
-
if (!text) return false;
|
|
1064
|
-
if (/not implemented/i.test(text)) return true;
|
|
1065
|
-
if (/convert_request_failed/i.test(text)) return true;
|
|
1066
|
-
if (/unknown (endpoint|route)/i.test(text)) return true;
|
|
1067
|
-
if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
|
|
1068
|
-
if (/does not support.*responses/i.test(text)) return true;
|
|
1069
|
-
if (/name['"`]?\s+is a required property/i.test(text) && /tools/i.test(text) && /function/i.test(text)) return true;
|
|
1070
|
-
|
|
1071
|
-
// Best-effort parse for structured error codes.
|
|
1072
|
-
try {
|
|
1073
|
-
const parsed = JSON.parse(text);
|
|
1074
|
-
const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
|
|
1075
|
-
const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
|
|
1076
|
-
if (code === 'convert_request_failed') return true;
|
|
1077
|
-
if (/not implemented/i.test(msg)) return true;
|
|
1078
|
-
if (/unknown (endpoint|route)/i.test(msg)) return true;
|
|
1079
|
-
if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
|
|
1080
|
-
if (/does not support.*responses/i.test(msg)) return true;
|
|
1081
|
-
if (/name['"`]?\s+is a required property/i.test(msg) && /tools/i.test(msg) && /function/i.test(msg)) return true;
|
|
1082
|
-
} catch (_) {}
|
|
1083
|
-
|
|
1084
|
-
return false;
|
|
1085
|
-
}
|
|
1086
|
-
|
|
1087
|
-
// 仅识别"端点级别不支持"——可缓存,与 per-request 的 tool 格式错误区分。
|
|
1088
|
-
function isResponsesEndpointUnsupported(status, bodyText) {
|
|
1089
|
-
if (!Number.isFinite(status)) return false;
|
|
1090
|
-
if (status === 404 || status === 405 || status === 501) return true;
|
|
1091
|
-
const text = String(bodyText || '');
|
|
1092
|
-
if (!text) return false;
|
|
1093
|
-
if (/not implemented/i.test(text)) return true;
|
|
1094
|
-
if (/convert_request_failed/i.test(text)) return true;
|
|
1095
|
-
if (/unknown (endpoint|route)/i.test(text)) return true;
|
|
1096
|
-
if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
|
|
1097
|
-
if (/does not support.*responses/i.test(text)) return true;
|
|
1098
|
-
try {
|
|
1099
|
-
const parsed = JSON.parse(text);
|
|
1100
|
-
const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
|
|
1101
|
-
const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
|
|
1102
|
-
if (code === 'convert_request_failed') return true;
|
|
1103
|
-
if (/not implemented/i.test(msg)) return true;
|
|
1104
|
-
if (/unknown (endpoint|route)/i.test(msg)) return true;
|
|
1105
|
-
if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
|
|
1106
|
-
if (/does not support.*responses/i.test(msg)) return true;
|
|
1107
|
-
} catch (_) {}
|
|
1108
|
-
return false;
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
function isLoopbackAddress(address) {
|
|
1112
|
-
if (!address) return false;
|
|
1113
|
-
const value = String(address);
|
|
1114
|
-
return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
|
|
1115
|
-
}
|
|
1116
|
-
|
|
1117
|
-
function isTransientNetworkError(error) {
|
|
1118
|
-
const text = String(error || '').trim();
|
|
1119
|
-
if (!text) return false;
|
|
1120
|
-
if (/socket hang up/i.test(text)) return true;
|
|
1121
|
-
if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
|
|
1122
|
-
if (/EAI_AGAIN/i.test(text)) return true;
|
|
1123
|
-
if (/UND_ERR_SOCKET/i.test(text)) return true;
|
|
1124
|
-
if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
|
|
1125
|
-
return false;
|
|
1126
|
-
}
|
|
1127
|
-
|
|
1128
|
-
const TRANSIENT_RETRY_DELAYS_MS = [200, 600];
|
|
1129
|
-
|
|
1130
|
-
async function retryTransientRequest(executor) {
|
|
1131
|
-
let lastResult = null;
|
|
1132
|
-
for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
1133
|
-
if (attempt > 0) {
|
|
1134
|
-
const delay = TRANSIENT_RETRY_DELAYS_MS[attempt - 1];
|
|
1135
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1136
|
-
await new Promise((r) => {
|
|
1137
|
-
const t = setTimeout(r, delay);
|
|
1138
|
-
if (typeof t.unref === 'function') t.unref();
|
|
1139
|
-
});
|
|
1140
|
-
}
|
|
1141
|
-
// eslint-disable-next-line no-await-in-loop
|
|
1142
|
-
const result = await executor(attempt);
|
|
1143
|
-
lastResult = result;
|
|
1144
|
-
if (!result) return result;
|
|
1145
|
-
if (result.ok) return result;
|
|
1146
|
-
if (result.retry) return result;
|
|
1147
|
-
if (result.status && result.status > 0) return result;
|
|
1148
|
-
if (!isTransientNetworkError(result.error)) return result;
|
|
1149
|
-
}
|
|
1150
|
-
return lastResult;
|
|
1151
|
-
}
|
|
1152
|
-
|
|
1153
|
-
function writeSse(res, eventName, dataObj) {
|
|
1154
|
-
if (!res || res.writableEnded || res.destroyed) return;
|
|
1155
|
-
if (eventName) {
|
|
1156
|
-
res.write(`event: ${eventName}\n`);
|
|
1157
|
-
}
|
|
1158
|
-
if (dataObj === '[DONE]') {
|
|
1159
|
-
res.write('data: [DONE]\n\n');
|
|
1160
|
-
return;
|
|
1161
|
-
}
|
|
1162
|
-
res.write(`data: ${JSON.stringify(dataObj)}\n\n`);
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
function appendChatStreamToolCall(target, toolCall) {
|
|
1166
|
-
if (!toolCall || typeof toolCall !== 'object') return;
|
|
1167
|
-
const index = Number.isFinite(toolCall.index) ? toolCall.index : target.length;
|
|
1168
|
-
if (!target[index]) {
|
|
1169
|
-
target[index] = {
|
|
1170
|
-
id: '',
|
|
1171
|
-
type: 'function',
|
|
1172
|
-
function: { name: '', arguments: '' }
|
|
1173
|
-
};
|
|
1174
|
-
}
|
|
1175
|
-
const current = target[index];
|
|
1176
|
-
if (typeof toolCall.id === 'string' && toolCall.id) current.id = toolCall.id;
|
|
1177
|
-
if (typeof toolCall.type === 'string' && toolCall.type) current.type = toolCall.type;
|
|
1178
|
-
const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : null;
|
|
1179
|
-
if (fn) {
|
|
1180
|
-
if (typeof fn.name === 'string' && fn.name) current.function.name = fn.name;
|
|
1181
|
-
if (typeof fn.arguments === 'string') current.function.arguments += fn.arguments;
|
|
1182
|
-
}
|
|
1183
|
-
}
|
|
1184
|
-
|
|
1185
|
-
function writeChatCompletionChunkAsResponsesSse(state, chunk) {
|
|
1186
|
-
if (!chunk || typeof chunk !== 'object') return;
|
|
1187
|
-
if (typeof chunk.model === 'string' && chunk.model) {
|
|
1188
|
-
state.model = chunk.model;
|
|
1189
|
-
}
|
|
1190
|
-
const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
|
|
1191
|
-
for (const choice of choices) {
|
|
1192
|
-
const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
|
|
1193
|
-
if (!delta) continue;
|
|
1194
|
-
|
|
1195
|
-
const segments = [];
|
|
1196
|
-
// DeepSeek-style OpenAI-compatible streams may emit private reasoning in
|
|
1197
|
-
// `reasoning_content` before the final answer. Responses `output_text`
|
|
1198
|
-
// must stay user-visible answer text only; forwarding reasoning here
|
|
1199
|
-
// pollutes Codex output and breaks exact-answer prompts.
|
|
1200
|
-
if (typeof delta.content === 'string' && delta.content) {
|
|
1201
|
-
segments.push(delta.content);
|
|
1202
|
-
}
|
|
1203
|
-
for (const seg of segments) {
|
|
1204
|
-
if (!state.messageItem) {
|
|
1205
|
-
state.messageItem = {
|
|
1206
|
-
id: `msg_${crypto.randomBytes(8).toString('hex')}`,
|
|
1207
|
-
type: 'message',
|
|
1208
|
-
role: 'assistant',
|
|
1209
|
-
content: [{ type: 'output_text', text: '' }]
|
|
1210
|
-
};
|
|
1211
|
-
state.output.push(state.messageItem);
|
|
1212
|
-
writeSse(state.res, 'response.output_item.added', {
|
|
1213
|
-
type: 'response.output_item.added',
|
|
1214
|
-
output_index: state.output.length - 1,
|
|
1215
|
-
item: state.messageItem
|
|
1216
|
-
});
|
|
1217
|
-
}
|
|
1218
|
-
state.messageText += seg;
|
|
1219
|
-
state.messageItem.content[0].text = state.messageText;
|
|
1220
|
-
writeSse(state.res, 'response.output_text.delta', {
|
|
1221
|
-
type: 'response.output_text.delta',
|
|
1222
|
-
item_id: state.messageItem.id,
|
|
1223
|
-
output_index: state.output.length - 1,
|
|
1224
|
-
content_index: 0,
|
|
1225
|
-
delta: seg,
|
|
1226
|
-
sequence_number: state.nextSeq()
|
|
1227
|
-
});
|
|
1228
|
-
}
|
|
1229
|
-
|
|
1230
|
-
if (Array.isArray(delta.tool_calls)) {
|
|
1231
|
-
for (const toolCall of delta.tool_calls) {
|
|
1232
|
-
appendChatStreamToolCall(state.toolCalls, toolCall);
|
|
1233
|
-
}
|
|
1234
|
-
}
|
|
1235
|
-
|
|
1236
|
-
if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
|
|
1237
|
-
state.sawFinishReason = true;
|
|
1238
|
-
}
|
|
1239
|
-
}
|
|
1240
|
-
}
|
|
1241
|
-
|
|
1242
|
-
function finishChatStreamResponsesSse(state) {
|
|
1243
|
-
if (!state || state.finished) return;
|
|
1244
|
-
state.finished = true;
|
|
1245
|
-
|
|
1246
|
-
if (state.messageItem) {
|
|
1247
|
-
const outputIndex = state.output.indexOf(state.messageItem);
|
|
1248
|
-
writeSse(state.res, 'response.output_text.done', {
|
|
1249
|
-
type: 'response.output_text.done',
|
|
1250
|
-
item_id: state.messageItem.id,
|
|
1251
|
-
output_index: outputIndex,
|
|
1252
|
-
content_index: 0,
|
|
1253
|
-
text: state.messageText,
|
|
1254
|
-
sequence_number: state.nextSeq()
|
|
1255
|
-
});
|
|
1256
|
-
writeSse(state.res, 'response.output_item.done', {
|
|
1257
|
-
type: 'response.output_item.done',
|
|
1258
|
-
output_index: outputIndex,
|
|
1259
|
-
item: state.messageItem,
|
|
1260
|
-
sequence_number: state.nextSeq()
|
|
1261
|
-
});
|
|
1262
|
-
}
|
|
1263
|
-
|
|
1264
|
-
for (const toolCall of state.toolCalls) {
|
|
1265
|
-
if (!toolCall) continue;
|
|
1266
|
-
const item = buildResponsesToolCallItemFromChatToolCall(toolCall, state.toolTypesByName || {});
|
|
1267
|
-
if (!item) continue;
|
|
1268
|
-
const outputIndex = state.output.length;
|
|
1269
|
-
state.output.push(item);
|
|
1270
|
-
writeSse(state.res, 'response.output_item.added', {
|
|
1271
|
-
type: 'response.output_item.added',
|
|
1272
|
-
output_index: outputIndex,
|
|
1273
|
-
item
|
|
1274
|
-
});
|
|
1275
|
-
writeSse(state.res, 'response.output_item.done', {
|
|
1276
|
-
type: 'response.output_item.done',
|
|
1277
|
-
output_index: outputIndex,
|
|
1278
|
-
item,
|
|
1279
|
-
sequence_number: state.nextSeq()
|
|
1280
|
-
});
|
|
1281
|
-
}
|
|
1282
|
-
|
|
1283
|
-
const response = ensureResponseMetadata({
|
|
1284
|
-
id: state.responseId,
|
|
1285
|
-
model: state.model,
|
|
1286
|
-
created_at: state.createdAt,
|
|
1287
|
-
status: 'completed',
|
|
1288
|
-
output: state.output,
|
|
1289
|
-
output_text: state.messageText
|
|
1290
|
-
});
|
|
1291
|
-
writeSse(state.res, 'response.completed', { type: 'response.completed', response });
|
|
1292
|
-
writeSse(state.res, 'done', '[DONE]');
|
|
1293
|
-
if (!state.res.writableEnded && !state.res.destroyed) {
|
|
1294
|
-
state.res.end();
|
|
1295
|
-
}
|
|
1296
|
-
}
|
|
1297
|
-
|
|
1298
|
-
function failChatStreamResponsesSse(state, errorMessage) {
|
|
1299
|
-
if (!state || state.finished) return;
|
|
1300
|
-
state.finished = true;
|
|
1301
|
-
writeSse(state.res, 'response.failed', {
|
|
1302
|
-
type: 'response.failed',
|
|
1303
|
-
response: ensureResponseMetadata({
|
|
1304
|
-
id: state.responseId,
|
|
1305
|
-
model: state.model,
|
|
1306
|
-
created_at: state.createdAt,
|
|
1307
|
-
status: 'failed',
|
|
1308
|
-
output: state.output,
|
|
1309
|
-
output_text: state.messageText
|
|
1310
|
-
}),
|
|
1311
|
-
error: String(errorMessage || 'upstream stream failed')
|
|
1312
|
-
});
|
|
1313
|
-
writeSse(state.res, 'done', '[DONE]');
|
|
1314
|
-
if (!state.res.writableEnded && !state.res.destroyed) {
|
|
1315
|
-
state.res.end();
|
|
1316
|
-
}
|
|
1317
|
-
}
|
|
1318
|
-
|
|
1319
|
-
function formatUpstreamStreamError(errorValue) {
|
|
1320
|
-
if (!errorValue) return 'upstream stream failed';
|
|
1321
|
-
if (typeof errorValue === 'string') return errorValue;
|
|
1322
|
-
if (typeof errorValue === 'object') {
|
|
1323
|
-
if (typeof errorValue.message === 'string' && errorValue.message) return errorValue.message;
|
|
1324
|
-
try { return JSON.stringify(errorValue); } catch (_) {}
|
|
1325
|
-
}
|
|
1326
|
-
return String(errorValue || 'upstream stream failed');
|
|
1327
|
-
}
|
|
1328
|
-
|
|
1329
|
-
function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
|
|
1330
|
-
const parsed = new URL(targetUrl);
|
|
1331
|
-
const transport = parsed.protocol === 'https:' ? https : http;
|
|
1332
|
-
const bodyText = options.body ? JSON.stringify(options.body) : '';
|
|
1333
|
-
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
|
|
1334
|
-
? Math.floor(options.maxBytes)
|
|
1335
|
-
: 0;
|
|
1336
|
-
const headers = {
|
|
1337
|
-
'Accept': 'text/event-stream',
|
|
1338
|
-
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
1339
|
-
...(options.headers || {})
|
|
1340
|
-
};
|
|
1341
|
-
if (options.body) {
|
|
1342
|
-
headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
|
|
1343
|
-
}
|
|
1344
|
-
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
1345
|
-
? Math.max(1000, Number(options.timeoutMs))
|
|
1346
|
-
: STREAM_IDLE_TIMEOUT_MS;
|
|
1347
|
-
const res = options.res;
|
|
1348
|
-
const fallbackModel = typeof options.model === 'string' ? options.model : '';
|
|
1349
|
-
|
|
1350
|
-
return new Promise((resolve) => {
|
|
1351
|
-
let settled = false;
|
|
1352
|
-
let upstreamReq = null;
|
|
1353
|
-
const finish = (value) => {
|
|
1354
|
-
if (settled) return;
|
|
1355
|
-
settled = true;
|
|
1356
|
-
resolve(value);
|
|
1357
|
-
};
|
|
1358
|
-
const abortUpstream = () => {
|
|
1359
|
-
if (upstreamReq) {
|
|
1360
|
-
try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {}
|
|
1361
|
-
}
|
|
1362
|
-
};
|
|
1363
|
-
if (res && typeof res.once === 'function') {
|
|
1364
|
-
res.once('close', abortUpstream);
|
|
1365
|
-
}
|
|
1366
|
-
|
|
1367
|
-
upstreamReq = transport.request({
|
|
1368
|
-
protocol: parsed.protocol,
|
|
1369
|
-
hostname: parsed.hostname,
|
|
1370
|
-
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
1371
|
-
method: options.method || 'POST',
|
|
1372
|
-
path: `${parsed.pathname}${parsed.search}`,
|
|
1373
|
-
headers,
|
|
1374
|
-
agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
|
|
1375
|
-
}, (upstreamRes) => {
|
|
1376
|
-
const status = upstreamRes.statusCode || 0;
|
|
1377
|
-
const chunks = [];
|
|
1378
|
-
let size = 0;
|
|
1379
|
-
const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
|
|
1380
|
-
|
|
1381
|
-
const collectChunk = (chunk) => {
|
|
1382
|
-
if (!chunk) return true;
|
|
1383
|
-
if (maxBytes > 0) {
|
|
1384
|
-
size += chunk.length;
|
|
1385
|
-
if (size > maxBytes) {
|
|
1386
|
-
chunks.length = 0;
|
|
1387
|
-
try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
|
|
1388
|
-
try { upstreamReq.destroy(new Error('response too large')); } catch (_) {}
|
|
1389
|
-
finish({ ok: false, status, error: 'response too large' });
|
|
1390
|
-
return false;
|
|
1391
|
-
}
|
|
1392
|
-
}
|
|
1393
|
-
chunks.push(chunk);
|
|
1394
|
-
return true;
|
|
1395
|
-
};
|
|
1396
|
-
|
|
1397
|
-
if (status >= 400) {
|
|
1398
|
-
upstreamRes.on('data', collectChunk);
|
|
1399
|
-
upstreamRes.on('end', () => finish({
|
|
1400
|
-
ok: false,
|
|
1401
|
-
status,
|
|
1402
|
-
bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
|
|
1403
|
-
}));
|
|
1404
|
-
return;
|
|
1405
|
-
}
|
|
1406
|
-
|
|
1407
|
-
if (!res.headersSent) {
|
|
1408
|
-
res.writeHead(200, {
|
|
1409
|
-
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
1410
|
-
'Cache-Control': 'no-cache',
|
|
1411
|
-
'Connection': 'keep-alive',
|
|
1412
|
-
'X-Accel-Buffering': 'no'
|
|
1413
|
-
});
|
|
1414
|
-
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
1415
|
-
}
|
|
1416
|
-
|
|
1417
|
-
if (!/text\/event-stream/i.test(contentType)) {
|
|
1418
|
-
upstreamRes.on('data', collectChunk);
|
|
1419
|
-
upstreamRes.on('end', () => {
|
|
1420
|
-
const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
|
|
1421
|
-
const parsedJson = parseJsonOrError(text);
|
|
1422
|
-
if (parsedJson.error) {
|
|
1423
|
-
writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
|
|
1424
|
-
writeSse(res, 'done', '[DONE]');
|
|
1425
|
-
if (!res.writableEnded && !res.destroyed) res.end();
|
|
1426
|
-
finish({ ok: true });
|
|
1427
|
-
return;
|
|
1428
|
-
}
|
|
1429
|
-
const extracted = extractChatCompletionResult(parsedJson.value);
|
|
1430
|
-
sendResponsesSse(res, buildResponsesPayloadFromChatResult(fallbackModel, extracted.text, extracted.toolCalls, parsedJson.value, {
|
|
1431
|
-
toolTypesByName: options.toolTypesByName || {}
|
|
1432
|
-
}));
|
|
1433
|
-
if (!res.writableEnded && !res.destroyed) res.end();
|
|
1434
|
-
finish({ ok: true });
|
|
1435
|
-
});
|
|
1436
|
-
return;
|
|
1437
|
-
}
|
|
1438
|
-
|
|
1439
|
-
let sequence = 0;
|
|
1440
|
-
const state = {
|
|
1441
|
-
res,
|
|
1442
|
-
responseId: `resp_${crypto.randomBytes(10).toString('hex')}`,
|
|
1443
|
-
model: fallbackModel,
|
|
1444
|
-
createdAt: Math.floor(Date.now() / 1000),
|
|
1445
|
-
output: [],
|
|
1446
|
-
messageItem: null,
|
|
1447
|
-
messageText: '',
|
|
1448
|
-
toolCalls: [],
|
|
1449
|
-
toolTypesByName: options.toolTypesByName || {},
|
|
1450
|
-
finished: false,
|
|
1451
|
-
sawDone: false,
|
|
1452
|
-
sawFinishReason: false,
|
|
1453
|
-
nextSeq: () => {
|
|
1454
|
-
sequence += 1;
|
|
1455
|
-
return sequence;
|
|
1456
|
-
}
|
|
1457
|
-
};
|
|
1458
|
-
writeSse(res, 'response.created', {
|
|
1459
|
-
type: 'response.created',
|
|
1460
|
-
response: {
|
|
1461
|
-
id: state.responseId,
|
|
1462
|
-
model: state.model,
|
|
1463
|
-
created_at: state.createdAt
|
|
1464
|
-
}
|
|
1465
|
-
});
|
|
1466
|
-
|
|
1467
|
-
let buffer = '';
|
|
1468
|
-
const utf8Decoder = new StringDecoder('utf8');
|
|
1469
|
-
const handleEventBlock = (block) => {
|
|
1470
|
-
const dataLines = String(block || '')
|
|
1471
|
-
.split(/\r?\n/)
|
|
1472
|
-
.filter((line) => line.startsWith('data:'))
|
|
1473
|
-
.map((line) => line.slice(5).trimStart());
|
|
1474
|
-
if (dataLines.length === 0) return;
|
|
1475
|
-
const data = dataLines.join('\n').trim();
|
|
1476
|
-
if (!data) return;
|
|
1477
|
-
if (data === '[DONE]') {
|
|
1478
|
-
state.sawDone = true;
|
|
1479
|
-
finishChatStreamResponsesSse(state);
|
|
1480
|
-
finish({ ok: true });
|
|
1481
|
-
return;
|
|
1482
|
-
}
|
|
1483
|
-
const parsedChunk = parseJsonOrError(data);
|
|
1484
|
-
if (!parsedChunk.error) {
|
|
1485
|
-
if (parsedChunk.value && typeof parsedChunk.value === 'object' && parsedChunk.value.error) {
|
|
1486
|
-
failChatStreamResponsesSse(state, formatUpstreamStreamError(parsedChunk.value.error));
|
|
1487
|
-
finish({ ok: true });
|
|
1488
|
-
return;
|
|
1489
|
-
}
|
|
1490
|
-
writeChatCompletionChunkAsResponsesSse(state, parsedChunk.value);
|
|
1491
|
-
}
|
|
1492
|
-
};
|
|
1493
|
-
|
|
1494
|
-
upstreamRes.on('data', (chunk) => {
|
|
1495
|
-
if (!chunk) return;
|
|
1496
|
-
buffer += utf8Decoder.write(chunk);
|
|
1497
|
-
let boundary = buffer.search(/\r?\n\r?\n/);
|
|
1498
|
-
while (boundary >= 0) {
|
|
1499
|
-
const block = buffer.slice(0, boundary);
|
|
1500
|
-
const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
|
|
1501
|
-
buffer = buffer.slice(boundary + (match ? match[0].length : 2));
|
|
1502
|
-
handleEventBlock(block);
|
|
1503
|
-
boundary = buffer.search(/\r?\n\r?\n/);
|
|
1504
|
-
}
|
|
1505
|
-
});
|
|
1506
|
-
upstreamRes.on('end', () => {
|
|
1507
|
-
buffer += utf8Decoder.end();
|
|
1508
|
-
if (buffer.trim()) handleEventBlock(buffer);
|
|
1509
|
-
if (!state.finished && !state.sawDone && !state.sawFinishReason) {
|
|
1510
|
-
failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]');
|
|
1511
|
-
finish({ ok: true });
|
|
1512
|
-
return;
|
|
1513
|
-
}
|
|
1514
|
-
finishChatStreamResponsesSse(state);
|
|
1515
|
-
finish({ ok: true });
|
|
1516
|
-
});
|
|
1517
|
-
upstreamRes.on('aborted', () => {
|
|
1518
|
-
failChatStreamResponsesSse(state, 'upstream stream aborted');
|
|
1519
|
-
finish({ ok: true });
|
|
1520
|
-
});
|
|
1521
|
-
upstreamRes.on('error', (err) => {
|
|
1522
|
-
failChatStreamResponsesSse(state, err && err.message ? err.message : 'upstream stream failed');
|
|
1523
|
-
finish({ ok: true });
|
|
1524
|
-
});
|
|
1525
|
-
});
|
|
1526
|
-
upstreamReq.setTimeout(timeoutMs, () => {
|
|
1527
|
-
try { upstreamReq.destroy(new Error('timeout')); } catch (_) {}
|
|
1528
|
-
finish({ ok: false, error: 'timeout' });
|
|
1529
|
-
});
|
|
1530
|
-
upstreamReq.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
|
|
1531
|
-
if (bodyText) upstreamReq.write(bodyText);
|
|
1532
|
-
upstreamReq.end();
|
|
1533
|
-
});
|
|
1534
|
-
}
|
|
1535
|
-
|
|
1536
|
-
async function proxyRequestJson(targetUrl, options = {}) {
|
|
1537
|
-
const parsed = new URL(targetUrl);
|
|
1538
|
-
const transport = parsed.protocol === 'https:' ? https : http;
|
|
1539
|
-
const bodyText = options.body ? JSON.stringify(options.body) : '';
|
|
1540
|
-
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
|
|
1541
|
-
? Math.floor(options.maxBytes)
|
|
1542
|
-
: 0;
|
|
1543
|
-
const headers = {
|
|
1544
|
-
'Accept': 'application/json',
|
|
1545
|
-
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
1546
|
-
...(options.headers || {})
|
|
1547
|
-
};
|
|
1548
|
-
if (options.body) {
|
|
1549
|
-
headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
|
|
1550
|
-
}
|
|
1551
|
-
|
|
1552
|
-
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
1553
|
-
? Math.max(1000, Number(options.timeoutMs))
|
|
1554
|
-
: REQUEST_TIMEOUT_MS;
|
|
1555
|
-
return new Promise((resolve) => {
|
|
1556
|
-
let settled = false;
|
|
1557
|
-
const finish = (value) => {
|
|
1558
|
-
if (settled) return;
|
|
1559
|
-
settled = true;
|
|
1560
|
-
resolve(value);
|
|
1561
|
-
};
|
|
1562
|
-
const req = transport.request({
|
|
1563
|
-
protocol: parsed.protocol,
|
|
1564
|
-
hostname: parsed.hostname,
|
|
1565
|
-
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
1566
|
-
method: options.method || 'GET',
|
|
1567
|
-
path: `${parsed.pathname}${parsed.search}`,
|
|
1568
|
-
headers,
|
|
1569
|
-
agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
|
|
1570
|
-
}, (upstreamRes) => {
|
|
1571
|
-
const chunks = [];
|
|
1572
|
-
let size = 0;
|
|
1573
|
-
upstreamRes.on('data', (chunk) => {
|
|
1574
|
-
if (!chunk) return;
|
|
1575
|
-
if (maxBytes > 0) {
|
|
1576
|
-
size += chunk.length;
|
|
1577
|
-
if (size > maxBytes) {
|
|
1578
|
-
chunks.length = 0;
|
|
1579
|
-
try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
|
|
1580
|
-
try { req.destroy(new Error('response too large')); } catch (_) {}
|
|
1581
|
-
finish({ ok: false, error: 'response too large' });
|
|
1582
|
-
return;
|
|
1583
|
-
}
|
|
1584
|
-
}
|
|
1585
|
-
chunks.push(chunk);
|
|
1586
|
-
});
|
|
1587
|
-
upstreamRes.on('end', () => {
|
|
1588
|
-
const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
|
|
1589
|
-
finish({
|
|
1590
|
-
ok: true,
|
|
1591
|
-
status: upstreamRes.statusCode || 0,
|
|
1592
|
-
headers: upstreamRes.headers || {},
|
|
1593
|
-
bodyText: text
|
|
1594
|
-
});
|
|
1595
|
-
});
|
|
1596
|
-
});
|
|
1597
|
-
req.setTimeout(timeoutMs, () => {
|
|
1598
|
-
try { req.destroy(new Error('timeout')); } catch (_) {}
|
|
1599
|
-
finish({ ok: false, error: 'timeout' });
|
|
1600
|
-
});
|
|
1601
|
-
req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
|
|
1602
|
-
if (bodyText) {
|
|
1603
|
-
req.write(bodyText);
|
|
1604
|
-
}
|
|
1605
|
-
req.end();
|
|
1606
|
-
});
|
|
1607
|
-
}
|
|
1608
|
-
|
|
1609
|
-
function createOpenaiBridgeHttpHandler(options = {}) {
|
|
1610
|
-
const settingsFile = options.settingsFile;
|
|
1611
|
-
const expectedTokenRaw = typeof options.expectedToken === 'string' ? options.expectedToken.trim() : '';
|
|
1612
|
-
const expectedToken = Object.prototype.hasOwnProperty.call(options, 'expectedToken')
|
|
1613
|
-
? expectedTokenRaw
|
|
1614
|
-
: (expectedTokenRaw || DEFAULT_BRIDGE_TOKEN);
|
|
1615
|
-
const maxBodySize = Number.isFinite(options.maxBodySize) ? options.maxBodySize : 0;
|
|
1616
|
-
const httpAgent = options.httpAgent;
|
|
1617
|
-
const httpsAgent = options.httpsAgent;
|
|
1618
|
-
const maxUpstreamBytes = Number.isFinite(options.maxUpstreamBytes) && options.maxUpstreamBytes > 0
|
|
1619
|
-
? Math.floor(options.maxUpstreamBytes)
|
|
1620
|
-
: Math.max(16 * 1024 * 1024, maxBodySize > 0 ? maxBodySize * 4 : 0);
|
|
1621
|
-
|
|
1622
|
-
if (!settingsFile) {
|
|
1623
|
-
throw new Error('createOpenaiBridgeHttpHandler 缺少 settingsFile');
|
|
1624
|
-
}
|
|
1625
|
-
|
|
1626
|
-
// 端点不支持的缓存(per-baseUrl, TTL 30 分钟):避免每次非流式请求重复探测 /v1/responses。
|
|
1627
|
-
const unsupportedResponses = new Map();
|
|
1628
|
-
const isResponsesKnownUnsupported = (baseUrl) => {
|
|
1629
|
-
if (!baseUrl) return false;
|
|
1630
|
-
const entry = unsupportedResponses.get(baseUrl);
|
|
1631
|
-
if (!entry) return false;
|
|
1632
|
-
if (entry.expiresAt <= Date.now()) {
|
|
1633
|
-
unsupportedResponses.delete(baseUrl);
|
|
1634
|
-
return false;
|
|
1635
|
-
}
|
|
1636
|
-
return true;
|
|
1637
|
-
};
|
|
1638
|
-
const markResponsesUnsupported = (baseUrl) => {
|
|
1639
|
-
if (!baseUrl) return;
|
|
1640
|
-
unsupportedResponses.set(baseUrl, { expiresAt: Date.now() + RESPONSES_UNSUPPORTED_TTL_MS });
|
|
1641
|
-
};
|
|
1642
|
-
const clearResponsesUnsupported = (baseUrl) => {
|
|
1643
|
-
if (!baseUrl) return;
|
|
1644
|
-
unsupportedResponses.delete(baseUrl);
|
|
1645
|
-
};
|
|
1646
|
-
|
|
1647
|
-
const matchPath = (requestPath) => {
|
|
1648
|
-
const normalized = String(requestPath || '');
|
|
1649
|
-
const prefix = '/bridge/openai/';
|
|
1650
|
-
if (!normalized.startsWith(prefix)) return null;
|
|
1651
|
-
const rest = normalized.slice(prefix.length);
|
|
1652
|
-
const [provider, ...tail] = rest.split('/').filter((part) => part.length > 0);
|
|
1653
|
-
if (!provider) return null;
|
|
1654
|
-
const tailPath = '/' + tail.join('/');
|
|
1655
|
-
if (!tailPath.startsWith('/v1')) return null;
|
|
1656
|
-
const suffix = tailPath === '/v1' ? '' : tailPath.replace(/^\/v1\/?/, '');
|
|
1657
|
-
return { provider, suffix };
|
|
1658
|
-
};
|
|
1659
|
-
|
|
1660
|
-
const handler = (req, res) => {
|
|
1661
|
-
let parsedUrl;
|
|
1662
|
-
try {
|
|
1663
|
-
parsedUrl = new URL(req.url || '/', 'http://localhost');
|
|
1664
|
-
} catch (_) {
|
|
1665
|
-
return false;
|
|
1666
|
-
}
|
|
1667
|
-
const match = matchPath(parsedUrl.pathname || '/');
|
|
1668
|
-
if (!match) return false;
|
|
1669
|
-
|
|
1670
|
-
void (async () => {
|
|
1671
|
-
try {
|
|
1672
|
-
const token = extractAuthorizationToken(req);
|
|
1673
|
-
// 兼容:某些客户端在自定义 base_url 时可能不带 Authorization。
|
|
1674
|
-
// 为避免在 LAN 暴露无鉴权的代理,这里仅允许 loopback 连接缺省 token。
|
|
1675
|
-
const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
|
|
1676
|
-
const isLoopback = isLoopbackAddress(remoteAddr);
|
|
1677
|
-
if (!isLoopback && !expectedToken) {
|
|
1678
|
-
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1679
|
-
res.end(JSON.stringify({ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)' }));
|
|
1680
|
-
return;
|
|
1681
|
-
}
|
|
1682
|
-
if (!token && !isLoopback) {
|
|
1683
|
-
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1684
|
-
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1685
|
-
return;
|
|
1686
|
-
}
|
|
1687
|
-
// loopback 上的本地代理:允许客户端携带任意 Authorization(例如 Codex 会附带 provider apiKey)。
|
|
1688
|
-
// 非 loopback 时仍强制校验 expectedToken,避免局域网被未授权调用。
|
|
1689
|
-
if (!isLoopback && token && token !== expectedToken) {
|
|
1690
|
-
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1691
|
-
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1692
|
-
return;
|
|
1693
|
-
}
|
|
1694
|
-
|
|
1695
|
-
const upstream = resolveOpenaiBridgeUpstream(settingsFile, match.provider);
|
|
1696
|
-
if (upstream.error) {
|
|
1697
|
-
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1698
|
-
res.end(JSON.stringify({ error: upstream.error }));
|
|
1699
|
-
return;
|
|
1700
|
-
}
|
|
1701
|
-
|
|
1702
|
-
const suffix = match.suffix || '';
|
|
1703
|
-
const normalizedSuffix = suffix.replace(/^\/+/, '');
|
|
1704
|
-
|
|
1705
|
-
const authHeader = upstream.apiKey
|
|
1706
|
-
? (/^bearer\s+/i.test(upstream.apiKey) ? upstream.apiKey : `Bearer ${upstream.apiKey}`)
|
|
1707
|
-
: '';
|
|
1708
|
-
const upstreamHeaders = upstream && upstream.headers && typeof upstream.headers === 'object' && !Array.isArray(upstream.headers)
|
|
1709
|
-
? upstream.headers
|
|
1710
|
-
: {};
|
|
1711
|
-
|
|
1712
|
-
if (!normalizedSuffix) {
|
|
1713
|
-
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
|
1714
|
-
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1715
|
-
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
1716
|
-
return;
|
|
1717
|
-
}
|
|
1718
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1719
|
-
res.end(JSON.stringify({
|
|
1720
|
-
object: 'codexmate.openai_bridge',
|
|
1721
|
-
provider: match.provider,
|
|
1722
|
-
status: 'ok',
|
|
1723
|
-
endpoints: ['/v1/responses', '/v1/models']
|
|
1724
|
-
}));
|
|
1725
|
-
return;
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
if (normalizedSuffix === 'models') {
|
|
1729
|
-
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
|
1730
|
-
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1731
|
-
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
1732
|
-
return;
|
|
1733
|
-
}
|
|
1734
|
-
|
|
1735
|
-
const url = joinApiUrl(upstream.baseUrl, 'models');
|
|
1736
|
-
const result = await retryTransientRequest(() => proxyRequestJson(url, {
|
|
1737
|
-
method: 'GET',
|
|
1738
|
-
headers: {
|
|
1739
|
-
...(authHeader ? { Authorization: authHeader } : {}),
|
|
1740
|
-
...upstreamHeaders
|
|
1741
|
-
},
|
|
1742
|
-
maxBytes: maxUpstreamBytes,
|
|
1743
|
-
httpAgent,
|
|
1744
|
-
httpsAgent
|
|
1745
|
-
}));
|
|
1746
|
-
if (!result.ok) {
|
|
1747
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1748
|
-
res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` }));
|
|
1749
|
-
return;
|
|
1750
|
-
}
|
|
1751
|
-
res.writeHead(result.status || 502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1752
|
-
res.end(result.bodyText || '');
|
|
1753
|
-
return;
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
if (normalizedSuffix !== 'responses') {
|
|
1757
|
-
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1758
|
-
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
1759
|
-
return;
|
|
1760
|
-
}
|
|
1761
|
-
|
|
1762
|
-
if ((req.method || 'GET').toUpperCase() !== 'POST') {
|
|
1763
|
-
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1764
|
-
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
1765
|
-
return;
|
|
1766
|
-
}
|
|
1767
|
-
|
|
1768
|
-
const { body, error: bodyErr } = await readRequestBody(req, maxBodySize);
|
|
1769
|
-
if (bodyErr) {
|
|
1770
|
-
res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1771
|
-
res.end(JSON.stringify({ error: bodyErr }));
|
|
1772
|
-
return;
|
|
1773
|
-
}
|
|
1774
|
-
const parsed = parseJsonOrError(body);
|
|
1775
|
-
if (parsed.error) {
|
|
1776
|
-
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1777
|
-
res.end(JSON.stringify({ error: `Invalid JSON: ${parsed.error}` }));
|
|
1778
|
-
return;
|
|
1779
|
-
}
|
|
1780
|
-
|
|
1781
|
-
const responsesRequest = parsed.value;
|
|
1782
|
-
const streamRequested = !!(responsesRequest && typeof responsesRequest === 'object' && responsesRequest.stream === true);
|
|
1783
|
-
const acceptHeader = req && req.headers ? (req.headers.accept || req.headers.Accept || '') : '';
|
|
1784
|
-
const wantsSse = /text\/event-stream/i.test(String(acceptHeader || ''));
|
|
1785
|
-
|
|
1786
|
-
if (streamRequested && wantsSse) {
|
|
1787
|
-
const converted = convertResponsesRequestToChatCompletions(responsesRequest);
|
|
1788
|
-
if (converted.error) {
|
|
1789
|
-
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1790
|
-
res.end(JSON.stringify({ error: converted.error }));
|
|
1791
|
-
return;
|
|
1792
|
-
}
|
|
1793
|
-
const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
|
|
1794
|
-
const chatBody = { ...converted.chat, stream: true };
|
|
1795
|
-
const streamed = await retryTransientRequest(() => streamChatCompletionsAsResponsesSse(upstreamUrl, {
|
|
1796
|
-
method: 'POST',
|
|
1797
|
-
body: chatBody,
|
|
1798
|
-
headers: {
|
|
1799
|
-
...(authHeader ? { Authorization: authHeader } : {}),
|
|
1800
|
-
...upstreamHeaders
|
|
1801
|
-
},
|
|
1802
|
-
maxBytes: maxUpstreamBytes,
|
|
1803
|
-
httpAgent,
|
|
1804
|
-
httpsAgent,
|
|
1805
|
-
res,
|
|
1806
|
-
model: typeof chatBody.model === 'string' ? chatBody.model : '',
|
|
1807
|
-
toolTypesByName: converted.toolTypesByName || {}
|
|
1808
|
-
}));
|
|
1809
|
-
if (!streamed.ok) {
|
|
1810
|
-
if (res.writableEnded || res.destroyed) {
|
|
1811
|
-
return;
|
|
1812
|
-
}
|
|
1813
|
-
if (!res.headersSent) {
|
|
1814
|
-
res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1815
|
-
res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'Upstream request failed' }));
|
|
1816
|
-
} else if (!res.writableEnded && !res.destroyed) {
|
|
1817
|
-
writeSse(res, 'response.failed', { type: 'response.failed', error: streamed.error || streamed.bodyText || 'Upstream request failed' });
|
|
1818
|
-
writeSse(res, 'done', '[DONE]');
|
|
1819
|
-
res.end();
|
|
1820
|
-
}
|
|
1821
|
-
}
|
|
1822
|
-
return;
|
|
1823
|
-
}
|
|
1824
|
-
|
|
1825
|
-
// Maxx-style behavior: prefer upstream /responses if supported.
|
|
1826
|
-
// Fallback to /chat/completions conversion when upstream does not implement /responses (404/405).
|
|
1827
|
-
// 已知不支持的上游:直接跳过探测,节省一次 round-trip。
|
|
1828
|
-
const skipResponsesProbe = isResponsesKnownUnsupported(upstream.baseUrl);
|
|
1829
|
-
const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
|
|
1830
|
-
const upstreamResponsesResult = skipResponsesProbe
|
|
1831
|
-
? { ok: true, status: 404, bodyText: '' }
|
|
1832
|
-
: await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, {
|
|
1833
|
-
method: 'POST',
|
|
1834
|
-
body: toUpstreamNonStreamingResponsesPayload(responsesRequest),
|
|
1835
|
-
headers: {
|
|
1836
|
-
...(authHeader ? { Authorization: authHeader } : {}),
|
|
1837
|
-
...upstreamHeaders
|
|
1838
|
-
},
|
|
1839
|
-
maxBytes: maxUpstreamBytes,
|
|
1840
|
-
httpAgent,
|
|
1841
|
-
httpsAgent
|
|
1842
|
-
}));
|
|
1843
|
-
|
|
1844
|
-
if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) {
|
|
1845
|
-
clearResponsesUnsupported(upstream.baseUrl);
|
|
1846
|
-
const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText);
|
|
1847
|
-
if (upstreamJson.error) {
|
|
1848
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1849
|
-
res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
|
|
1850
|
-
return;
|
|
1851
|
-
}
|
|
1852
|
-
const upstreamPayload = upstreamJson.value;
|
|
1853
|
-
if (streamRequested && wantsSse) {
|
|
1854
|
-
res.writeHead(200, {
|
|
1855
|
-
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
1856
|
-
'Cache-Control': 'no-cache',
|
|
1857
|
-
'Connection': 'keep-alive',
|
|
1858
|
-
'X-Accel-Buffering': 'no'
|
|
1859
|
-
});
|
|
1860
|
-
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
1861
|
-
sendResponsesSse(res, upstreamPayload);
|
|
1862
|
-
res.end();
|
|
1863
|
-
return;
|
|
1864
|
-
}
|
|
1865
|
-
|
|
1866
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1867
|
-
res.end(JSON.stringify(ensureResponseMetadata(upstreamPayload)));
|
|
1868
|
-
return;
|
|
1869
|
-
}
|
|
1870
|
-
|
|
1871
|
-
if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 400) {
|
|
1872
|
-
if (!shouldFallbackFromUpstreamResponses(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
|
|
1873
|
-
res.writeHead(upstreamResponsesResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1874
|
-
res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
|
|
1875
|
-
return;
|
|
1876
|
-
}
|
|
1877
|
-
if (!skipResponsesProbe && isResponsesEndpointUnsupported(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
|
|
1878
|
-
markResponsesUnsupported(upstream.baseUrl);
|
|
1879
|
-
}
|
|
1880
|
-
// fallthrough to chat/completions conversion
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
if (!upstreamResponsesResult.ok) {
|
|
1884
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1885
|
-
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResponsesResult.error}` }));
|
|
1886
|
-
return;
|
|
1887
|
-
}
|
|
1888
|
-
|
|
1889
|
-
const converted = convertResponsesRequestToChatCompletions(responsesRequest);
|
|
1890
|
-
if (converted.error) {
|
|
1891
|
-
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1892
|
-
res.end(JSON.stringify({ error: converted.error }));
|
|
1893
|
-
return;
|
|
1894
|
-
}
|
|
1895
|
-
|
|
1896
|
-
const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
|
|
1897
|
-
const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
|
|
1898
|
-
method: 'POST',
|
|
1899
|
-
body: converted.chat,
|
|
1900
|
-
headers: {
|
|
1901
|
-
...(authHeader ? { Authorization: authHeader } : {}),
|
|
1902
|
-
...upstreamHeaders
|
|
1903
|
-
},
|
|
1904
|
-
maxBytes: maxUpstreamBytes,
|
|
1905
|
-
httpAgent,
|
|
1906
|
-
httpsAgent
|
|
1907
|
-
}));
|
|
1908
|
-
if (!upstreamResult.ok) {
|
|
1909
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1910
|
-
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));
|
|
1911
|
-
return;
|
|
1912
|
-
}
|
|
1913
|
-
|
|
1914
|
-
const upstreamJson = parseJsonOrError(upstreamResult.bodyText);
|
|
1915
|
-
if (upstreamResult.status >= 400) {
|
|
1916
|
-
res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1917
|
-
res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
|
|
1918
|
-
return;
|
|
1919
|
-
}
|
|
1920
|
-
if (upstreamJson.error) {
|
|
1921
|
-
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1922
|
-
res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
|
|
1923
|
-
return;
|
|
1924
|
-
}
|
|
1925
|
-
|
|
1926
|
-
const model = typeof converted.chat.model === 'string' ? converted.chat.model : '';
|
|
1927
|
-
const extracted = extractChatCompletionResult(upstreamJson.value);
|
|
1928
|
-
const text = extracted && typeof extracted.text === 'string' ? extracted.text : '';
|
|
1929
|
-
const toolCalls = extracted && Array.isArray(extracted.toolCalls) ? extracted.toolCalls : [];
|
|
1930
|
-
const responsesPayload = buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamJson.value, {
|
|
1931
|
-
toolTypesByName: converted.toolTypesByName || {}
|
|
1932
|
-
});
|
|
1933
|
-
|
|
1934
|
-
if (converted.streamRequested && wantsSse) {
|
|
1935
|
-
res.writeHead(200, {
|
|
1936
|
-
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
1937
|
-
'Cache-Control': 'no-cache',
|
|
1938
|
-
'Connection': 'keep-alive',
|
|
1939
|
-
'X-Accel-Buffering': 'no'
|
|
1940
|
-
});
|
|
1941
|
-
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
1942
|
-
sendResponsesSse(res, responsesPayload);
|
|
1943
|
-
res.end();
|
|
1944
|
-
return;
|
|
1945
|
-
}
|
|
1946
|
-
|
|
1947
|
-
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1948
|
-
res.end(JSON.stringify(ensureResponseMetadata(responsesPayload)));
|
|
1949
|
-
} catch (e) {
|
|
1950
|
-
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1951
|
-
res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' }));
|
|
1952
|
-
}
|
|
1953
|
-
})();
|
|
1954
|
-
|
|
1955
|
-
return true;
|
|
1956
|
-
};
|
|
1957
|
-
|
|
1958
|
-
handler.matchPath = matchPath;
|
|
1959
|
-
return handler;
|
|
1960
|
-
}
|
|
1961
|
-
|
|
1962
|
-
module.exports = {
|
|
1963
|
-
readOpenaiBridgeSettings,
|
|
1964
|
-
upsertOpenaiBridgeProvider,
|
|
1965
|
-
resolveOpenaiBridgeUpstream,
|
|
1966
|
-
createOpenaiBridgeHttpHandler,
|
|
1967
|
-
// exported for local-bridge reuse
|
|
1968
|
-
convertResponsesRequestToChatCompletions,
|
|
1969
|
-
streamChatCompletionsAsResponsesSse,
|
|
1970
|
-
proxyRequestJson,
|
|
1971
|
-
ensureResponseMetadata,
|
|
1972
|
-
sendResponsesSse,
|
|
1973
|
-
extractAuthorizationToken,
|
|
1974
|
-
readRequestBody,
|
|
1975
|
-
parseJsonOrError,
|
|
1976
|
-
extractChatCompletionResult,
|
|
1977
|
-
buildResponsesPayloadFromChatResult,
|
|
1978
|
-
retryTransientRequest,
|
|
1979
|
-
normalizeOpenaiUpstreamBaseUrl,
|
|
1980
|
-
extractResponsesOutputText,
|
|
1981
|
-
shouldFallbackFromUpstreamResponses,
|
|
1982
|
-
isTransientNetworkError,
|
|
1983
|
-
isLoopbackAddress
|
|
1984
|
-
};
|
|
1
|
+
const http = require('http');
|
|
2
|
+
const https = require('https');
|
|
3
|
+
const crypto = require('crypto');
|
|
4
|
+
const { StringDecoder } = require('string_decoder');
|
|
5
|
+
const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils');
|
|
6
|
+
const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils');
|
|
7
|
+
|
|
8
|
+
const DEFAULT_BRIDGE_TOKEN = crypto.randomBytes(16).toString('hex');
|
|
9
|
+
const SETTINGS_VERSION = 1;
|
|
10
|
+
// 推理模型 reasoning 阶段可能长时间无字节输出,需匹配 codex 的 stream_idle_timeout_ms=300000。
|
|
11
|
+
const STREAM_IDLE_TIMEOUT_MS = 5 * 60 * 1000;
|
|
12
|
+
const REQUEST_TIMEOUT_MS = 5 * 60 * 1000;
|
|
13
|
+
const RESPONSES_UNSUPPORTED_TTL_MS = 30 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
function normalizeText(value) {
|
|
16
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function normalizeProviderName(value) {
|
|
20
|
+
// Provider name validation is done elsewhere; keep this conservative.
|
|
21
|
+
return normalizeText(value);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function normalizeOpenaiUpstreamBaseUrl(rawValue) {
|
|
25
|
+
const normalized = normalizeBaseUrl(rawValue);
|
|
26
|
+
if (!normalized) return '';
|
|
27
|
+
try {
|
|
28
|
+
const parsed = new URL(normalized);
|
|
29
|
+
let pathname = String(parsed.pathname || '').replace(/\/+$/g, '');
|
|
30
|
+
|
|
31
|
+
// If user accidentally pasted a full endpoint, strip it back to the base URL.
|
|
32
|
+
// Keep direct provider routes (e.g. /project/ym) intact.
|
|
33
|
+
pathname = pathname
|
|
34
|
+
.replace(/\/v1\/chat\/completions$/i, '/v1')
|
|
35
|
+
.replace(/\/chat\/completions$/i, '')
|
|
36
|
+
.replace(/\/v1\/responses$/i, '/v1')
|
|
37
|
+
.replace(/\/responses$/i, '')
|
|
38
|
+
.replace(/\/v1\/models$/i, '/v1')
|
|
39
|
+
.replace(/\/models$/i, '');
|
|
40
|
+
|
|
41
|
+
// Normalize empty/root path.
|
|
42
|
+
if (pathname === '/') pathname = '';
|
|
43
|
+
|
|
44
|
+
const rebuilt = `${parsed.origin}${pathname}`;
|
|
45
|
+
return normalizeBaseUrl(rebuilt);
|
|
46
|
+
} catch (_) {
|
|
47
|
+
return normalized;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function normalizeUpstreamEntry(entry) {
|
|
52
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
const baseUrl = normalizeOpenaiUpstreamBaseUrl(entry.baseUrl || entry.base_url || '');
|
|
56
|
+
const apiKey = normalizeText(entry.apiKey || entry.api_key || entry.key || '');
|
|
57
|
+
const headersRaw = entry.headers || entry.extraHeaders || entry.extra_headers || null;
|
|
58
|
+
const headers = normalizeHeadersMap(headersRaw);
|
|
59
|
+
if (!baseUrl || !isValidHttpUrl(baseUrl)) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
return { baseUrl, apiKey, headers };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeHeadersMap(value) {
|
|
66
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
67
|
+
return {};
|
|
68
|
+
}
|
|
69
|
+
const forbidden = new Set([
|
|
70
|
+
'authorization',
|
|
71
|
+
'host',
|
|
72
|
+
'content-length',
|
|
73
|
+
'connection',
|
|
74
|
+
'transfer-encoding',
|
|
75
|
+
'keep-alive',
|
|
76
|
+
'proxy-authenticate',
|
|
77
|
+
'proxy-authorization',
|
|
78
|
+
'te',
|
|
79
|
+
'trailer',
|
|
80
|
+
'upgrade'
|
|
81
|
+
]);
|
|
82
|
+
const result = {};
|
|
83
|
+
for (const [rawKey, rawVal] of Object.entries(value)) {
|
|
84
|
+
const key = typeof rawKey === 'string' ? rawKey.trim() : '';
|
|
85
|
+
if (!key) continue;
|
|
86
|
+
const lower = key.toLowerCase();
|
|
87
|
+
if (forbidden.has(lower)) continue;
|
|
88
|
+
if (typeof rawVal !== 'string') continue;
|
|
89
|
+
result[key] = rawVal;
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function readOpenaiBridgeSettings(filePath) {
|
|
95
|
+
const parsed = readJsonFile(filePath, null);
|
|
96
|
+
const providers = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
97
|
+
? parsed.providers
|
|
98
|
+
: null;
|
|
99
|
+
const providerMap = providers && typeof providers === 'object' && !Array.isArray(providers)
|
|
100
|
+
? providers
|
|
101
|
+
: {};
|
|
102
|
+
return {
|
|
103
|
+
version: SETTINGS_VERSION,
|
|
104
|
+
providers: providerMap
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, apiKey, headers) {
|
|
109
|
+
const name = normalizeProviderName(providerName);
|
|
110
|
+
const baseUrl = normalizeOpenaiUpstreamBaseUrl(upstreamBaseUrl);
|
|
111
|
+
const key = normalizeText(apiKey);
|
|
112
|
+
const nextHeaders = normalizeHeadersMap(headers);
|
|
113
|
+
|
|
114
|
+
if (!name) {
|
|
115
|
+
return { error: 'Provider name is required' };
|
|
116
|
+
}
|
|
117
|
+
if (!baseUrl || !isValidHttpUrl(baseUrl)) {
|
|
118
|
+
return { error: 'Upstream base URL is invalid' };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const settings = readOpenaiBridgeSettings(filePath);
|
|
122
|
+
const existing = settings && settings.providers ? settings.providers[name] : null;
|
|
123
|
+
const existingHeaders = existing && typeof existing === 'object' && !Array.isArray(existing)
|
|
124
|
+
? normalizeHeadersMap(existing.headers || existing.extraHeaders || existing.extra_headers || null)
|
|
125
|
+
: {};
|
|
126
|
+
const next = {
|
|
127
|
+
version: SETTINGS_VERSION,
|
|
128
|
+
providers: {
|
|
129
|
+
...(settings.providers || {}),
|
|
130
|
+
[name]: {
|
|
131
|
+
baseUrl,
|
|
132
|
+
apiKey: key,
|
|
133
|
+
headers: Object.keys(nextHeaders).length ? nextHeaders : existingHeaders
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
};
|
|
137
|
+
writeJsonAtomic(filePath, next);
|
|
138
|
+
return { success: true };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function resolveOpenaiBridgeUpstream(filePath, providerName) {
|
|
142
|
+
const name = normalizeProviderName(providerName);
|
|
143
|
+
if (!name) return { error: 'Provider name is required' };
|
|
144
|
+
const settings = readOpenaiBridgeSettings(filePath);
|
|
145
|
+
const entry = settings.providers ? settings.providers[name] : null;
|
|
146
|
+
const normalized = normalizeUpstreamEntry(entry);
|
|
147
|
+
if (!normalized) {
|
|
148
|
+
return { error: `OpenAI 转换未配置: ${name}` };
|
|
149
|
+
}
|
|
150
|
+
return { provider: name, ...normalized };
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function extractAuthorizationToken(req) {
|
|
154
|
+
const header = typeof req.headers.authorization === 'string' ? req.headers.authorization.trim() : '';
|
|
155
|
+
if (!header) return '';
|
|
156
|
+
if (/^bearer\s+/i.test(header)) {
|
|
157
|
+
return header.replace(/^bearer\s+/i, '').trim();
|
|
158
|
+
}
|
|
159
|
+
return header;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function readRequestBody(req, maxBytes) {
|
|
163
|
+
return new Promise((resolve) => {
|
|
164
|
+
let body = '';
|
|
165
|
+
let size = 0;
|
|
166
|
+
let aborted = false;
|
|
167
|
+
req.on('data', (chunk) => {
|
|
168
|
+
if (aborted) return;
|
|
169
|
+
size += chunk.length;
|
|
170
|
+
if (Number.isFinite(maxBytes) && maxBytes > 0 && size > maxBytes) {
|
|
171
|
+
aborted = true;
|
|
172
|
+
try { req.destroy(); } catch (_) {}
|
|
173
|
+
resolve({ error: '请求体过大' });
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
body += chunk;
|
|
177
|
+
});
|
|
178
|
+
req.on('end', () => {
|
|
179
|
+
if (aborted) return;
|
|
180
|
+
resolve({ body });
|
|
181
|
+
});
|
|
182
|
+
req.on('error', (err) => resolve({ error: err && err.message ? err.message : 'request failed' }));
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function parseJsonOrError(text) {
|
|
187
|
+
if (typeof text !== 'string' || !text.trim()) {
|
|
188
|
+
return { value: null, error: 'empty body' };
|
|
189
|
+
}
|
|
190
|
+
try {
|
|
191
|
+
return { value: JSON.parse(text), error: '' };
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return { value: null, error: e && e.message ? e.message : 'invalid json' };
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function extractChatCompletionResult(payload) {
|
|
198
|
+
if (!payload || typeof payload !== 'object') return { text: '', toolCalls: [] };
|
|
199
|
+
const choice = Array.isArray(payload.choices) ? payload.choices[0] : null;
|
|
200
|
+
const message = choice && typeof choice === 'object' ? choice.message : null;
|
|
201
|
+
const toolCalls = message && typeof message === 'object' && Array.isArray(message.tool_calls)
|
|
202
|
+
? message.tool_calls
|
|
203
|
+
: [];
|
|
204
|
+
const content = message && typeof message === 'object' ? message.content : '';
|
|
205
|
+
let text = '';
|
|
206
|
+
if (typeof content === 'string') {
|
|
207
|
+
text = content;
|
|
208
|
+
} else if (Array.isArray(content)) {
|
|
209
|
+
text = content
|
|
210
|
+
.map((item) => {
|
|
211
|
+
if (!item) return '';
|
|
212
|
+
if (typeof item === 'string') return item;
|
|
213
|
+
if (typeof item === 'object') {
|
|
214
|
+
if (typeof item.text === 'string') return item.text;
|
|
215
|
+
if (typeof item.content === 'string') return item.content;
|
|
216
|
+
}
|
|
217
|
+
return '';
|
|
218
|
+
})
|
|
219
|
+
.filter(Boolean)
|
|
220
|
+
.join('');
|
|
221
|
+
}
|
|
222
|
+
return { text, toolCalls };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function stringifyJsonValue(value, fallback = '') {
|
|
226
|
+
if (typeof value === 'string') return value;
|
|
227
|
+
if (value == null) return fallback;
|
|
228
|
+
try {
|
|
229
|
+
return JSON.stringify(value);
|
|
230
|
+
} catch (_) {
|
|
231
|
+
return fallback;
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseJsonValueOrNull(value) {
|
|
236
|
+
if (typeof value !== 'string') return null;
|
|
237
|
+
const text = value.trim();
|
|
238
|
+
if (!text) return null;
|
|
239
|
+
try {
|
|
240
|
+
return JSON.parse(text);
|
|
241
|
+
} catch (_) {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function isRecord(value) {
|
|
247
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function asTrimmedString(value) {
|
|
251
|
+
return typeof value === 'string' ? value.trim() : '';
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function cloneJsonValue(value) {
|
|
255
|
+
if (Array.isArray(value)) return value.map((item) => cloneJsonValue(item));
|
|
256
|
+
if (isRecord(value)) {
|
|
257
|
+
return Object.fromEntries(Object.entries(value).map(([key, item]) => [key, cloneJsonValue(item)]));
|
|
258
|
+
}
|
|
259
|
+
return value;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function normalizeResponsesToolOutput(value) {
|
|
263
|
+
if (typeof value === 'string') return value;
|
|
264
|
+
if (value == null) return '';
|
|
265
|
+
return stringifyJsonValue(value, '');
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function normalizeOpenAiToolArguments(value) {
|
|
269
|
+
if (typeof value === 'string') return value;
|
|
270
|
+
if (value == null) return '{}';
|
|
271
|
+
return stringifyJsonValue(value, '{}');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
function normalizeInputFileBlock(item) {
|
|
275
|
+
if (!isRecord(item)) return null;
|
|
276
|
+
const file = isRecord(item.file) ? item.file : item;
|
|
277
|
+
const out = {};
|
|
278
|
+
const fileId = asTrimmedString(file.file_id || file.id);
|
|
279
|
+
const filename = asTrimmedString(file.filename || file.name);
|
|
280
|
+
const fileData = asTrimmedString(file.file_data || file.data);
|
|
281
|
+
const mimeType = asTrimmedString(file.mime_type || file.media_type);
|
|
282
|
+
if (fileId) out.file_id = fileId;
|
|
283
|
+
if (filename) out.filename = filename;
|
|
284
|
+
if (fileData) out.file_data = fileData;
|
|
285
|
+
if (mimeType) out.mime_type = mimeType;
|
|
286
|
+
return Object.keys(out).length > 0 ? out : null;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function normalizeResponsesContentBlockForChat(item) {
|
|
290
|
+
if (typeof item === 'string') return item.trim() ? item : null;
|
|
291
|
+
if (!isRecord(item)) return null;
|
|
292
|
+
|
|
293
|
+
const type = asTrimmedString(item.type).toLowerCase();
|
|
294
|
+
if (!type) {
|
|
295
|
+
const text = asTrimmedString(item.text || item.content || item.output_text);
|
|
296
|
+
return text ? { type: 'text', text } : null;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
if (type === 'input_text' || type === 'output_text' || type === 'text' || type === 'summary_text' || type === 'reasoning_text') {
|
|
300
|
+
const text = typeof item.text === 'string' ? item.text : asTrimmedString(item.content || item.output_text);
|
|
301
|
+
return text ? { type: 'text', text } : null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (type === 'refusal' && typeof item.refusal === 'string') {
|
|
305
|
+
return item.refusal ? { type: 'text', text: item.refusal } : null;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (type === 'input_image') {
|
|
309
|
+
const raw = item.image_url != null ? item.image_url : (item.url != null ? item.url : item.imageUrl);
|
|
310
|
+
if (raw === undefined) return null;
|
|
311
|
+
return {
|
|
312
|
+
type: 'image_url',
|
|
313
|
+
image_url: typeof raw === 'string' ? { url: raw } : cloneJsonValue(raw)
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
if (type === 'image_url' && item.image_url !== undefined) {
|
|
318
|
+
return { type: 'image_url', image_url: item.image_url };
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (type === 'input_audio') {
|
|
322
|
+
if (item.input_audio !== undefined) return { type: 'input_audio', input_audio: item.input_audio };
|
|
323
|
+
if (item.data !== undefined || item.format !== undefined) {
|
|
324
|
+
return { type: 'input_audio', input_audio: { data: item.data, format: item.format } };
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
if (type === 'input_file' || type === 'file') {
|
|
330
|
+
const file = normalizeInputFileBlock(item);
|
|
331
|
+
return file ? { type: 'file', file } : null;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
if (type === 'reasoning' || type === 'thinking' || type === 'redacted_reasoning') {
|
|
335
|
+
const text = asTrimmedString(item.text || item.content);
|
|
336
|
+
return text ? { type: 'text', text } : null;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const text = asTrimmedString(item.text || item.content);
|
|
340
|
+
return text ? { type: 'text', text } : cloneJsonValue(item);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function toOpenAiMessageContent(content) {
|
|
344
|
+
if (typeof content === 'string') return content;
|
|
345
|
+
if (!Array.isArray(content)) {
|
|
346
|
+
if (isRecord(content)) {
|
|
347
|
+
const single = normalizeResponsesContentBlockForChat(content);
|
|
348
|
+
if (!single) return '';
|
|
349
|
+
return typeof single === 'string' ? single : [single];
|
|
350
|
+
}
|
|
351
|
+
return '';
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
const blocks = content
|
|
355
|
+
.map((item) => normalizeResponsesContentBlockForChat(item))
|
|
356
|
+
.filter((item) => !!item);
|
|
357
|
+
|
|
358
|
+
if (blocks.length === 0) return '';
|
|
359
|
+
if (blocks.length === 1 && typeof blocks[0] === 'string') return blocks[0];
|
|
360
|
+
return blocks;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const RESPONSES_TOOL_CALL_INPUT_TYPES = new Set(['function_call', 'custom_tool_call', 'mcp_tool_call', 'local_shell_call']);
|
|
364
|
+
const RESPONSES_TOOL_CALL_OUTPUT_TYPES = new Set(['function_call_output', 'custom_tool_call_output', 'mcp_tool_call_output', 'tool_search_output', 'local_shell_call_output']);
|
|
365
|
+
|
|
366
|
+
function stripOrphanedResponsesToolOutputs(input) {
|
|
367
|
+
if (!Array.isArray(input)) return input;
|
|
368
|
+
const seenToolCallIds = new Set();
|
|
369
|
+
const sanitized = [];
|
|
370
|
+
for (const item of input) {
|
|
371
|
+
if (!isRecord(item)) {
|
|
372
|
+
sanitized.push(item);
|
|
373
|
+
continue;
|
|
374
|
+
}
|
|
375
|
+
const type = asTrimmedString(item.type).toLowerCase();
|
|
376
|
+
if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(type)) {
|
|
377
|
+
const callId = asTrimmedString(item.call_id || item.id);
|
|
378
|
+
if (callId) seenToolCallIds.add(callId);
|
|
379
|
+
sanitized.push(item);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(type)) {
|
|
383
|
+
const callId = asTrimmedString(item.call_id || item.id);
|
|
384
|
+
if (!callId || !seenToolCallIds.has(callId)) continue;
|
|
385
|
+
sanitized.push(item);
|
|
386
|
+
continue;
|
|
387
|
+
}
|
|
388
|
+
sanitized.push(item);
|
|
389
|
+
}
|
|
390
|
+
return sanitized;
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
function normalizeFreeformToolArguments(value) {
|
|
394
|
+
if (typeof value === 'string') return stringifyJsonValue({ input: value }, '{"input":""}');
|
|
395
|
+
if (value == null) return '{"input":""}';
|
|
396
|
+
if (isRecord(value) && Object.prototype.hasOwnProperty.call(value, 'input')) {
|
|
397
|
+
return stringifyJsonValue(value, '{"input":""}');
|
|
398
|
+
}
|
|
399
|
+
return stringifyJsonValue({ input: normalizeResponsesToolOutput(value) }, '{"input":""}');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function toOpenAiToolCall(item, fallbackIndex) {
|
|
403
|
+
if (!isRecord(item)) return null;
|
|
404
|
+
const callId = asTrimmedString(item.call_id || item.id) || `call_${crypto.randomBytes(8).toString('hex')}_${fallbackIndex}`;
|
|
405
|
+
const type = asTrimmedString(item.type).toLowerCase();
|
|
406
|
+
const name = asTrimmedString(item.name)
|
|
407
|
+
|| asTrimmedString(item.server_label)
|
|
408
|
+
|| (type === 'local_shell_call' ? 'local_shell' : '');
|
|
409
|
+
if (!name) return null;
|
|
410
|
+
const rawArguments = item.arguments != null
|
|
411
|
+
? item.arguments
|
|
412
|
+
: (item.input != null ? item.input : (item.action != null ? item.action : item.command));
|
|
413
|
+
const args = (type === 'custom_tool_call' && item.arguments == null)
|
|
414
|
+
? normalizeFreeformToolArguments(rawArguments)
|
|
415
|
+
: normalizeOpenAiToolArguments(rawArguments);
|
|
416
|
+
return {
|
|
417
|
+
id: callId,
|
|
418
|
+
type: 'function',
|
|
419
|
+
function: {
|
|
420
|
+
name,
|
|
421
|
+
arguments: args
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
function hasOpenAiMessageContent(content) {
|
|
427
|
+
return typeof content === 'string'
|
|
428
|
+
? content.trim().length > 0
|
|
429
|
+
: Array.isArray(content) && content.length > 0;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
function normalizeResponsesInputToChatMessages(input) {
|
|
433
|
+
// Keep the OpenAI bridge in lockstep with the builtin proxy's Responses → Chat shim.
|
|
434
|
+
// Codex long-running tasks append richer Responses history (custom/local_shell/MCP calls)
|
|
435
|
+
// back into `input`; dropping those items makes the next model turn lose tool state and stop early.
|
|
436
|
+
const messages = [];
|
|
437
|
+
const normalizedInput = stripOrphanedResponsesToolOutputs(input);
|
|
438
|
+
let functionCallIndex = 0;
|
|
439
|
+
let pendingToolCalls = [];
|
|
440
|
+
const emittedToolCallIds = new Set();
|
|
441
|
+
|
|
442
|
+
const flushPendingToolCalls = () => {
|
|
443
|
+
if (pendingToolCalls.length <= 0) return;
|
|
444
|
+
for (const toolCall of pendingToolCalls) {
|
|
445
|
+
const callId = asTrimmedString(toolCall.id);
|
|
446
|
+
if (callId) emittedToolCallIds.add(callId);
|
|
447
|
+
}
|
|
448
|
+
messages.push({
|
|
449
|
+
role: 'assistant',
|
|
450
|
+
content: null,
|
|
451
|
+
tool_calls: pendingToolCalls
|
|
452
|
+
});
|
|
453
|
+
pendingToolCalls = [];
|
|
454
|
+
};
|
|
455
|
+
|
|
456
|
+
const pushToolOutputMessage = (callIdRaw, outputRaw) => {
|
|
457
|
+
const toolCallId = asTrimmedString(callIdRaw);
|
|
458
|
+
if (!toolCallId) return;
|
|
459
|
+
messages.push({
|
|
460
|
+
role: 'tool',
|
|
461
|
+
tool_call_id: toolCallId,
|
|
462
|
+
content: normalizeResponsesToolOutput(outputRaw)
|
|
463
|
+
});
|
|
464
|
+
};
|
|
465
|
+
|
|
466
|
+
const processInputItem = (item) => {
|
|
467
|
+
if (typeof item === 'string') {
|
|
468
|
+
flushPendingToolCalls();
|
|
469
|
+
const text = item.trim();
|
|
470
|
+
if (text) messages.push({ role: 'user', content: text });
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
if (!isRecord(item)) return;
|
|
474
|
+
|
|
475
|
+
const itemType = asTrimmedString(item.type).toLowerCase();
|
|
476
|
+
if (RESPONSES_TOOL_CALL_INPUT_TYPES.has(itemType)) {
|
|
477
|
+
const toolCall = toOpenAiToolCall(item, functionCallIndex);
|
|
478
|
+
functionCallIndex += 1;
|
|
479
|
+
if (toolCall) pendingToolCalls.push(toolCall);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
if (RESPONSES_TOOL_CALL_OUTPUT_TYPES.has(itemType)) {
|
|
484
|
+
flushPendingToolCalls();
|
|
485
|
+
const toolCallId = asTrimmedString(item.call_id || item.id);
|
|
486
|
+
if (!toolCallId || !emittedToolCallIds.has(toolCallId)) return;
|
|
487
|
+
pushToolOutputMessage(toolCallId, item.output != null ? item.output : item.content);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
if (itemType === 'reasoning') {
|
|
492
|
+
flushPendingToolCalls();
|
|
493
|
+
const reasoningContent = toOpenAiMessageContent(item.summary != null ? item.summary : (item.content != null ? item.content : item));
|
|
494
|
+
const reasoningSignature = asTrimmedString(item.encrypted_content || item.reasoning_signature);
|
|
495
|
+
if (!hasOpenAiMessageContent(reasoningContent) && !reasoningSignature) return;
|
|
496
|
+
const message = { role: 'assistant', content: reasoningContent };
|
|
497
|
+
if (reasoningSignature) message.reasoning_signature = reasoningSignature;
|
|
498
|
+
messages.push(message);
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
flushPendingToolCalls();
|
|
503
|
+
const role = asTrimmedString(item.role).toLowerCase() || 'user';
|
|
504
|
+
const normalizedRole = role === 'developer' ? 'system' : role;
|
|
505
|
+
const content = toOpenAiMessageContent(item.content != null ? item.content : (item.input != null ? item.input : item));
|
|
506
|
+
|
|
507
|
+
if (normalizedRole === 'tool') {
|
|
508
|
+
const toolCallId = asTrimmedString(item.tool_call_id || item.call_id || item.id);
|
|
509
|
+
if (!toolCallId || !emittedToolCallIds.has(toolCallId)) return;
|
|
510
|
+
pushToolOutputMessage(toolCallId, item.content);
|
|
511
|
+
return;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
if (!hasOpenAiMessageContent(content)) return;
|
|
515
|
+
const message = { role: normalizedRole, content };
|
|
516
|
+
const phase = asTrimmedString(item.phase);
|
|
517
|
+
if (phase) message.phase = phase;
|
|
518
|
+
messages.push(message);
|
|
519
|
+
};
|
|
520
|
+
|
|
521
|
+
if (typeof normalizedInput === 'string') {
|
|
522
|
+
const text = normalizedInput.trim();
|
|
523
|
+
if (text) messages.push({ role: 'user', content: text });
|
|
524
|
+
} else if (Array.isArray(normalizedInput)) {
|
|
525
|
+
for (const item of normalizedInput) processInputItem(item);
|
|
526
|
+
} else if (isRecord(normalizedInput)) {
|
|
527
|
+
processInputItem(normalizedInput);
|
|
528
|
+
}
|
|
529
|
+
flushPendingToolCalls();
|
|
530
|
+
return messages;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
function normalizeFunctionToolForChat(tool) {
|
|
534
|
+
if (!isRecord(tool)) return null;
|
|
535
|
+
const sourceFn = isRecord(tool.function) ? tool.function : tool;
|
|
536
|
+
const name = asTrimmedString(sourceFn.name) || asTrimmedString(tool.name);
|
|
537
|
+
if (!name) return null;
|
|
538
|
+
const fn = { name };
|
|
539
|
+
const description = asTrimmedString(sourceFn.description) || asTrimmedString(tool.description);
|
|
540
|
+
if (description) fn.description = description;
|
|
541
|
+
if (sourceFn.parameters !== undefined) {
|
|
542
|
+
fn.parameters = cloneJsonValue(sourceFn.parameters);
|
|
543
|
+
} else if (tool.parameters !== undefined) {
|
|
544
|
+
fn.parameters = cloneJsonValue(tool.parameters);
|
|
545
|
+
}
|
|
546
|
+
if (typeof sourceFn.strict === 'boolean') {
|
|
547
|
+
fn.strict = sourceFn.strict;
|
|
548
|
+
} else if (typeof tool.strict === 'boolean') {
|
|
549
|
+
fn.strict = tool.strict;
|
|
550
|
+
}
|
|
551
|
+
return { type: 'function', function: fn };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
function buildLocalShellToolForChat(tool) {
|
|
555
|
+
return {
|
|
556
|
+
type: 'function',
|
|
557
|
+
function: {
|
|
558
|
+
name: asTrimmedString(tool && tool.name) || 'local_shell',
|
|
559
|
+
description: asTrimmedString(tool && tool.description) || 'Run a local shell command and return its output.',
|
|
560
|
+
parameters: {
|
|
561
|
+
type: 'object',
|
|
562
|
+
properties: {
|
|
563
|
+
cmd: { type: 'string', description: 'Shell command to execute.' },
|
|
564
|
+
yield_time_ms: { type: 'number', description: 'Milliseconds to wait before yielding partial output.' },
|
|
565
|
+
max_output_tokens: { type: 'number', description: 'Maximum output tokens to return.' }
|
|
566
|
+
},
|
|
567
|
+
required: ['cmd'],
|
|
568
|
+
additionalProperties: true
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
function buildFreeformToolForChat(tool, fallbackName = 'custom_tool') {
|
|
575
|
+
return {
|
|
576
|
+
type: 'function',
|
|
577
|
+
function: {
|
|
578
|
+
name: asTrimmedString(tool && tool.name) || fallbackName,
|
|
579
|
+
description: asTrimmedString(tool && tool.description) || 'Pass raw freeform input to the local tool.',
|
|
580
|
+
parameters: {
|
|
581
|
+
type: 'object',
|
|
582
|
+
properties: {
|
|
583
|
+
input: { type: 'string', description: 'Raw tool input.' }
|
|
584
|
+
},
|
|
585
|
+
required: ['input'],
|
|
586
|
+
additionalProperties: false
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
};
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
const MAX_RESPONSES_TOOL_NAMESPACE_DEPTH = 5;
|
|
593
|
+
|
|
594
|
+
function rememberResponsesToolType(tool, target, depth = 0) {
|
|
595
|
+
if (!isRecord(tool) || !target || depth > MAX_RESPONSES_TOOL_NAMESPACE_DEPTH) return;
|
|
596
|
+
const type = asTrimmedString(tool.type).toLowerCase();
|
|
597
|
+
if (type === 'namespace' && Array.isArray(tool.tools)) {
|
|
598
|
+
for (const inner of tool.tools) rememberResponsesToolType(inner, target, depth + 1);
|
|
599
|
+
return;
|
|
600
|
+
}
|
|
601
|
+
const sourceFn = isRecord(tool.function) ? tool.function : tool;
|
|
602
|
+
const name = asTrimmedString(sourceFn.name) || asTrimmedString(tool.name);
|
|
603
|
+
if (!name) return;
|
|
604
|
+
if (type === 'local_shell') {
|
|
605
|
+
target[name] = 'local_shell_call';
|
|
606
|
+
return;
|
|
607
|
+
}
|
|
608
|
+
if (type === 'custom' || type === 'custom_tool' || (!type && name === 'apply_patch')) {
|
|
609
|
+
target[name] = 'custom_tool_call';
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
if (type === 'function') {
|
|
613
|
+
target[name] = 'function_call';
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
function collectResponsesToolTypesByName(tools) {
|
|
618
|
+
const result = {};
|
|
619
|
+
if (!Array.isArray(tools)) return result;
|
|
620
|
+
for (const tool of tools) rememberResponsesToolType(tool, result);
|
|
621
|
+
return result;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
function extractFreeformInputFromChatArguments(argumentsText) {
|
|
625
|
+
if (typeof argumentsText !== 'string') return '';
|
|
626
|
+
const parsed = parseJsonValueOrNull(argumentsText);
|
|
627
|
+
if (isRecord(parsed) && Object.prototype.hasOwnProperty.call(parsed, 'input')) {
|
|
628
|
+
return typeof parsed.input === 'string' ? parsed.input : normalizeResponsesToolOutput(parsed.input);
|
|
629
|
+
}
|
|
630
|
+
return argumentsText;
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
function extractLocalShellActionFromChatArguments(argumentsText) {
|
|
634
|
+
const parsed = parseJsonValueOrNull(argumentsText);
|
|
635
|
+
if (isRecord(parsed)) return cloneJsonValue(parsed);
|
|
636
|
+
return { cmd: typeof argumentsText === 'string' ? argumentsText : '' };
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
function buildResponsesToolCallItemFromChatToolCall(toolCall, toolTypesByName = {}) {
|
|
640
|
+
if (!isRecord(toolCall)) return null;
|
|
641
|
+
const fn = isRecord(toolCall.function) ? toolCall.function : {};
|
|
642
|
+
const name = asTrimmedString(fn.name);
|
|
643
|
+
if (!name) return null;
|
|
644
|
+
const callId = asTrimmedString(toolCall.id) || `call_${crypto.randomBytes(8).toString('hex')}`;
|
|
645
|
+
const argumentsText = typeof fn.arguments === 'string' ? fn.arguments : '';
|
|
646
|
+
const responseType = toolTypesByName && toolTypesByName[name] ? toolTypesByName[name] : 'function_call';
|
|
647
|
+
|
|
648
|
+
if (responseType === 'custom_tool_call') {
|
|
649
|
+
return {
|
|
650
|
+
type: 'custom_tool_call',
|
|
651
|
+
call_id: callId,
|
|
652
|
+
name,
|
|
653
|
+
input: extractFreeformInputFromChatArguments(argumentsText)
|
|
654
|
+
};
|
|
655
|
+
}
|
|
656
|
+
if (responseType === 'local_shell_call') {
|
|
657
|
+
return {
|
|
658
|
+
type: 'local_shell_call',
|
|
659
|
+
call_id: callId,
|
|
660
|
+
name,
|
|
661
|
+
action: extractLocalShellActionFromChatArguments(argumentsText)
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
return {
|
|
665
|
+
type: 'function_call',
|
|
666
|
+
call_id: callId,
|
|
667
|
+
name,
|
|
668
|
+
arguments: argumentsText
|
|
669
|
+
};
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
function normalizeSingleResponsesToolToChatTools(tool, depth = 0) {
|
|
673
|
+
if (!isRecord(tool) || depth > MAX_RESPONSES_TOOL_NAMESPACE_DEPTH) return [];
|
|
674
|
+
const type = asTrimmedString(tool.type).toLowerCase();
|
|
675
|
+
if (type === 'namespace' && Array.isArray(tool.tools)) {
|
|
676
|
+
return tool.tools.flatMap((inner) => normalizeSingleResponsesToolToChatTools(inner, depth + 1));
|
|
677
|
+
}
|
|
678
|
+
if (type === 'function') {
|
|
679
|
+
const converted = normalizeFunctionToolForChat(tool);
|
|
680
|
+
return converted ? [converted] : [];
|
|
681
|
+
}
|
|
682
|
+
if (type === 'local_shell') {
|
|
683
|
+
return [buildLocalShellToolForChat(tool)];
|
|
684
|
+
}
|
|
685
|
+
const name = asTrimmedString(tool.name);
|
|
686
|
+
if (type === 'custom' || type === 'custom_tool' || (!type && name === 'apply_patch')) {
|
|
687
|
+
return [buildFreeformToolForChat(tool, name || 'custom_tool')];
|
|
688
|
+
}
|
|
689
|
+
return [];
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
function normalizeResponsesToolsToChatTools(tools) {
|
|
693
|
+
if (!Array.isArray(tools)) return tools;
|
|
694
|
+
return tools.flatMap((tool) => normalizeSingleResponsesToolToChatTools(tool));
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function normalizeResponsesToolChoiceToChatToolChoice(toolChoice) {
|
|
698
|
+
if (toolChoice === undefined) return undefined;
|
|
699
|
+
if (typeof toolChoice === 'string') return toolChoice;
|
|
700
|
+
if (!isRecord(toolChoice)) return toolChoice;
|
|
701
|
+
|
|
702
|
+
const type = asTrimmedString(toolChoice.type).toLowerCase();
|
|
703
|
+
if (type === 'tool' || type === 'function' || type === 'custom' || type === 'custom_tool' || type === 'local_shell') {
|
|
704
|
+
if (isRecord(toolChoice.function) && asTrimmedString(toolChoice.function.name)) return cloneJsonValue(toolChoice);
|
|
705
|
+
const name = asTrimmedString(toolChoice.name) || asTrimmedString(toolChoice.server_label);
|
|
706
|
+
if (!name) return 'required';
|
|
707
|
+
return { type: 'function', function: { name } };
|
|
708
|
+
}
|
|
709
|
+
if (type === 'auto' || type === 'none' || type === 'required') return type;
|
|
710
|
+
return 'auto';
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function getChatToolChoiceName(toolChoice) {
|
|
714
|
+
if (!isRecord(toolChoice)) return '';
|
|
715
|
+
if (isRecord(toolChoice.function)) return asTrimmedString(toolChoice.function.name);
|
|
716
|
+
return '';
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function pruneInvalidChatToolChoice(chatBody) {
|
|
720
|
+
if (!isRecord(chatBody) || !Array.isArray(chatBody.tools)) return;
|
|
721
|
+
if (chatBody.tools.length === 0) {
|
|
722
|
+
delete chatBody.tools;
|
|
723
|
+
delete chatBody.tool_choice;
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
const chosenName = getChatToolChoiceName(chatBody.tool_choice);
|
|
727
|
+
if (!chosenName) return;
|
|
728
|
+
const toolNames = new Set(chatBody.tools
|
|
729
|
+
.map((tool) => isRecord(tool) && isRecord(tool.function) ? asTrimmedString(tool.function.name) : '')
|
|
730
|
+
.filter(Boolean));
|
|
731
|
+
if (!toolNames.has(chosenName)) {
|
|
732
|
+
delete chatBody.tool_choice;
|
|
733
|
+
}
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function normalizeResponsesToolsForResponsesApi(tools) {
|
|
737
|
+
if (!Array.isArray(tools)) return tools;
|
|
738
|
+
return tools
|
|
739
|
+
.map((tool) => {
|
|
740
|
+
const converted = normalizeFunctionToolForChat(tool);
|
|
741
|
+
if (!converted || !converted.function) return null;
|
|
742
|
+
const out = {
|
|
743
|
+
type: 'function',
|
|
744
|
+
name: converted.function.name
|
|
745
|
+
};
|
|
746
|
+
if (converted.function.description !== undefined) out.description = converted.function.description;
|
|
747
|
+
if (converted.function.parameters !== undefined) out.parameters = converted.function.parameters;
|
|
748
|
+
if (converted.function.strict !== undefined) out.strict = converted.function.strict;
|
|
749
|
+
return out;
|
|
750
|
+
})
|
|
751
|
+
.filter(Boolean);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function mergeLeadingSystemMessages(messages, leadingInstructions) {
|
|
755
|
+
const segments = [];
|
|
756
|
+
const seen = new Set();
|
|
757
|
+
const pushSegment = (text) => {
|
|
758
|
+
const trimmed = typeof text === 'string' ? text.trim() : '';
|
|
759
|
+
if (!trimmed || seen.has(trimmed)) return;
|
|
760
|
+
seen.add(trimmed);
|
|
761
|
+
segments.push(trimmed);
|
|
762
|
+
};
|
|
763
|
+
if (typeof leadingInstructions === 'string') {
|
|
764
|
+
pushSegment(leadingInstructions);
|
|
765
|
+
}
|
|
766
|
+
const rest = [];
|
|
767
|
+
for (const msg of messages) {
|
|
768
|
+
if (msg && msg.role === 'system') {
|
|
769
|
+
const content = msg.content;
|
|
770
|
+
if (typeof content === 'string') {
|
|
771
|
+
pushSegment(content);
|
|
772
|
+
} else if (Array.isArray(content)) {
|
|
773
|
+
for (const part of content) {
|
|
774
|
+
if (part && typeof part === 'object' && typeof part.text === 'string') {
|
|
775
|
+
pushSegment(part.text);
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
continue;
|
|
780
|
+
}
|
|
781
|
+
rest.push(msg);
|
|
782
|
+
}
|
|
783
|
+
const out = [];
|
|
784
|
+
if (segments.length) {
|
|
785
|
+
out.push({ role: 'system', content: segments.join('\n\n---\n\n') });
|
|
786
|
+
}
|
|
787
|
+
for (const msg of rest) out.push(msg);
|
|
788
|
+
return out;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function messageContentAsText(content) {
|
|
792
|
+
if (typeof content === 'string') return content;
|
|
793
|
+
if (!Array.isArray(content)) return '';
|
|
794
|
+
return content
|
|
795
|
+
.map((item) => {
|
|
796
|
+
if (typeof item === 'string') return item;
|
|
797
|
+
if (!isRecord(item)) return '';
|
|
798
|
+
if (typeof item.text === 'string') return item.text;
|
|
799
|
+
if (typeof item.content === 'string') return item.content;
|
|
800
|
+
return '';
|
|
801
|
+
})
|
|
802
|
+
.filter(Boolean)
|
|
803
|
+
.join('\n');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function hasRunningCodexExecSession(messages) {
|
|
807
|
+
if (!Array.isArray(messages)) return false;
|
|
808
|
+
return messages.some((message) => {
|
|
809
|
+
if (!isRecord(message) || message.role !== 'tool') return false;
|
|
810
|
+
return /Process running with session ID\s+\d+/i.test(messageContentAsText(message.content));
|
|
811
|
+
});
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function appendChatFallbackRuntimeInstructions(baseInstructions, rawMessages) {
|
|
815
|
+
const segments = [];
|
|
816
|
+
const base = typeof baseInstructions === 'string' ? baseInstructions.trim() : '';
|
|
817
|
+
if (base) segments.push(base);
|
|
818
|
+
if (hasRunningCodexExecSession(rawMessages)) {
|
|
819
|
+
segments.push('Codex tool output indicates a command is still running ("Process running with session ID ..."). You must call write_stdin with that numeric session_id and empty chars to poll/wait for completion before giving a final answer. Do not merely say that you are waiting.');
|
|
820
|
+
}
|
|
821
|
+
return segments.join('\n\n');
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
function convertResponsesRequestToChatCompletions(payload) {
|
|
825
|
+
const body = payload && typeof payload === 'object' ? payload : {};
|
|
826
|
+
const model = typeof body.model === 'string' ? body.model.trim() : '';
|
|
827
|
+
if (!model) {
|
|
828
|
+
return { error: 'responses 请求缺少 model' };
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
const rawMessages = normalizeResponsesInputToChatMessages(body.input);
|
|
832
|
+
const leadingInstructions = appendChatFallbackRuntimeInstructions(body.instructions, rawMessages);
|
|
833
|
+
// codex 同时下发 body.instructions(内置 prompt)与 input 内 developer/system 消息(AGENTS.md)。
|
|
834
|
+
// 合流为一条领头 system,避免某些上游"只认第一条 system"导致 AGENTS.md 失效。
|
|
835
|
+
const messages = mergeLeadingSystemMessages(rawMessages, leadingInstructions);
|
|
836
|
+
if (!messages.length) {
|
|
837
|
+
// codex sometimes sends empty input for probes; tolerate.
|
|
838
|
+
messages.push({ role: 'user', content: '' });
|
|
839
|
+
}
|
|
840
|
+
|
|
841
|
+
const maxOutputTokens = Number.parseInt(String(body.max_output_tokens), 10);
|
|
842
|
+
const stream = body.stream === true;
|
|
843
|
+
|
|
844
|
+
const chat = {
|
|
845
|
+
model,
|
|
846
|
+
messages,
|
|
847
|
+
stream: false,
|
|
848
|
+
temperature: Number.isFinite(body.temperature) ? Number(body.temperature) : undefined,
|
|
849
|
+
top_p: Number.isFinite(body.top_p) ? Number(body.top_p) : undefined,
|
|
850
|
+
max_tokens: Number.isFinite(maxOutputTokens) && maxOutputTokens > 0 ? maxOutputTokens : undefined
|
|
851
|
+
};
|
|
852
|
+
if (Array.isArray(body.stop) && body.stop.length) {
|
|
853
|
+
chat.stop = body.stop.filter((item) => typeof item === 'string' && item.trim());
|
|
854
|
+
}
|
|
855
|
+
if (Array.isArray(body.tools) && body.tools.length) {
|
|
856
|
+
chat.tools = normalizeResponsesToolsToChatTools(body.tools);
|
|
857
|
+
}
|
|
858
|
+
if (body.tool_choice !== undefined) {
|
|
859
|
+
chat.tool_choice = normalizeResponsesToolChoiceToChatToolChoice(body.tool_choice);
|
|
860
|
+
}
|
|
861
|
+
if (body.response_format !== undefined) {
|
|
862
|
+
chat.response_format = body.response_format;
|
|
863
|
+
}
|
|
864
|
+
if (body.metadata !== undefined) {
|
|
865
|
+
chat.metadata = body.metadata;
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
pruneInvalidChatToolChoice(chat);
|
|
869
|
+
|
|
870
|
+
// Remove undefined keys
|
|
871
|
+
Object.keys(chat).forEach((key) => chat[key] === undefined && delete chat[key]);
|
|
872
|
+
|
|
873
|
+
return { chat, streamRequested: stream, toolTypesByName: collectResponsesToolTypesByName(body.tools) };
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamPayload, options = {}) {
|
|
877
|
+
const responseId = `resp_${crypto.randomBytes(10).toString('hex')}`;
|
|
878
|
+
const usage = upstreamPayload && upstreamPayload.usage && typeof upstreamPayload.usage === 'object'
|
|
879
|
+
? upstreamPayload.usage
|
|
880
|
+
: null;
|
|
881
|
+
const createdAt = Math.floor(Date.now() / 1000);
|
|
882
|
+
const output = [];
|
|
883
|
+
const trimmedText = typeof text === 'string' ? text : '';
|
|
884
|
+
if (trimmedText) {
|
|
885
|
+
output.push({
|
|
886
|
+
id: `msg_${crypto.randomBytes(8).toString('hex')}`,
|
|
887
|
+
type: 'message',
|
|
888
|
+
role: 'assistant',
|
|
889
|
+
content: [{ type: 'output_text', text: trimmedText }]
|
|
890
|
+
});
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// Convert chat.completions tool_calls back into the original Responses item type.
|
|
894
|
+
// Treating every call as `function_call` makes Codex built-ins (custom/local_shell)
|
|
895
|
+
// degrade into ordinary chat text instead of executable agent steps.
|
|
896
|
+
if (Array.isArray(toolCalls)) {
|
|
897
|
+
for (const call of toolCalls) {
|
|
898
|
+
const item = buildResponsesToolCallItemFromChatToolCall(call, options.toolTypesByName || {});
|
|
899
|
+
if (item) output.push(item);
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
const payload = {
|
|
904
|
+
id: responseId,
|
|
905
|
+
object: 'response',
|
|
906
|
+
model,
|
|
907
|
+
created_at: createdAt,
|
|
908
|
+
status: 'completed',
|
|
909
|
+
output,
|
|
910
|
+
output_text: trimmedText
|
|
911
|
+
};
|
|
912
|
+
|
|
913
|
+
if (usage) {
|
|
914
|
+
// Map chat.completions usage -> responses usage shape when possible.
|
|
915
|
+
const promptTokens = Number.isFinite(usage.prompt_tokens) ? Number(usage.prompt_tokens) : null;
|
|
916
|
+
const completionTokens = Number.isFinite(usage.completion_tokens) ? Number(usage.completion_tokens) : null;
|
|
917
|
+
const totalTokens = Number.isFinite(usage.total_tokens) ? Number(usage.total_tokens) : null;
|
|
918
|
+
if (promptTokens !== null || completionTokens !== null || totalTokens !== null) {
|
|
919
|
+
payload.usage = {
|
|
920
|
+
input_tokens: promptTokens ?? undefined,
|
|
921
|
+
output_tokens: completionTokens ?? undefined,
|
|
922
|
+
total_tokens: totalTokens ?? undefined
|
|
923
|
+
};
|
|
924
|
+
Object.keys(payload.usage).forEach((key) => payload.usage[key] === undefined && delete payload.usage[key]);
|
|
925
|
+
} else {
|
|
926
|
+
payload.usage = usage;
|
|
927
|
+
}
|
|
928
|
+
}
|
|
929
|
+
|
|
930
|
+
return payload;
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
function ensureResponseMetadata(response) {
|
|
934
|
+
const payload = response && typeof response === 'object' ? response : {};
|
|
935
|
+
if (typeof payload.object !== 'string' || !payload.object.trim()) {
|
|
936
|
+
payload.object = 'response';
|
|
937
|
+
}
|
|
938
|
+
if (typeof payload.created_at !== 'number') {
|
|
939
|
+
payload.created_at = Math.floor(Date.now() / 1000);
|
|
940
|
+
}
|
|
941
|
+
if (typeof payload.status !== 'string' || !payload.status.trim()) {
|
|
942
|
+
payload.status = 'completed';
|
|
943
|
+
}
|
|
944
|
+
if (!Array.isArray(payload.output)) {
|
|
945
|
+
payload.output = [];
|
|
946
|
+
}
|
|
947
|
+
return payload;
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
function sendResponsesSse(res, responsePayload) {
|
|
951
|
+
const response = ensureResponseMetadata(responsePayload);
|
|
952
|
+
const responseId = typeof response.id === 'string' && response.id.trim()
|
|
953
|
+
? response.id.trim()
|
|
954
|
+
: `resp_${crypto.randomBytes(10).toString('hex')}`;
|
|
955
|
+
const model = typeof response.model === 'string' ? response.model : '';
|
|
956
|
+
|
|
957
|
+
let sequence = 0;
|
|
958
|
+
const nextSeq = () => {
|
|
959
|
+
sequence += 1;
|
|
960
|
+
return sequence;
|
|
961
|
+
};
|
|
962
|
+
|
|
963
|
+
writeSse(res, 'response.created', {
|
|
964
|
+
type: 'response.created',
|
|
965
|
+
response: {
|
|
966
|
+
id: responseId,
|
|
967
|
+
model,
|
|
968
|
+
created_at: response.created_at
|
|
969
|
+
}
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
const output = Array.isArray(response.output) ? response.output : [];
|
|
973
|
+
for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
|
|
974
|
+
const item = output[outputIndex];
|
|
975
|
+
if (!item || typeof item !== 'object') continue;
|
|
976
|
+
const itemType = typeof item.type === 'string' ? item.type : '';
|
|
977
|
+
const itemId = typeof item.id === 'string' && item.id.trim()
|
|
978
|
+
? item.id.trim()
|
|
979
|
+
: (typeof item.call_id === 'string' && item.call_id.trim() ? item.call_id.trim() : `item_${crypto.randomBytes(8).toString('hex')}`);
|
|
980
|
+
|
|
981
|
+
// Emit item added so Codex can anchor subsequent deltas by output_index/content_index/item_id.
|
|
982
|
+
writeSse(res, 'response.output_item.added', {
|
|
983
|
+
type: 'response.output_item.added',
|
|
984
|
+
output_index: outputIndex,
|
|
985
|
+
item: { ...item, id: itemId }
|
|
986
|
+
});
|
|
987
|
+
|
|
988
|
+
if (itemType === 'message') {
|
|
989
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
990
|
+
for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
|
|
991
|
+
const block = content[contentIndex];
|
|
992
|
+
if (!block || typeof block !== 'object') continue;
|
|
993
|
+
if (block.type !== 'output_text') continue;
|
|
994
|
+
const text = typeof block.text === 'string' ? block.text : '';
|
|
995
|
+
if (text) {
|
|
996
|
+
writeSse(res, 'response.output_text.delta', {
|
|
997
|
+
type: 'response.output_text.delta',
|
|
998
|
+
item_id: itemId,
|
|
999
|
+
output_index: outputIndex,
|
|
1000
|
+
content_index: contentIndex,
|
|
1001
|
+
delta: text,
|
|
1002
|
+
sequence_number: nextSeq()
|
|
1003
|
+
});
|
|
1004
|
+
}
|
|
1005
|
+
writeSse(res, 'response.output_text.done', {
|
|
1006
|
+
type: 'response.output_text.done',
|
|
1007
|
+
item_id: itemId,
|
|
1008
|
+
output_index: outputIndex,
|
|
1009
|
+
content_index: contentIndex,
|
|
1010
|
+
text,
|
|
1011
|
+
sequence_number: nextSeq()
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
// Emit item done for all item types (message/function_call/etc).
|
|
1017
|
+
writeSse(res, 'response.output_item.done', {
|
|
1018
|
+
type: 'response.output_item.done',
|
|
1019
|
+
output_index: outputIndex,
|
|
1020
|
+
item: { ...item, id: itemId },
|
|
1021
|
+
sequence_number: nextSeq()
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
writeSse(res, 'response.completed', { type: 'response.completed', response });
|
|
1026
|
+
writeSse(res, 'done', '[DONE]');
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function extractResponsesOutputText(payload) {
|
|
1030
|
+
if (!payload || typeof payload !== 'object') return '';
|
|
1031
|
+
const output = Array.isArray(payload.output) ? payload.output : [];
|
|
1032
|
+
for (const item of output) {
|
|
1033
|
+
if (!item || typeof item !== 'object') continue;
|
|
1034
|
+
if (item.type !== 'message') continue;
|
|
1035
|
+
const content = Array.isArray(item.content) ? item.content : [];
|
|
1036
|
+
for (const block of content) {
|
|
1037
|
+
if (!block || typeof block !== 'object') continue;
|
|
1038
|
+
if (block.type !== 'output_text') continue;
|
|
1039
|
+
if (typeof block.text === 'string') return block.text;
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
if (typeof payload.output_text === 'string') return payload.output_text;
|
|
1043
|
+
return '';
|
|
1044
|
+
}
|
|
1045
|
+
|
|
1046
|
+
function toUpstreamNonStreamingResponsesPayload(payload) {
|
|
1047
|
+
const body = payload && typeof payload === 'object' ? payload : {};
|
|
1048
|
+
const normalized = { ...body, stream: false };
|
|
1049
|
+
if (Array.isArray(body.tools)) {
|
|
1050
|
+
normalized.tools = normalizeResponsesToolsForResponsesApi(body.tools);
|
|
1051
|
+
}
|
|
1052
|
+
return normalized;
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
function shouldFallbackFromUpstreamResponses(status, bodyText) {
|
|
1056
|
+
if (!Number.isFinite(status)) return false;
|
|
1057
|
+
// Common "unsupported" status codes for a route.
|
|
1058
|
+
if (status === 404 || status === 405 || status === 501) return true;
|
|
1059
|
+
|
|
1060
|
+
// Some OpenAI-compatible gateways respond with 500 + "not implemented" (e.g. convert_request_failed)
|
|
1061
|
+
// instead of 404/405 for unsupported endpoints. In that case we can safely fallback to chat/completions.
|
|
1062
|
+
const text = String(bodyText || '');
|
|
1063
|
+
if (!text) return false;
|
|
1064
|
+
if (/not implemented/i.test(text)) return true;
|
|
1065
|
+
if (/convert_request_failed/i.test(text)) return true;
|
|
1066
|
+
if (/unknown (endpoint|route)/i.test(text)) return true;
|
|
1067
|
+
if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
|
|
1068
|
+
if (/does not support.*responses/i.test(text)) return true;
|
|
1069
|
+
if (/name['"`]?\s+is a required property/i.test(text) && /tools/i.test(text) && /function/i.test(text)) return true;
|
|
1070
|
+
|
|
1071
|
+
// Best-effort parse for structured error codes.
|
|
1072
|
+
try {
|
|
1073
|
+
const parsed = JSON.parse(text);
|
|
1074
|
+
const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
|
|
1075
|
+
const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
|
|
1076
|
+
if (code === 'convert_request_failed') return true;
|
|
1077
|
+
if (/not implemented/i.test(msg)) return true;
|
|
1078
|
+
if (/unknown (endpoint|route)/i.test(msg)) return true;
|
|
1079
|
+
if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
|
|
1080
|
+
if (/does not support.*responses/i.test(msg)) return true;
|
|
1081
|
+
if (/name['"`]?\s+is a required property/i.test(msg) && /tools/i.test(msg) && /function/i.test(msg)) return true;
|
|
1082
|
+
} catch (_) {}
|
|
1083
|
+
|
|
1084
|
+
return false;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
// 仅识别"端点级别不支持"——可缓存,与 per-request 的 tool 格式错误区分。
|
|
1088
|
+
function isResponsesEndpointUnsupported(status, bodyText) {
|
|
1089
|
+
if (!Number.isFinite(status)) return false;
|
|
1090
|
+
if (status === 404 || status === 405 || status === 501) return true;
|
|
1091
|
+
const text = String(bodyText || '');
|
|
1092
|
+
if (!text) return false;
|
|
1093
|
+
if (/not implemented/i.test(text)) return true;
|
|
1094
|
+
if (/convert_request_failed/i.test(text)) return true;
|
|
1095
|
+
if (/unknown (endpoint|route)/i.test(text)) return true;
|
|
1096
|
+
if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
|
|
1097
|
+
if (/does not support.*responses/i.test(text)) return true;
|
|
1098
|
+
try {
|
|
1099
|
+
const parsed = JSON.parse(text);
|
|
1100
|
+
const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
|
|
1101
|
+
const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
|
|
1102
|
+
if (code === 'convert_request_failed') return true;
|
|
1103
|
+
if (/not implemented/i.test(msg)) return true;
|
|
1104
|
+
if (/unknown (endpoint|route)/i.test(msg)) return true;
|
|
1105
|
+
if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
|
|
1106
|
+
if (/does not support.*responses/i.test(msg)) return true;
|
|
1107
|
+
} catch (_) {}
|
|
1108
|
+
return false;
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
function isLoopbackAddress(address) {
|
|
1112
|
+
if (!address) return false;
|
|
1113
|
+
const value = String(address);
|
|
1114
|
+
return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
function isTransientNetworkError(error) {
|
|
1118
|
+
const text = String(error || '').trim();
|
|
1119
|
+
if (!text) return false;
|
|
1120
|
+
if (/socket hang up/i.test(text)) return true;
|
|
1121
|
+
if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
|
|
1122
|
+
if (/EAI_AGAIN/i.test(text)) return true;
|
|
1123
|
+
if (/UND_ERR_SOCKET/i.test(text)) return true;
|
|
1124
|
+
if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
|
|
1125
|
+
return false;
|
|
1126
|
+
}
|
|
1127
|
+
|
|
1128
|
+
const TRANSIENT_RETRY_DELAYS_MS = [200, 600];
|
|
1129
|
+
|
|
1130
|
+
async function retryTransientRequest(executor) {
|
|
1131
|
+
let lastResult = null;
|
|
1132
|
+
for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) {
|
|
1133
|
+
if (attempt > 0) {
|
|
1134
|
+
const delay = TRANSIENT_RETRY_DELAYS_MS[attempt - 1];
|
|
1135
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1136
|
+
await new Promise((r) => {
|
|
1137
|
+
const t = setTimeout(r, delay);
|
|
1138
|
+
if (typeof t.unref === 'function') t.unref();
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
// eslint-disable-next-line no-await-in-loop
|
|
1142
|
+
const result = await executor(attempt);
|
|
1143
|
+
lastResult = result;
|
|
1144
|
+
if (!result) return result;
|
|
1145
|
+
if (result.ok) return result;
|
|
1146
|
+
if (result.retry) return result;
|
|
1147
|
+
if (result.status && result.status > 0) return result;
|
|
1148
|
+
if (!isTransientNetworkError(result.error)) return result;
|
|
1149
|
+
}
|
|
1150
|
+
return lastResult;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1153
|
+
function writeSse(res, eventName, dataObj) {
|
|
1154
|
+
if (!res || res.writableEnded || res.destroyed) return;
|
|
1155
|
+
if (eventName) {
|
|
1156
|
+
res.write(`event: ${eventName}\n`);
|
|
1157
|
+
}
|
|
1158
|
+
if (dataObj === '[DONE]') {
|
|
1159
|
+
res.write('data: [DONE]\n\n');
|
|
1160
|
+
return;
|
|
1161
|
+
}
|
|
1162
|
+
res.write(`data: ${JSON.stringify(dataObj)}\n\n`);
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
function appendChatStreamToolCall(target, toolCall) {
|
|
1166
|
+
if (!toolCall || typeof toolCall !== 'object') return;
|
|
1167
|
+
const index = Number.isFinite(toolCall.index) ? toolCall.index : target.length;
|
|
1168
|
+
if (!target[index]) {
|
|
1169
|
+
target[index] = {
|
|
1170
|
+
id: '',
|
|
1171
|
+
type: 'function',
|
|
1172
|
+
function: { name: '', arguments: '' }
|
|
1173
|
+
};
|
|
1174
|
+
}
|
|
1175
|
+
const current = target[index];
|
|
1176
|
+
if (typeof toolCall.id === 'string' && toolCall.id) current.id = toolCall.id;
|
|
1177
|
+
if (typeof toolCall.type === 'string' && toolCall.type) current.type = toolCall.type;
|
|
1178
|
+
const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : null;
|
|
1179
|
+
if (fn) {
|
|
1180
|
+
if (typeof fn.name === 'string' && fn.name) current.function.name = fn.name;
|
|
1181
|
+
if (typeof fn.arguments === 'string') current.function.arguments += fn.arguments;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function writeChatCompletionChunkAsResponsesSse(state, chunk) {
|
|
1186
|
+
if (!chunk || typeof chunk !== 'object') return;
|
|
1187
|
+
if (typeof chunk.model === 'string' && chunk.model) {
|
|
1188
|
+
state.model = chunk.model;
|
|
1189
|
+
}
|
|
1190
|
+
const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
|
|
1191
|
+
for (const choice of choices) {
|
|
1192
|
+
const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
|
|
1193
|
+
if (!delta) continue;
|
|
1194
|
+
|
|
1195
|
+
const segments = [];
|
|
1196
|
+
// DeepSeek-style OpenAI-compatible streams may emit private reasoning in
|
|
1197
|
+
// `reasoning_content` before the final answer. Responses `output_text`
|
|
1198
|
+
// must stay user-visible answer text only; forwarding reasoning here
|
|
1199
|
+
// pollutes Codex output and breaks exact-answer prompts.
|
|
1200
|
+
if (typeof delta.content === 'string' && delta.content) {
|
|
1201
|
+
segments.push(delta.content);
|
|
1202
|
+
}
|
|
1203
|
+
for (const seg of segments) {
|
|
1204
|
+
if (!state.messageItem) {
|
|
1205
|
+
state.messageItem = {
|
|
1206
|
+
id: `msg_${crypto.randomBytes(8).toString('hex')}`,
|
|
1207
|
+
type: 'message',
|
|
1208
|
+
role: 'assistant',
|
|
1209
|
+
content: [{ type: 'output_text', text: '' }]
|
|
1210
|
+
};
|
|
1211
|
+
state.output.push(state.messageItem);
|
|
1212
|
+
writeSse(state.res, 'response.output_item.added', {
|
|
1213
|
+
type: 'response.output_item.added',
|
|
1214
|
+
output_index: state.output.length - 1,
|
|
1215
|
+
item: state.messageItem
|
|
1216
|
+
});
|
|
1217
|
+
}
|
|
1218
|
+
state.messageText += seg;
|
|
1219
|
+
state.messageItem.content[0].text = state.messageText;
|
|
1220
|
+
writeSse(state.res, 'response.output_text.delta', {
|
|
1221
|
+
type: 'response.output_text.delta',
|
|
1222
|
+
item_id: state.messageItem.id,
|
|
1223
|
+
output_index: state.output.length - 1,
|
|
1224
|
+
content_index: 0,
|
|
1225
|
+
delta: seg,
|
|
1226
|
+
sequence_number: state.nextSeq()
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
|
|
1230
|
+
if (Array.isArray(delta.tool_calls)) {
|
|
1231
|
+
for (const toolCall of delta.tool_calls) {
|
|
1232
|
+
appendChatStreamToolCall(state.toolCalls, toolCall);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
|
|
1237
|
+
state.sawFinishReason = true;
|
|
1238
|
+
}
|
|
1239
|
+
}
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
function finishChatStreamResponsesSse(state) {
|
|
1243
|
+
if (!state || state.finished) return;
|
|
1244
|
+
state.finished = true;
|
|
1245
|
+
|
|
1246
|
+
if (state.messageItem) {
|
|
1247
|
+
const outputIndex = state.output.indexOf(state.messageItem);
|
|
1248
|
+
writeSse(state.res, 'response.output_text.done', {
|
|
1249
|
+
type: 'response.output_text.done',
|
|
1250
|
+
item_id: state.messageItem.id,
|
|
1251
|
+
output_index: outputIndex,
|
|
1252
|
+
content_index: 0,
|
|
1253
|
+
text: state.messageText,
|
|
1254
|
+
sequence_number: state.nextSeq()
|
|
1255
|
+
});
|
|
1256
|
+
writeSse(state.res, 'response.output_item.done', {
|
|
1257
|
+
type: 'response.output_item.done',
|
|
1258
|
+
output_index: outputIndex,
|
|
1259
|
+
item: state.messageItem,
|
|
1260
|
+
sequence_number: state.nextSeq()
|
|
1261
|
+
});
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
for (const toolCall of state.toolCalls) {
|
|
1265
|
+
if (!toolCall) continue;
|
|
1266
|
+
const item = buildResponsesToolCallItemFromChatToolCall(toolCall, state.toolTypesByName || {});
|
|
1267
|
+
if (!item) continue;
|
|
1268
|
+
const outputIndex = state.output.length;
|
|
1269
|
+
state.output.push(item);
|
|
1270
|
+
writeSse(state.res, 'response.output_item.added', {
|
|
1271
|
+
type: 'response.output_item.added',
|
|
1272
|
+
output_index: outputIndex,
|
|
1273
|
+
item
|
|
1274
|
+
});
|
|
1275
|
+
writeSse(state.res, 'response.output_item.done', {
|
|
1276
|
+
type: 'response.output_item.done',
|
|
1277
|
+
output_index: outputIndex,
|
|
1278
|
+
item,
|
|
1279
|
+
sequence_number: state.nextSeq()
|
|
1280
|
+
});
|
|
1281
|
+
}
|
|
1282
|
+
|
|
1283
|
+
const response = ensureResponseMetadata({
|
|
1284
|
+
id: state.responseId,
|
|
1285
|
+
model: state.model,
|
|
1286
|
+
created_at: state.createdAt,
|
|
1287
|
+
status: 'completed',
|
|
1288
|
+
output: state.output,
|
|
1289
|
+
output_text: state.messageText
|
|
1290
|
+
});
|
|
1291
|
+
writeSse(state.res, 'response.completed', { type: 'response.completed', response });
|
|
1292
|
+
writeSse(state.res, 'done', '[DONE]');
|
|
1293
|
+
if (!state.res.writableEnded && !state.res.destroyed) {
|
|
1294
|
+
state.res.end();
|
|
1295
|
+
}
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function failChatStreamResponsesSse(state, errorMessage) {
|
|
1299
|
+
if (!state || state.finished) return;
|
|
1300
|
+
state.finished = true;
|
|
1301
|
+
writeSse(state.res, 'response.failed', {
|
|
1302
|
+
type: 'response.failed',
|
|
1303
|
+
response: ensureResponseMetadata({
|
|
1304
|
+
id: state.responseId,
|
|
1305
|
+
model: state.model,
|
|
1306
|
+
created_at: state.createdAt,
|
|
1307
|
+
status: 'failed',
|
|
1308
|
+
output: state.output,
|
|
1309
|
+
output_text: state.messageText
|
|
1310
|
+
}),
|
|
1311
|
+
error: String(errorMessage || 'upstream stream failed')
|
|
1312
|
+
});
|
|
1313
|
+
writeSse(state.res, 'done', '[DONE]');
|
|
1314
|
+
if (!state.res.writableEnded && !state.res.destroyed) {
|
|
1315
|
+
state.res.end();
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
|
|
1319
|
+
function formatUpstreamStreamError(errorValue) {
|
|
1320
|
+
if (!errorValue) return 'upstream stream failed';
|
|
1321
|
+
if (typeof errorValue === 'string') return errorValue;
|
|
1322
|
+
if (typeof errorValue === 'object') {
|
|
1323
|
+
if (typeof errorValue.message === 'string' && errorValue.message) return errorValue.message;
|
|
1324
|
+
try { return JSON.stringify(errorValue); } catch (_) {}
|
|
1325
|
+
}
|
|
1326
|
+
return String(errorValue || 'upstream stream failed');
|
|
1327
|
+
}
|
|
1328
|
+
|
|
1329
|
+
function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
|
|
1330
|
+
const parsed = new URL(targetUrl);
|
|
1331
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
1332
|
+
const bodyText = options.body ? JSON.stringify(options.body) : '';
|
|
1333
|
+
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
|
|
1334
|
+
? Math.floor(options.maxBytes)
|
|
1335
|
+
: 0;
|
|
1336
|
+
const headers = {
|
|
1337
|
+
'Accept': 'text/event-stream',
|
|
1338
|
+
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
1339
|
+
...(options.headers || {})
|
|
1340
|
+
};
|
|
1341
|
+
if (options.body) {
|
|
1342
|
+
headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
|
|
1343
|
+
}
|
|
1344
|
+
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
1345
|
+
? Math.max(1000, Number(options.timeoutMs))
|
|
1346
|
+
: STREAM_IDLE_TIMEOUT_MS;
|
|
1347
|
+
const res = options.res;
|
|
1348
|
+
const fallbackModel = typeof options.model === 'string' ? options.model : '';
|
|
1349
|
+
|
|
1350
|
+
return new Promise((resolve) => {
|
|
1351
|
+
let settled = false;
|
|
1352
|
+
let upstreamReq = null;
|
|
1353
|
+
const finish = (value) => {
|
|
1354
|
+
if (settled) return;
|
|
1355
|
+
settled = true;
|
|
1356
|
+
resolve(value);
|
|
1357
|
+
};
|
|
1358
|
+
const abortUpstream = () => {
|
|
1359
|
+
if (upstreamReq) {
|
|
1360
|
+
try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {}
|
|
1361
|
+
}
|
|
1362
|
+
};
|
|
1363
|
+
if (res && typeof res.once === 'function') {
|
|
1364
|
+
res.once('close', abortUpstream);
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
upstreamReq = transport.request({
|
|
1368
|
+
protocol: parsed.protocol,
|
|
1369
|
+
hostname: parsed.hostname,
|
|
1370
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
1371
|
+
method: options.method || 'POST',
|
|
1372
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
1373
|
+
headers,
|
|
1374
|
+
agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
|
|
1375
|
+
}, (upstreamRes) => {
|
|
1376
|
+
const status = upstreamRes.statusCode || 0;
|
|
1377
|
+
const chunks = [];
|
|
1378
|
+
let size = 0;
|
|
1379
|
+
const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
|
|
1380
|
+
|
|
1381
|
+
const collectChunk = (chunk) => {
|
|
1382
|
+
if (!chunk) return true;
|
|
1383
|
+
if (maxBytes > 0) {
|
|
1384
|
+
size += chunk.length;
|
|
1385
|
+
if (size > maxBytes) {
|
|
1386
|
+
chunks.length = 0;
|
|
1387
|
+
try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
|
|
1388
|
+
try { upstreamReq.destroy(new Error('response too large')); } catch (_) {}
|
|
1389
|
+
finish({ ok: false, status, error: 'response too large' });
|
|
1390
|
+
return false;
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
chunks.push(chunk);
|
|
1394
|
+
return true;
|
|
1395
|
+
};
|
|
1396
|
+
|
|
1397
|
+
if (status >= 400) {
|
|
1398
|
+
upstreamRes.on('data', collectChunk);
|
|
1399
|
+
upstreamRes.on('end', () => finish({
|
|
1400
|
+
ok: false,
|
|
1401
|
+
status,
|
|
1402
|
+
bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
|
|
1403
|
+
}));
|
|
1404
|
+
return;
|
|
1405
|
+
}
|
|
1406
|
+
|
|
1407
|
+
if (!res.headersSent) {
|
|
1408
|
+
res.writeHead(200, {
|
|
1409
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
1410
|
+
'Cache-Control': 'no-cache',
|
|
1411
|
+
'Connection': 'keep-alive',
|
|
1412
|
+
'X-Accel-Buffering': 'no'
|
|
1413
|
+
});
|
|
1414
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
1415
|
+
}
|
|
1416
|
+
|
|
1417
|
+
if (!/text\/event-stream/i.test(contentType)) {
|
|
1418
|
+
upstreamRes.on('data', collectChunk);
|
|
1419
|
+
upstreamRes.on('end', () => {
|
|
1420
|
+
const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
|
|
1421
|
+
const parsedJson = parseJsonOrError(text);
|
|
1422
|
+
if (parsedJson.error) {
|
|
1423
|
+
writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
|
|
1424
|
+
writeSse(res, 'done', '[DONE]');
|
|
1425
|
+
if (!res.writableEnded && !res.destroyed) res.end();
|
|
1426
|
+
finish({ ok: true });
|
|
1427
|
+
return;
|
|
1428
|
+
}
|
|
1429
|
+
const extracted = extractChatCompletionResult(parsedJson.value);
|
|
1430
|
+
sendResponsesSse(res, buildResponsesPayloadFromChatResult(fallbackModel, extracted.text, extracted.toolCalls, parsedJson.value, {
|
|
1431
|
+
toolTypesByName: options.toolTypesByName || {}
|
|
1432
|
+
}));
|
|
1433
|
+
if (!res.writableEnded && !res.destroyed) res.end();
|
|
1434
|
+
finish({ ok: true });
|
|
1435
|
+
});
|
|
1436
|
+
return;
|
|
1437
|
+
}
|
|
1438
|
+
|
|
1439
|
+
let sequence = 0;
|
|
1440
|
+
const state = {
|
|
1441
|
+
res,
|
|
1442
|
+
responseId: `resp_${crypto.randomBytes(10).toString('hex')}`,
|
|
1443
|
+
model: fallbackModel,
|
|
1444
|
+
createdAt: Math.floor(Date.now() / 1000),
|
|
1445
|
+
output: [],
|
|
1446
|
+
messageItem: null,
|
|
1447
|
+
messageText: '',
|
|
1448
|
+
toolCalls: [],
|
|
1449
|
+
toolTypesByName: options.toolTypesByName || {},
|
|
1450
|
+
finished: false,
|
|
1451
|
+
sawDone: false,
|
|
1452
|
+
sawFinishReason: false,
|
|
1453
|
+
nextSeq: () => {
|
|
1454
|
+
sequence += 1;
|
|
1455
|
+
return sequence;
|
|
1456
|
+
}
|
|
1457
|
+
};
|
|
1458
|
+
writeSse(res, 'response.created', {
|
|
1459
|
+
type: 'response.created',
|
|
1460
|
+
response: {
|
|
1461
|
+
id: state.responseId,
|
|
1462
|
+
model: state.model,
|
|
1463
|
+
created_at: state.createdAt
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
let buffer = '';
|
|
1468
|
+
const utf8Decoder = new StringDecoder('utf8');
|
|
1469
|
+
const handleEventBlock = (block) => {
|
|
1470
|
+
const dataLines = String(block || '')
|
|
1471
|
+
.split(/\r?\n/)
|
|
1472
|
+
.filter((line) => line.startsWith('data:'))
|
|
1473
|
+
.map((line) => line.slice(5).trimStart());
|
|
1474
|
+
if (dataLines.length === 0) return;
|
|
1475
|
+
const data = dataLines.join('\n').trim();
|
|
1476
|
+
if (!data) return;
|
|
1477
|
+
if (data === '[DONE]') {
|
|
1478
|
+
state.sawDone = true;
|
|
1479
|
+
finishChatStreamResponsesSse(state);
|
|
1480
|
+
finish({ ok: true });
|
|
1481
|
+
return;
|
|
1482
|
+
}
|
|
1483
|
+
const parsedChunk = parseJsonOrError(data);
|
|
1484
|
+
if (!parsedChunk.error) {
|
|
1485
|
+
if (parsedChunk.value && typeof parsedChunk.value === 'object' && parsedChunk.value.error) {
|
|
1486
|
+
failChatStreamResponsesSse(state, formatUpstreamStreamError(parsedChunk.value.error));
|
|
1487
|
+
finish({ ok: true });
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
writeChatCompletionChunkAsResponsesSse(state, parsedChunk.value);
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
|
|
1494
|
+
upstreamRes.on('data', (chunk) => {
|
|
1495
|
+
if (!chunk) return;
|
|
1496
|
+
buffer += utf8Decoder.write(chunk);
|
|
1497
|
+
let boundary = buffer.search(/\r?\n\r?\n/);
|
|
1498
|
+
while (boundary >= 0) {
|
|
1499
|
+
const block = buffer.slice(0, boundary);
|
|
1500
|
+
const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
|
|
1501
|
+
buffer = buffer.slice(boundary + (match ? match[0].length : 2));
|
|
1502
|
+
handleEventBlock(block);
|
|
1503
|
+
boundary = buffer.search(/\r?\n\r?\n/);
|
|
1504
|
+
}
|
|
1505
|
+
});
|
|
1506
|
+
upstreamRes.on('end', () => {
|
|
1507
|
+
buffer += utf8Decoder.end();
|
|
1508
|
+
if (buffer.trim()) handleEventBlock(buffer);
|
|
1509
|
+
if (!state.finished && !state.sawDone && !state.sawFinishReason) {
|
|
1510
|
+
failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]');
|
|
1511
|
+
finish({ ok: true });
|
|
1512
|
+
return;
|
|
1513
|
+
}
|
|
1514
|
+
finishChatStreamResponsesSse(state);
|
|
1515
|
+
finish({ ok: true });
|
|
1516
|
+
});
|
|
1517
|
+
upstreamRes.on('aborted', () => {
|
|
1518
|
+
failChatStreamResponsesSse(state, 'upstream stream aborted');
|
|
1519
|
+
finish({ ok: true });
|
|
1520
|
+
});
|
|
1521
|
+
upstreamRes.on('error', (err) => {
|
|
1522
|
+
failChatStreamResponsesSse(state, err && err.message ? err.message : 'upstream stream failed');
|
|
1523
|
+
finish({ ok: true });
|
|
1524
|
+
});
|
|
1525
|
+
});
|
|
1526
|
+
upstreamReq.setTimeout(timeoutMs, () => {
|
|
1527
|
+
try { upstreamReq.destroy(new Error('timeout')); } catch (_) {}
|
|
1528
|
+
finish({ ok: false, error: 'timeout' });
|
|
1529
|
+
});
|
|
1530
|
+
upstreamReq.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
|
|
1531
|
+
if (bodyText) upstreamReq.write(bodyText);
|
|
1532
|
+
upstreamReq.end();
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
async function proxyRequestJson(targetUrl, options = {}) {
|
|
1537
|
+
const parsed = new URL(targetUrl);
|
|
1538
|
+
const transport = parsed.protocol === 'https:' ? https : http;
|
|
1539
|
+
const bodyText = options.body ? JSON.stringify(options.body) : '';
|
|
1540
|
+
const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
|
|
1541
|
+
? Math.floor(options.maxBytes)
|
|
1542
|
+
: 0;
|
|
1543
|
+
const headers = {
|
|
1544
|
+
'Accept': 'application/json',
|
|
1545
|
+
...(options.body ? { 'Content-Type': 'application/json' } : {}),
|
|
1546
|
+
...(options.headers || {})
|
|
1547
|
+
};
|
|
1548
|
+
if (options.body) {
|
|
1549
|
+
headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
const timeoutMs = Number.isFinite(options.timeoutMs)
|
|
1553
|
+
? Math.max(1000, Number(options.timeoutMs))
|
|
1554
|
+
: REQUEST_TIMEOUT_MS;
|
|
1555
|
+
return new Promise((resolve) => {
|
|
1556
|
+
let settled = false;
|
|
1557
|
+
const finish = (value) => {
|
|
1558
|
+
if (settled) return;
|
|
1559
|
+
settled = true;
|
|
1560
|
+
resolve(value);
|
|
1561
|
+
};
|
|
1562
|
+
const req = transport.request({
|
|
1563
|
+
protocol: parsed.protocol,
|
|
1564
|
+
hostname: parsed.hostname,
|
|
1565
|
+
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
|
1566
|
+
method: options.method || 'GET',
|
|
1567
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
1568
|
+
headers,
|
|
1569
|
+
agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
|
|
1570
|
+
}, (upstreamRes) => {
|
|
1571
|
+
const chunks = [];
|
|
1572
|
+
let size = 0;
|
|
1573
|
+
upstreamRes.on('data', (chunk) => {
|
|
1574
|
+
if (!chunk) return;
|
|
1575
|
+
if (maxBytes > 0) {
|
|
1576
|
+
size += chunk.length;
|
|
1577
|
+
if (size > maxBytes) {
|
|
1578
|
+
chunks.length = 0;
|
|
1579
|
+
try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
|
|
1580
|
+
try { req.destroy(new Error('response too large')); } catch (_) {}
|
|
1581
|
+
finish({ ok: false, error: 'response too large' });
|
|
1582
|
+
return;
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
chunks.push(chunk);
|
|
1586
|
+
});
|
|
1587
|
+
upstreamRes.on('end', () => {
|
|
1588
|
+
const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
|
|
1589
|
+
finish({
|
|
1590
|
+
ok: true,
|
|
1591
|
+
status: upstreamRes.statusCode || 0,
|
|
1592
|
+
headers: upstreamRes.headers || {},
|
|
1593
|
+
bodyText: text
|
|
1594
|
+
});
|
|
1595
|
+
});
|
|
1596
|
+
});
|
|
1597
|
+
req.setTimeout(timeoutMs, () => {
|
|
1598
|
+
try { req.destroy(new Error('timeout')); } catch (_) {}
|
|
1599
|
+
finish({ ok: false, error: 'timeout' });
|
|
1600
|
+
});
|
|
1601
|
+
req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
|
|
1602
|
+
if (bodyText) {
|
|
1603
|
+
req.write(bodyText);
|
|
1604
|
+
}
|
|
1605
|
+
req.end();
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
|
|
1609
|
+
function createOpenaiBridgeHttpHandler(options = {}) {
|
|
1610
|
+
const settingsFile = options.settingsFile;
|
|
1611
|
+
const expectedTokenRaw = typeof options.expectedToken === 'string' ? options.expectedToken.trim() : '';
|
|
1612
|
+
const expectedToken = Object.prototype.hasOwnProperty.call(options, 'expectedToken')
|
|
1613
|
+
? expectedTokenRaw
|
|
1614
|
+
: (expectedTokenRaw || DEFAULT_BRIDGE_TOKEN);
|
|
1615
|
+
const maxBodySize = Number.isFinite(options.maxBodySize) ? options.maxBodySize : 0;
|
|
1616
|
+
const httpAgent = options.httpAgent;
|
|
1617
|
+
const httpsAgent = options.httpsAgent;
|
|
1618
|
+
const maxUpstreamBytes = Number.isFinite(options.maxUpstreamBytes) && options.maxUpstreamBytes > 0
|
|
1619
|
+
? Math.floor(options.maxUpstreamBytes)
|
|
1620
|
+
: Math.max(16 * 1024 * 1024, maxBodySize > 0 ? maxBodySize * 4 : 0);
|
|
1621
|
+
|
|
1622
|
+
if (!settingsFile) {
|
|
1623
|
+
throw new Error('createOpenaiBridgeHttpHandler 缺少 settingsFile');
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
// 端点不支持的缓存(per-baseUrl, TTL 30 分钟):避免每次非流式请求重复探测 /v1/responses。
|
|
1627
|
+
const unsupportedResponses = new Map();
|
|
1628
|
+
const isResponsesKnownUnsupported = (baseUrl) => {
|
|
1629
|
+
if (!baseUrl) return false;
|
|
1630
|
+
const entry = unsupportedResponses.get(baseUrl);
|
|
1631
|
+
if (!entry) return false;
|
|
1632
|
+
if (entry.expiresAt <= Date.now()) {
|
|
1633
|
+
unsupportedResponses.delete(baseUrl);
|
|
1634
|
+
return false;
|
|
1635
|
+
}
|
|
1636
|
+
return true;
|
|
1637
|
+
};
|
|
1638
|
+
const markResponsesUnsupported = (baseUrl) => {
|
|
1639
|
+
if (!baseUrl) return;
|
|
1640
|
+
unsupportedResponses.set(baseUrl, { expiresAt: Date.now() + RESPONSES_UNSUPPORTED_TTL_MS });
|
|
1641
|
+
};
|
|
1642
|
+
const clearResponsesUnsupported = (baseUrl) => {
|
|
1643
|
+
if (!baseUrl) return;
|
|
1644
|
+
unsupportedResponses.delete(baseUrl);
|
|
1645
|
+
};
|
|
1646
|
+
|
|
1647
|
+
const matchPath = (requestPath) => {
|
|
1648
|
+
const normalized = String(requestPath || '');
|
|
1649
|
+
const prefix = '/bridge/openai/';
|
|
1650
|
+
if (!normalized.startsWith(prefix)) return null;
|
|
1651
|
+
const rest = normalized.slice(prefix.length);
|
|
1652
|
+
const [provider, ...tail] = rest.split('/').filter((part) => part.length > 0);
|
|
1653
|
+
if (!provider) return null;
|
|
1654
|
+
const tailPath = '/' + tail.join('/');
|
|
1655
|
+
if (!tailPath.startsWith('/v1')) return null;
|
|
1656
|
+
const suffix = tailPath === '/v1' ? '' : tailPath.replace(/^\/v1\/?/, '');
|
|
1657
|
+
return { provider, suffix };
|
|
1658
|
+
};
|
|
1659
|
+
|
|
1660
|
+
const handler = (req, res) => {
|
|
1661
|
+
let parsedUrl;
|
|
1662
|
+
try {
|
|
1663
|
+
parsedUrl = new URL(req.url || '/', 'http://localhost');
|
|
1664
|
+
} catch (_) {
|
|
1665
|
+
return false;
|
|
1666
|
+
}
|
|
1667
|
+
const match = matchPath(parsedUrl.pathname || '/');
|
|
1668
|
+
if (!match) return false;
|
|
1669
|
+
|
|
1670
|
+
void (async () => {
|
|
1671
|
+
try {
|
|
1672
|
+
const token = extractAuthorizationToken(req);
|
|
1673
|
+
// 兼容:某些客户端在自定义 base_url 时可能不带 Authorization。
|
|
1674
|
+
// 为避免在 LAN 暴露无鉴权的代理,这里仅允许 loopback 连接缺省 token。
|
|
1675
|
+
const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
|
|
1676
|
+
const isLoopback = isLoopbackAddress(remoteAddr);
|
|
1677
|
+
if (!isLoopback && !expectedToken) {
|
|
1678
|
+
res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1679
|
+
res.end(JSON.stringify({ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)' }));
|
|
1680
|
+
return;
|
|
1681
|
+
}
|
|
1682
|
+
if (!token && !isLoopback) {
|
|
1683
|
+
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1684
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1685
|
+
return;
|
|
1686
|
+
}
|
|
1687
|
+
// loopback 上的本地代理:允许客户端携带任意 Authorization(例如 Codex 会附带 provider apiKey)。
|
|
1688
|
+
// 非 loopback 时仍强制校验 expectedToken,避免局域网被未授权调用。
|
|
1689
|
+
if (!isLoopback && token && token !== expectedToken) {
|
|
1690
|
+
res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1691
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
|
|
1695
|
+
const upstream = resolveOpenaiBridgeUpstream(settingsFile, match.provider);
|
|
1696
|
+
if (upstream.error) {
|
|
1697
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1698
|
+
res.end(JSON.stringify({ error: upstream.error }));
|
|
1699
|
+
return;
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
const suffix = match.suffix || '';
|
|
1703
|
+
const normalizedSuffix = suffix.replace(/^\/+/, '');
|
|
1704
|
+
|
|
1705
|
+
const authHeader = upstream.apiKey
|
|
1706
|
+
? (/^bearer\s+/i.test(upstream.apiKey) ? upstream.apiKey : `Bearer ${upstream.apiKey}`)
|
|
1707
|
+
: '';
|
|
1708
|
+
const upstreamHeaders = upstream && upstream.headers && typeof upstream.headers === 'object' && !Array.isArray(upstream.headers)
|
|
1709
|
+
? upstream.headers
|
|
1710
|
+
: {};
|
|
1711
|
+
|
|
1712
|
+
if (!normalizedSuffix) {
|
|
1713
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
|
1714
|
+
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1715
|
+
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
1716
|
+
return;
|
|
1717
|
+
}
|
|
1718
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1719
|
+
res.end(JSON.stringify({
|
|
1720
|
+
object: 'codexmate.openai_bridge',
|
|
1721
|
+
provider: match.provider,
|
|
1722
|
+
status: 'ok',
|
|
1723
|
+
endpoints: ['/v1/responses', '/v1/models']
|
|
1724
|
+
}));
|
|
1725
|
+
return;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
if (normalizedSuffix === 'models') {
|
|
1729
|
+
if ((req.method || 'GET').toUpperCase() !== 'GET') {
|
|
1730
|
+
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1731
|
+
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
|
|
1735
|
+
const url = joinApiUrl(upstream.baseUrl, 'models');
|
|
1736
|
+
const result = await retryTransientRequest(() => proxyRequestJson(url, {
|
|
1737
|
+
method: 'GET',
|
|
1738
|
+
headers: {
|
|
1739
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
1740
|
+
...upstreamHeaders
|
|
1741
|
+
},
|
|
1742
|
+
maxBytes: maxUpstreamBytes,
|
|
1743
|
+
httpAgent,
|
|
1744
|
+
httpsAgent
|
|
1745
|
+
}));
|
|
1746
|
+
if (!result.ok) {
|
|
1747
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1748
|
+
res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` }));
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
res.writeHead(result.status || 502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1752
|
+
res.end(result.bodyText || '');
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
|
|
1756
|
+
if (normalizedSuffix !== 'responses') {
|
|
1757
|
+
res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1758
|
+
res.end(JSON.stringify({ error: 'Not Found' }));
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
|
|
1762
|
+
if ((req.method || 'GET').toUpperCase() !== 'POST') {
|
|
1763
|
+
res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1764
|
+
res.end(JSON.stringify({ error: 'Method Not Allowed' }));
|
|
1765
|
+
return;
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
const { body, error: bodyErr } = await readRequestBody(req, maxBodySize);
|
|
1769
|
+
if (bodyErr) {
|
|
1770
|
+
res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1771
|
+
res.end(JSON.stringify({ error: bodyErr }));
|
|
1772
|
+
return;
|
|
1773
|
+
}
|
|
1774
|
+
const parsed = parseJsonOrError(body);
|
|
1775
|
+
if (parsed.error) {
|
|
1776
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1777
|
+
res.end(JSON.stringify({ error: `Invalid JSON: ${parsed.error}` }));
|
|
1778
|
+
return;
|
|
1779
|
+
}
|
|
1780
|
+
|
|
1781
|
+
const responsesRequest = parsed.value;
|
|
1782
|
+
const streamRequested = !!(responsesRequest && typeof responsesRequest === 'object' && responsesRequest.stream === true);
|
|
1783
|
+
const acceptHeader = req && req.headers ? (req.headers.accept || req.headers.Accept || '') : '';
|
|
1784
|
+
const wantsSse = /text\/event-stream/i.test(String(acceptHeader || ''));
|
|
1785
|
+
|
|
1786
|
+
if (streamRequested && wantsSse) {
|
|
1787
|
+
const converted = convertResponsesRequestToChatCompletions(responsesRequest);
|
|
1788
|
+
if (converted.error) {
|
|
1789
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1790
|
+
res.end(JSON.stringify({ error: converted.error }));
|
|
1791
|
+
return;
|
|
1792
|
+
}
|
|
1793
|
+
const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
|
|
1794
|
+
const chatBody = { ...converted.chat, stream: true };
|
|
1795
|
+
const streamed = await retryTransientRequest(() => streamChatCompletionsAsResponsesSse(upstreamUrl, {
|
|
1796
|
+
method: 'POST',
|
|
1797
|
+
body: chatBody,
|
|
1798
|
+
headers: {
|
|
1799
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
1800
|
+
...upstreamHeaders
|
|
1801
|
+
},
|
|
1802
|
+
maxBytes: maxUpstreamBytes,
|
|
1803
|
+
httpAgent,
|
|
1804
|
+
httpsAgent,
|
|
1805
|
+
res,
|
|
1806
|
+
model: typeof chatBody.model === 'string' ? chatBody.model : '',
|
|
1807
|
+
toolTypesByName: converted.toolTypesByName || {}
|
|
1808
|
+
}));
|
|
1809
|
+
if (!streamed.ok) {
|
|
1810
|
+
if (res.writableEnded || res.destroyed) {
|
|
1811
|
+
return;
|
|
1812
|
+
}
|
|
1813
|
+
if (!res.headersSent) {
|
|
1814
|
+
res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1815
|
+
res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'Upstream request failed' }));
|
|
1816
|
+
} else if (!res.writableEnded && !res.destroyed) {
|
|
1817
|
+
writeSse(res, 'response.failed', { type: 'response.failed', error: streamed.error || streamed.bodyText || 'Upstream request failed' });
|
|
1818
|
+
writeSse(res, 'done', '[DONE]');
|
|
1819
|
+
res.end();
|
|
1820
|
+
}
|
|
1821
|
+
}
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
|
|
1825
|
+
// Maxx-style behavior: prefer upstream /responses if supported.
|
|
1826
|
+
// Fallback to /chat/completions conversion when upstream does not implement /responses (404/405).
|
|
1827
|
+
// 已知不支持的上游:直接跳过探测,节省一次 round-trip。
|
|
1828
|
+
const skipResponsesProbe = isResponsesKnownUnsupported(upstream.baseUrl);
|
|
1829
|
+
const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
|
|
1830
|
+
const upstreamResponsesResult = skipResponsesProbe
|
|
1831
|
+
? { ok: true, status: 404, bodyText: '' }
|
|
1832
|
+
: await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, {
|
|
1833
|
+
method: 'POST',
|
|
1834
|
+
body: toUpstreamNonStreamingResponsesPayload(responsesRequest),
|
|
1835
|
+
headers: {
|
|
1836
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
1837
|
+
...upstreamHeaders
|
|
1838
|
+
},
|
|
1839
|
+
maxBytes: maxUpstreamBytes,
|
|
1840
|
+
httpAgent,
|
|
1841
|
+
httpsAgent
|
|
1842
|
+
}));
|
|
1843
|
+
|
|
1844
|
+
if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) {
|
|
1845
|
+
clearResponsesUnsupported(upstream.baseUrl);
|
|
1846
|
+
const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText);
|
|
1847
|
+
if (upstreamJson.error) {
|
|
1848
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1849
|
+
res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
|
|
1850
|
+
return;
|
|
1851
|
+
}
|
|
1852
|
+
const upstreamPayload = upstreamJson.value;
|
|
1853
|
+
if (streamRequested && wantsSse) {
|
|
1854
|
+
res.writeHead(200, {
|
|
1855
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
1856
|
+
'Cache-Control': 'no-cache',
|
|
1857
|
+
'Connection': 'keep-alive',
|
|
1858
|
+
'X-Accel-Buffering': 'no'
|
|
1859
|
+
});
|
|
1860
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
1861
|
+
sendResponsesSse(res, upstreamPayload);
|
|
1862
|
+
res.end();
|
|
1863
|
+
return;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1867
|
+
res.end(JSON.stringify(ensureResponseMetadata(upstreamPayload)));
|
|
1868
|
+
return;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 400) {
|
|
1872
|
+
if (!shouldFallbackFromUpstreamResponses(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
|
|
1873
|
+
res.writeHead(upstreamResponsesResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1874
|
+
res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
|
|
1875
|
+
return;
|
|
1876
|
+
}
|
|
1877
|
+
if (!skipResponsesProbe && isResponsesEndpointUnsupported(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
|
|
1878
|
+
markResponsesUnsupported(upstream.baseUrl);
|
|
1879
|
+
}
|
|
1880
|
+
// fallthrough to chat/completions conversion
|
|
1881
|
+
}
|
|
1882
|
+
|
|
1883
|
+
if (!upstreamResponsesResult.ok) {
|
|
1884
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1885
|
+
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResponsesResult.error}` }));
|
|
1886
|
+
return;
|
|
1887
|
+
}
|
|
1888
|
+
|
|
1889
|
+
const converted = convertResponsesRequestToChatCompletions(responsesRequest);
|
|
1890
|
+
if (converted.error) {
|
|
1891
|
+
res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1892
|
+
res.end(JSON.stringify({ error: converted.error }));
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
|
|
1896
|
+
const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
|
|
1897
|
+
const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
|
|
1898
|
+
method: 'POST',
|
|
1899
|
+
body: converted.chat,
|
|
1900
|
+
headers: {
|
|
1901
|
+
...(authHeader ? { Authorization: authHeader } : {}),
|
|
1902
|
+
...upstreamHeaders
|
|
1903
|
+
},
|
|
1904
|
+
maxBytes: maxUpstreamBytes,
|
|
1905
|
+
httpAgent,
|
|
1906
|
+
httpsAgent
|
|
1907
|
+
}));
|
|
1908
|
+
if (!upstreamResult.ok) {
|
|
1909
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1910
|
+
res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
|
|
1914
|
+
const upstreamJson = parseJsonOrError(upstreamResult.bodyText);
|
|
1915
|
+
if (upstreamResult.status >= 400) {
|
|
1916
|
+
res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1917
|
+
res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1920
|
+
if (upstreamJson.error) {
|
|
1921
|
+
res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1922
|
+
res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
|
|
1923
|
+
return;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
const model = typeof converted.chat.model === 'string' ? converted.chat.model : '';
|
|
1927
|
+
const extracted = extractChatCompletionResult(upstreamJson.value);
|
|
1928
|
+
const text = extracted && typeof extracted.text === 'string' ? extracted.text : '';
|
|
1929
|
+
const toolCalls = extracted && Array.isArray(extracted.toolCalls) ? extracted.toolCalls : [];
|
|
1930
|
+
const responsesPayload = buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamJson.value, {
|
|
1931
|
+
toolTypesByName: converted.toolTypesByName || {}
|
|
1932
|
+
});
|
|
1933
|
+
|
|
1934
|
+
if (converted.streamRequested && wantsSse) {
|
|
1935
|
+
res.writeHead(200, {
|
|
1936
|
+
'Content-Type': 'text/event-stream; charset=utf-8',
|
|
1937
|
+
'Cache-Control': 'no-cache',
|
|
1938
|
+
'Connection': 'keep-alive',
|
|
1939
|
+
'X-Accel-Buffering': 'no'
|
|
1940
|
+
});
|
|
1941
|
+
if (typeof res.flushHeaders === 'function') res.flushHeaders();
|
|
1942
|
+
sendResponsesSse(res, responsesPayload);
|
|
1943
|
+
res.end();
|
|
1944
|
+
return;
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1948
|
+
res.end(JSON.stringify(ensureResponseMetadata(responsesPayload)));
|
|
1949
|
+
} catch (e) {
|
|
1950
|
+
res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
|
|
1951
|
+
res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' }));
|
|
1952
|
+
}
|
|
1953
|
+
})();
|
|
1954
|
+
|
|
1955
|
+
return true;
|
|
1956
|
+
};
|
|
1957
|
+
|
|
1958
|
+
handler.matchPath = matchPath;
|
|
1959
|
+
return handler;
|
|
1960
|
+
}
|
|
1961
|
+
|
|
1962
|
+
module.exports = {
|
|
1963
|
+
readOpenaiBridgeSettings,
|
|
1964
|
+
upsertOpenaiBridgeProvider,
|
|
1965
|
+
resolveOpenaiBridgeUpstream,
|
|
1966
|
+
createOpenaiBridgeHttpHandler,
|
|
1967
|
+
// exported for local-bridge reuse
|
|
1968
|
+
convertResponsesRequestToChatCompletions,
|
|
1969
|
+
streamChatCompletionsAsResponsesSse,
|
|
1970
|
+
proxyRequestJson,
|
|
1971
|
+
ensureResponseMetadata,
|
|
1972
|
+
sendResponsesSse,
|
|
1973
|
+
extractAuthorizationToken,
|
|
1974
|
+
readRequestBody,
|
|
1975
|
+
parseJsonOrError,
|
|
1976
|
+
extractChatCompletionResult,
|
|
1977
|
+
buildResponsesPayloadFromChatResult,
|
|
1978
|
+
retryTransientRequest,
|
|
1979
|
+
normalizeOpenaiUpstreamBaseUrl,
|
|
1980
|
+
extractResponsesOutputText,
|
|
1981
|
+
shouldFallbackFromUpstreamResponses,
|
|
1982
|
+
isTransientNetworkError,
|
|
1983
|
+
isLoopbackAddress
|
|
1984
|
+
};
|