codexmate 0.0.28 → 0.0.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/README.md +421 -421
  2. package/README.zh.md +354 -354
  3. package/cli/agents-files.js +224 -224
  4. package/cli/archive-helpers.js +446 -446
  5. package/cli/auth-profiles.js +375 -375
  6. package/cli/builtin-proxy.js +1725 -1620
  7. package/cli/claude-proxy.js +1022 -1022
  8. package/cli/config-bootstrap.js +384 -384
  9. package/cli/config-health.js +338 -338
  10. package/cli/doctor-core.js +903 -903
  11. package/cli/import-skills-url.js +356 -356
  12. package/cli/openai-bridge.js +1576 -1489
  13. package/cli/openclaw-config.js +629 -629
  14. package/cli/session-convert-args.js +65 -65
  15. package/cli/session-convert-io.js +82 -82
  16. package/cli/session-convert.js +43 -43
  17. package/cli/session-usage.concurrent.js +28 -28
  18. package/cli/session-usage.js +118 -118
  19. package/cli/session-usage.models.js +176 -176
  20. package/cli/skills.js +1141 -1141
  21. package/cli/zip-commands.js +510 -510
  22. package/cli.js +15340 -15264
  23. package/lib/automation.js +404 -404
  24. package/lib/cli-file-utils.js +151 -151
  25. package/lib/cli-models-utils.js +440 -440
  26. package/lib/cli-network-utils.js +190 -190
  27. package/lib/cli-path-utils.js +85 -85
  28. package/lib/cli-session-utils.js +121 -121
  29. package/lib/cli-sessions.js +417 -417
  30. package/lib/cli-utils.js +155 -155
  31. package/lib/cli-webhook.js +126 -0
  32. package/lib/download-artifacts.js +92 -92
  33. package/lib/mcp-stdio.js +453 -453
  34. package/lib/task-orchestrator.js +869 -869
  35. package/lib/text-diff.js +303 -303
  36. package/lib/workflow-engine.js +340 -340
  37. package/package.json +76 -76
  38. package/plugins/README.md +20 -20
  39. package/plugins/README.zh-CN.md +20 -20
  40. package/plugins/prompt-templates/comment-polish/index.mjs +25 -25
  41. package/plugins/prompt-templates/computed.mjs +253 -253
  42. package/plugins/prompt-templates/index.mjs +8 -8
  43. package/plugins/prompt-templates/manifest.mjs +15 -15
  44. package/plugins/prompt-templates/methods.mjs +553 -553
  45. package/plugins/prompt-templates/overview.mjs +91 -91
  46. package/plugins/prompt-templates/ownership.mjs +19 -19
  47. package/plugins/prompt-templates/rule-ack/index.mjs +21 -21
  48. package/plugins/prompt-templates/storage.mjs +64 -64
  49. package/plugins/registry.mjs +16 -16
  50. package/web-ui/app.js +639 -625
  51. package/web-ui/index.html +36 -35
  52. package/web-ui/logic.agents-diff.mjs +386 -386
  53. package/web-ui/logic.claude.mjs +168 -168
  54. package/web-ui/logic.codex.mjs +69 -56
  55. package/web-ui/logic.mjs +5 -5
  56. package/web-ui/logic.runtime.mjs +128 -128
  57. package/web-ui/logic.session-convert.mjs +70 -70
  58. package/web-ui/logic.sessions.mjs +765 -765
  59. package/web-ui/modules/api.mjs +90 -90
  60. package/web-ui/modules/app.computed.dashboard.mjs +225 -225
  61. package/web-ui/modules/app.computed.index.mjs +17 -17
  62. package/web-ui/modules/app.computed.main-tabs.mjs +205 -205
  63. package/web-ui/modules/app.computed.session.mjs +999 -994
  64. package/web-ui/modules/app.constants.mjs +15 -15
  65. package/web-ui/modules/app.methods.agents.mjs +632 -632
  66. package/web-ui/modules/app.methods.claude-config.mjs +200 -190
  67. package/web-ui/modules/app.methods.codex-config.mjs +892 -892
  68. package/web-ui/modules/app.methods.index.mjs +94 -92
  69. package/web-ui/modules/app.methods.install.mjs +205 -205
  70. package/web-ui/modules/app.methods.navigation.mjs +761 -743
  71. package/web-ui/modules/app.methods.openclaw-core.mjs +814 -814
  72. package/web-ui/modules/app.methods.openclaw-editing.mjs +372 -372
  73. package/web-ui/modules/app.methods.openclaw-persist.mjs +369 -369
  74. package/web-ui/modules/app.methods.providers.mjs +493 -412
  75. package/web-ui/modules/app.methods.runtime.mjs +345 -345
  76. package/web-ui/modules/app.methods.session-actions.mjs +593 -593
  77. package/web-ui/modules/app.methods.session-browser.mjs +984 -984
  78. package/web-ui/modules/app.methods.session-timeline.mjs +479 -479
  79. package/web-ui/modules/app.methods.session-trash.mjs +438 -439
  80. package/web-ui/modules/app.methods.startup-claude.mjs +533 -533
  81. package/web-ui/modules/app.methods.task-orchestration.mjs +556 -556
  82. package/web-ui/modules/app.methods.webhook.mjs +79 -0
  83. package/web-ui/modules/config-mode.computed.mjs +124 -124
  84. package/web-ui/modules/config-template-confirm-pref.mjs +33 -33
  85. package/web-ui/modules/i18n.dict.mjs +3131 -2109
  86. package/web-ui/modules/i18n.mjs +62 -56
  87. package/web-ui/modules/plugins.computed.mjs +3 -3
  88. package/web-ui/modules/plugins.methods.mjs +3 -3
  89. package/web-ui/modules/plugins.storage.mjs +11 -11
  90. package/web-ui/modules/provider-url-display.mjs +17 -17
  91. package/web-ui/modules/sessions-filters-url.mjs +85 -85
  92. package/web-ui/modules/skills.computed.mjs +107 -107
  93. package/web-ui/modules/skills.methods.mjs +481 -481
  94. package/web-ui/partials/index/layout-footer.html +13 -13
  95. package/web-ui/partials/index/layout-header.html +500 -475
  96. package/web-ui/partials/index/modal-config-template-agents.html +174 -174
  97. package/web-ui/partials/index/modal-confirm-toast.html +32 -32
  98. package/web-ui/partials/index/modal-health-check.html +45 -45
  99. package/web-ui/partials/index/modal-openclaw-config.html +280 -280
  100. package/web-ui/partials/index/modal-skills.html +200 -200
  101. package/web-ui/partials/index/modals-basic.html +162 -165
  102. package/web-ui/partials/index/panel-config-claude.html +194 -188
  103. package/web-ui/partials/index/panel-config-codex.html +323 -312
  104. package/web-ui/partials/index/panel-config-openclaw.html +83 -83
  105. package/web-ui/partials/index/panel-dashboard.html +186 -186
  106. package/web-ui/partials/index/panel-docs.html +147 -147
  107. package/web-ui/partials/index/panel-market.html +177 -177
  108. package/web-ui/partials/index/panel-orchestration.html +391 -391
  109. package/web-ui/partials/index/panel-plugins.html +253 -253
  110. package/web-ui/partials/index/panel-sessions.html +316 -316
  111. package/web-ui/partials/index/panel-settings.html +190 -253
  112. package/web-ui/partials/index/panel-trash.html +88 -0
  113. package/web-ui/partials/index/panel-usage.html +371 -371
  114. package/web-ui/res/json5.min.js +1 -1
  115. package/web-ui/res/vue.global.prod.js +13 -13
  116. package/web-ui/session-helpers.mjs +576 -576
  117. package/web-ui/source-bundle.cjs +233 -233
  118. package/web-ui/styles/base-theme.css +281 -281
  119. package/web-ui/styles/controls-forms.css +422 -422
  120. package/web-ui/styles/dashboard.css +274 -274
  121. package/web-ui/styles/docs-panel.css +271 -247
  122. package/web-ui/styles/feedback.css +108 -108
  123. package/web-ui/styles/health-check-dialog.css +144 -144
  124. package/web-ui/styles/layout-shell.css +626 -606
  125. package/web-ui/styles/modals-core.css +466 -466
  126. package/web-ui/styles/navigation-panels.css +391 -391
  127. package/web-ui/styles/openclaw-structured.css +266 -266
  128. package/web-ui/styles/plugins-panel.css +523 -523
  129. package/web-ui/styles/responsive.css +454 -454
  130. package/web-ui/styles/sessions-list.css +419 -419
  131. package/web-ui/styles/sessions-preview.css +411 -411
  132. package/web-ui/styles/sessions-toolbar-trash.css +330 -330
  133. package/web-ui/styles/sessions-usage.css +1040 -1040
  134. package/web-ui/styles/settings-panel.css +349 -185
  135. package/web-ui/styles/skills-list.css +303 -303
  136. package/web-ui/styles/skills-market.css +406 -406
  137. package/web-ui/styles/task-orchestration.css +822 -822
  138. package/web-ui/styles/titles-cards.css +472 -472
  139. package/web-ui/styles/trash-panel.css +90 -0
  140. package/web-ui/styles/webhook.css +81 -0
  141. package/web-ui/styles.css +23 -21
  142. package/web-ui.html +17 -17
@@ -1,1489 +1,1576 @@
1
- const http = require('http');
2
- const https = require('https');
3
- const crypto = require('crypto');
4
- const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils');
5
- const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils');
6
-
7
- const DEFAULT_BRIDGE_TOKEN = 'codexmate';
8
- const SETTINGS_VERSION = 1;
9
-
10
- function normalizeText(value) {
11
- return typeof value === 'string' ? value.trim() : '';
12
- }
13
-
14
- function normalizeProviderName(value) {
15
- // Provider name validation is done elsewhere; keep this conservative.
16
- return normalizeText(value);
17
- }
18
-
19
- function normalizeOpenaiUpstreamBaseUrl(rawValue) {
20
- const normalized = normalizeBaseUrl(rawValue);
21
- if (!normalized) return '';
22
- try {
23
- const parsed = new URL(normalized);
24
- let pathname = String(parsed.pathname || '').replace(/\/+$/g, '');
25
-
26
- // If user accidentally pasted a full endpoint, strip it back to the base URL.
27
- // Keep direct provider routes (e.g. /project/ym) intact.
28
- pathname = pathname
29
- .replace(/\/v1\/chat\/completions$/i, '/v1')
30
- .replace(/\/chat\/completions$/i, '')
31
- .replace(/\/v1\/responses$/i, '/v1')
32
- .replace(/\/responses$/i, '')
33
- .replace(/\/v1\/models$/i, '/v1')
34
- .replace(/\/models$/i, '');
35
-
36
- // Normalize empty/root path.
37
- if (pathname === '/') pathname = '';
38
-
39
- const rebuilt = `${parsed.origin}${pathname}`;
40
- return normalizeBaseUrl(rebuilt);
41
- } catch (_) {
42
- return normalized;
43
- }
44
- }
45
-
46
- function normalizeUpstreamEntry(entry) {
47
- if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
48
- return null;
49
- }
50
- const baseUrl = normalizeOpenaiUpstreamBaseUrl(entry.baseUrl || entry.base_url || '');
51
- const apiKey = normalizeText(entry.apiKey || entry.api_key || entry.key || '');
52
- const headersRaw = entry.headers || entry.extraHeaders || entry.extra_headers || null;
53
- const headers = normalizeHeadersMap(headersRaw);
54
- if (!baseUrl || !isValidHttpUrl(baseUrl)) {
55
- return null;
56
- }
57
- return { baseUrl, apiKey, headers };
58
- }
59
-
60
- function normalizeHeadersMap(value) {
61
- if (!value || typeof value !== 'object' || Array.isArray(value)) {
62
- return {};
63
- }
64
- const forbidden = new Set([
65
- 'authorization',
66
- 'host',
67
- 'content-length',
68
- 'connection',
69
- 'transfer-encoding',
70
- 'keep-alive',
71
- 'proxy-authenticate',
72
- 'proxy-authorization',
73
- 'te',
74
- 'trailer',
75
- 'upgrade'
76
- ]);
77
- const result = {};
78
- for (const [rawKey, rawVal] of Object.entries(value)) {
79
- const key = typeof rawKey === 'string' ? rawKey.trim() : '';
80
- if (!key) continue;
81
- const lower = key.toLowerCase();
82
- if (forbidden.has(lower)) continue;
83
- if (typeof rawVal !== 'string') continue;
84
- result[key] = rawVal;
85
- }
86
- return result;
87
- }
88
-
89
- function readOpenaiBridgeSettings(filePath) {
90
- const parsed = readJsonFile(filePath, null);
91
- const providers = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
92
- ? parsed.providers
93
- : null;
94
- const providerMap = providers && typeof providers === 'object' && !Array.isArray(providers)
95
- ? providers
96
- : {};
97
- return {
98
- version: SETTINGS_VERSION,
99
- providers: providerMap
100
- };
101
- }
102
-
103
- function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, apiKey, headers) {
104
- const name = normalizeProviderName(providerName);
105
- const baseUrl = normalizeOpenaiUpstreamBaseUrl(upstreamBaseUrl);
106
- const key = normalizeText(apiKey);
107
- const nextHeaders = normalizeHeadersMap(headers);
108
-
109
- if (!name) {
110
- return { error: 'Provider name is required' };
111
- }
112
- if (!baseUrl || !isValidHttpUrl(baseUrl)) {
113
- return { error: 'Upstream base URL is invalid' };
114
- }
115
-
116
- const settings = readOpenaiBridgeSettings(filePath);
117
- const existing = settings && settings.providers ? settings.providers[name] : null;
118
- const existingHeaders = existing && typeof existing === 'object' && !Array.isArray(existing)
119
- ? normalizeHeadersMap(existing.headers || existing.extraHeaders || existing.extra_headers || null)
120
- : {};
121
- const next = {
122
- version: SETTINGS_VERSION,
123
- providers: {
124
- ...(settings.providers || {}),
125
- [name]: {
126
- baseUrl,
127
- apiKey: key,
128
- headers: Object.keys(nextHeaders).length ? nextHeaders : existingHeaders
129
- }
130
- }
131
- };
132
- writeJsonAtomic(filePath, next);
133
- return { success: true };
134
- }
135
-
136
- function resolveOpenaiBridgeUpstream(filePath, providerName) {
137
- const name = normalizeProviderName(providerName);
138
- if (!name) return { error: 'Provider name is required' };
139
- const settings = readOpenaiBridgeSettings(filePath);
140
- const entry = settings.providers ? settings.providers[name] : null;
141
- const normalized = normalizeUpstreamEntry(entry);
142
- if (!normalized) {
143
- return { error: `OpenAI 转换未配置: ${name}` };
144
- }
145
- return { provider: name, ...normalized };
146
- }
147
-
148
- function extractAuthorizationToken(req) {
149
- const header = typeof req.headers.authorization === 'string' ? req.headers.authorization.trim() : '';
150
- if (!header) return '';
151
- if (/^bearer\s+/i.test(header)) {
152
- return header.replace(/^bearer\s+/i, '').trim();
153
- }
154
- return header;
155
- }
156
-
157
- function readRequestBody(req, maxBytes) {
158
- return new Promise((resolve) => {
159
- let body = '';
160
- let size = 0;
161
- let aborted = false;
162
- req.on('data', (chunk) => {
163
- if (aborted) return;
164
- size += chunk.length;
165
- if (Number.isFinite(maxBytes) && maxBytes > 0 && size > maxBytes) {
166
- aborted = true;
167
- try { req.destroy(); } catch (_) {}
168
- resolve({ error: '请求体过大' });
169
- return;
170
- }
171
- body += chunk;
172
- });
173
- req.on('end', () => {
174
- if (aborted) return;
175
- resolve({ body });
176
- });
177
- req.on('error', (err) => resolve({ error: err && err.message ? err.message : 'request failed' }));
178
- });
179
- }
180
-
181
- function parseJsonOrError(text) {
182
- if (typeof text !== 'string' || !text.trim()) {
183
- return { value: null, error: 'empty body' };
184
- }
185
- try {
186
- return { value: JSON.parse(text), error: '' };
187
- } catch (e) {
188
- return { value: null, error: e && e.message ? e.message : 'invalid json' };
189
- }
190
- }
191
-
192
- function extractChatCompletionResult(payload) {
193
- if (!payload || typeof payload !== 'object') return { text: '', toolCalls: [] };
194
- const choice = Array.isArray(payload.choices) ? payload.choices[0] : null;
195
- const message = choice && typeof choice === 'object' ? choice.message : null;
196
- const toolCalls = message && typeof message === 'object' && Array.isArray(message.tool_calls)
197
- ? message.tool_calls
198
- : [];
199
- const content = message && typeof message === 'object' ? message.content : '';
200
- let text = '';
201
- if (typeof content === 'string') {
202
- text = content;
203
- } else if (Array.isArray(content)) {
204
- text = content
205
- .map((item) => {
206
- if (!item) return '';
207
- if (typeof item === 'string') return item;
208
- if (typeof item === 'object') {
209
- if (typeof item.text === 'string') return item.text;
210
- if (typeof item.content === 'string') return item.content;
211
- }
212
- return '';
213
- })
214
- .filter(Boolean)
215
- .join('');
216
- }
217
- return { text, toolCalls };
218
- }
219
-
220
- function normalizeResponsesInputToChatMessages(input) {
221
- // 支持:
222
- // - string
223
- // - { role, content }(单条 message)
224
- // - { type:"input_text"|"input_image", ... }(单个 block)
225
- // - [{ role, content: [...] }](messages array)
226
- // - [{ type:"input_text"|"input_image", ... }](blocks array -> 单条 user 消息)
227
- if (typeof input === 'string') {
228
- return [{ role: 'user', content: input }];
229
- }
230
-
231
- const toChatContent = (blocks) => {
232
- if (!Array.isArray(blocks)) return '';
233
- const out = [];
234
- for (const block of blocks) {
235
- if (!block || typeof block !== 'object') continue;
236
- const type = typeof block.type === 'string' ? block.type : '';
237
- if ((type === 'input_text' || type === 'output_text') && typeof block.text === 'string') {
238
- out.push({ type: 'text', text: block.text });
239
- continue;
240
- }
241
- if ((type === 'reasoning' || type === 'reasoning_text' || type === 'reasoning_content') && typeof block.text === 'string') {
242
- out.push({ type: 'text', text: block.text });
243
- continue;
244
- }
245
- if (type === 'input_image') {
246
- const raw = block.image_url != null ? block.image_url : block.imageUrl;
247
- const url = typeof raw === 'string'
248
- ? raw
249
- : (raw && typeof raw === 'object' && typeof raw.url === 'string' ? raw.url : '');
250
- if (url) {
251
- out.push({ type: 'image_url', image_url: { url } });
252
- }
253
- continue;
254
- }
255
- // 容错:兼容已是 chat content 的 {type:"text"} / {type:"image_url"}
256
- if (type === 'text' && typeof block.text === 'string') {
257
- out.push({ type: 'text', text: block.text });
258
- continue;
259
- }
260
- if (type === 'image_url' && block.image_url) {
261
- out.push({ type: 'image_url', image_url: block.image_url });
262
- continue;
263
- }
264
- const text = typeof block.text === 'string'
265
- ? block.text
266
- : (typeof block.content === 'string' ? block.content : '');
267
- if (text) {
268
- out.push({ type: 'text', text });
269
- continue;
270
- }
271
- try {
272
- const raw = JSON.stringify(block);
273
- if (raw) {
274
- out.push({ type: 'text', text: raw.slice(0, 4000) });
275
- }
276
- } catch (_) {}
277
- }
278
- if (out.length === 0) return '';
279
- return out;
280
- };
281
-
282
- const toRole = (value) => {
283
- const roleRaw = typeof value === 'string' ? value.trim().toLowerCase() : '';
284
- return roleRaw === 'assistant' ? 'assistant' : (roleRaw === 'system' ? 'system' : 'user');
285
- };
286
-
287
- if (input && typeof input === 'object' && !Array.isArray(input)) {
288
- if (typeof input.role === 'string' && input.content != null) {
289
- const role = toRole(input.role);
290
- const content = Array.isArray(input.content)
291
- ? toChatContent(input.content)
292
- : input.content;
293
- return content ? [{ role, content }] : [];
294
- }
295
- if (typeof input.type === 'string') {
296
- const content = toChatContent([input]);
297
- return content ? [{ role: 'user', content }] : [];
298
- }
299
- return [];
300
- }
301
-
302
- if (!Array.isArray(input)) {
303
- return [];
304
- }
305
-
306
- const messages = [];
307
- for (const item of input) {
308
- if (!item || typeof item !== 'object') continue;
309
-
310
- // Tool calls (Responses): { type: "function_call", call_id, name, arguments }
311
- // Chat Completions equivalent: assistant message with tool_calls
312
- if (typeof item.type === 'string' && item.type === 'function_call') {
313
- const callId = typeof item.call_id === 'string' ? item.call_id.trim() : '';
314
- const name = typeof item.name === 'string' ? item.name.trim() : '';
315
- const args = typeof item.arguments === 'string' ? item.arguments : '';
316
- if (callId && name) {
317
- messages.push({
318
- role: 'assistant',
319
- tool_calls: [{
320
- id: callId,
321
- type: 'function',
322
- function: { name, arguments: args || '' }
323
- }]
324
- });
325
- }
326
- continue;
327
- }
328
-
329
- // Tool results (Responses): { type: "function_call_output", call_id, output }
330
- // Chat Completions equivalent: { role: "tool", tool_call_id, content }
331
- if (typeof item.type === 'string' && item.type === 'function_call_output') {
332
- const toolCallId = typeof item.call_id === 'string' ? item.call_id.trim() : '';
333
- let content = item.output;
334
- if (typeof content !== 'string') {
335
- try {
336
- content = JSON.stringify(content);
337
- } catch (_) {
338
- content = String(content ?? '');
339
- }
340
- }
341
- if (toolCallId) {
342
- messages.push({ role: 'tool', tool_call_id: toolCallId, content: String(content || '') });
343
- }
344
- continue;
345
- }
346
-
347
- // message form
348
- if (typeof item.role === 'string' && item.content != null) {
349
- const role = toRole(item.role);
350
- const content = Array.isArray(item.content)
351
- ? toChatContent(item.content)
352
- : item.content;
353
- if (content) {
354
- messages.push({ role, content });
355
- }
356
- continue;
357
- }
358
- }
359
-
360
- if (messages.length > 0) {
361
- return messages;
362
- }
363
-
364
- // 退化:把 input array 当作单条 user content blocks
365
- const fallbackContent = toChatContent(input);
366
- if (fallbackContent) {
367
- return [{ role: 'user', content: fallbackContent }];
368
- }
369
- return [];
370
- }
371
-
372
-
373
- function normalizeResponsesToolsToChatTools(tools) {
374
- if (!Array.isArray(tools)) return tools;
375
- return tools
376
- .map((tool) => {
377
- if (!tool || typeof tool !== 'object') return null;
378
- if (tool.type !== 'function') return null;
379
- const sourceFn = tool.function && typeof tool.function === 'object' && !Array.isArray(tool.function)
380
- ? tool.function
381
- : {};
382
- const name = typeof sourceFn.name === 'string' && sourceFn.name.trim()
383
- ? sourceFn.name.trim()
384
- : (typeof tool.name === 'string' ? tool.name.trim() : '');
385
- if (!name) return null;
386
- const parameters = sourceFn.parameters && typeof sourceFn.parameters === 'object' && !Array.isArray(sourceFn.parameters)
387
- ? sourceFn.parameters
388
- : (tool.parameters && typeof tool.parameters === 'object' && !Array.isArray(tool.parameters) ? tool.parameters : {});
389
- const fn = { name, parameters };
390
- const description = typeof sourceFn.description === 'string'
391
- ? sourceFn.description
392
- : (typeof tool.description === 'string' ? tool.description : undefined);
393
- const strict = typeof sourceFn.strict === 'boolean'
394
- ? sourceFn.strict
395
- : (typeof tool.strict === 'boolean' ? tool.strict : undefined);
396
- if (description !== undefined) fn.description = description;
397
- if (strict !== undefined) fn.strict = strict;
398
- return { type: 'function', function: fn };
399
- })
400
- .filter(Boolean);
401
- }
402
-
403
- function normalizeResponsesToolChoiceToChatToolChoice(toolChoice) {
404
- if (!toolChoice || typeof toolChoice !== 'object' || Array.isArray(toolChoice)) return toolChoice;
405
- if (toolChoice.type === 'function' && typeof toolChoice.name === 'string' && toolChoice.name.trim()) {
406
- return { type: 'function', function: { name: toolChoice.name.trim() } };
407
- }
408
- return toolChoice;
409
- }
410
-
411
- function normalizeResponsesToolsForResponsesApi(tools) {
412
- if (!Array.isArray(tools)) return tools;
413
- return tools
414
- .map((tool) => {
415
- if (!tool || typeof tool !== 'object') return null;
416
- if (tool.type !== 'function') return null;
417
- const sourceFn = tool.function && typeof tool.function === 'object' && !Array.isArray(tool.function)
418
- ? tool.function
419
- : {};
420
- const name = typeof sourceFn.name === 'string' && sourceFn.name.trim()
421
- ? sourceFn.name.trim()
422
- : (typeof tool.name === 'string' ? tool.name.trim() : '');
423
- if (!name) return null;
424
- const out = { type: 'function', name };
425
- const description = typeof sourceFn.description === 'string'
426
- ? sourceFn.description
427
- : (typeof tool.description === 'string' ? tool.description : undefined);
428
- const parameters = sourceFn.parameters && typeof sourceFn.parameters === 'object' && !Array.isArray(sourceFn.parameters)
429
- ? sourceFn.parameters
430
- : (tool.parameters && typeof tool.parameters === 'object' && !Array.isArray(tool.parameters) ? tool.parameters : undefined);
431
- const strict = typeof sourceFn.strict === 'boolean'
432
- ? sourceFn.strict
433
- : (typeof tool.strict === 'boolean' ? tool.strict : undefined);
434
- if (description !== undefined) out.description = description;
435
- if (parameters !== undefined) out.parameters = parameters;
436
- if (strict !== undefined) out.strict = strict;
437
- return out;
438
- })
439
- .filter(Boolean);
440
- }
441
-
442
- function convertResponsesRequestToChatCompletions(payload) {
443
- const body = payload && typeof payload === 'object' ? payload : {};
444
- const model = typeof body.model === 'string' ? body.model.trim() : '';
445
- if (!model) {
446
- return { error: 'responses 请求缺少 model' };
447
- }
448
-
449
- const messages = [];
450
- // Align with Maxx/CLIProxyAPI style: map "instructions" to a leading system message.
451
- if (typeof body.instructions === 'string' && body.instructions.trim()) {
452
- messages.push({ role: 'system', content: body.instructions.trim() });
453
- }
454
- messages.push(...normalizeResponsesInputToChatMessages(body.input));
455
- if (!messages.length) {
456
- // codex sometimes sends empty input for probes; tolerate.
457
- messages.push({ role: 'user', content: '' });
458
- }
459
-
460
- const maxOutputTokens = Number.parseInt(String(body.max_output_tokens), 10);
461
- const stream = body.stream === true;
462
-
463
- const chat = {
464
- model,
465
- messages,
466
- stream: false,
467
- temperature: Number.isFinite(body.temperature) ? Number(body.temperature) : undefined,
468
- top_p: Number.isFinite(body.top_p) ? Number(body.top_p) : undefined,
469
- max_tokens: Number.isFinite(maxOutputTokens) && maxOutputTokens > 0 ? maxOutputTokens : undefined
470
- };
471
- if (Array.isArray(body.stop) && body.stop.length) {
472
- chat.stop = body.stop.filter((item) => typeof item === 'string' && item.trim());
473
- }
474
- if (Array.isArray(body.tools) && body.tools.length) {
475
- chat.tools = normalizeResponsesToolsToChatTools(body.tools);
476
- }
477
- if (body.tool_choice !== undefined) {
478
- chat.tool_choice = normalizeResponsesToolChoiceToChatToolChoice(body.tool_choice);
479
- }
480
- if (body.response_format !== undefined) {
481
- chat.response_format = body.response_format;
482
- }
483
- if (body.metadata !== undefined) {
484
- chat.metadata = body.metadata;
485
- }
486
-
487
- // Remove undefined keys
488
- Object.keys(chat).forEach((key) => chat[key] === undefined && delete chat[key]);
489
-
490
- return { chat, streamRequested: stream };
491
- }
492
-
493
- function buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamPayload) {
494
- const responseId = `resp_${crypto.randomBytes(10).toString('hex')}`;
495
- const usage = upstreamPayload && upstreamPayload.usage && typeof upstreamPayload.usage === 'object'
496
- ? upstreamPayload.usage
497
- : null;
498
- const createdAt = Math.floor(Date.now() / 1000);
499
- const output = [];
500
- const trimmedText = typeof text === 'string' ? text : '';
501
- if (trimmedText) {
502
- output.push({
503
- id: `msg_${crypto.randomBytes(8).toString('hex')}`,
504
- type: 'message',
505
- role: 'assistant',
506
- content: [{ type: 'output_text', text: trimmedText }]
507
- });
508
- }
509
-
510
- // Convert chat.completions tool_calls into Responses-style function_call output items.
511
- // This is important for Codex, which appends function_call + function_call_output back into `input`.
512
- if (Array.isArray(toolCalls)) {
513
- for (const call of toolCalls) {
514
- if (!call || typeof call !== 'object') continue;
515
- const callId = typeof call.id === 'string' && call.id.trim() ? call.id.trim() : `call_${crypto.randomBytes(8).toString('hex')}`;
516
- const fn = call.function && typeof call.function === 'object' ? call.function : {};
517
- const name = typeof fn.name === 'string' ? fn.name : '';
518
- const args = typeof fn.arguments === 'string' ? fn.arguments : '';
519
- if (!name) continue;
520
- output.push({
521
- type: 'function_call',
522
- call_id: callId,
523
- name,
524
- arguments: args
525
- });
526
- }
527
- }
528
-
529
- const payload = {
530
- id: responseId,
531
- object: 'response',
532
- model,
533
- created_at: createdAt,
534
- status: 'completed',
535
- output,
536
- output_text: trimmedText
537
- };
538
-
539
- if (usage) {
540
- // Map chat.completions usage -> responses usage shape when possible.
541
- const promptTokens = Number.isFinite(usage.prompt_tokens) ? Number(usage.prompt_tokens) : null;
542
- const completionTokens = Number.isFinite(usage.completion_tokens) ? Number(usage.completion_tokens) : null;
543
- const totalTokens = Number.isFinite(usage.total_tokens) ? Number(usage.total_tokens) : null;
544
- if (promptTokens !== null || completionTokens !== null || totalTokens !== null) {
545
- payload.usage = {
546
- input_tokens: promptTokens ?? undefined,
547
- output_tokens: completionTokens ?? undefined,
548
- total_tokens: totalTokens ?? undefined
549
- };
550
- Object.keys(payload.usage).forEach((key) => payload.usage[key] === undefined && delete payload.usage[key]);
551
- } else {
552
- payload.usage = usage;
553
- }
554
- }
555
-
556
- return payload;
557
- }
558
-
559
- function ensureResponseMetadata(response) {
560
- const payload = response && typeof response === 'object' ? response : {};
561
- if (typeof payload.object !== 'string' || !payload.object.trim()) {
562
- payload.object = 'response';
563
- }
564
- if (typeof payload.created_at !== 'number') {
565
- payload.created_at = Math.floor(Date.now() / 1000);
566
- }
567
- if (typeof payload.status !== 'string' || !payload.status.trim()) {
568
- payload.status = 'completed';
569
- }
570
- if (!Array.isArray(payload.output)) {
571
- payload.output = [];
572
- }
573
- return payload;
574
- }
575
-
576
- function sendResponsesSse(res, responsePayload) {
577
- const response = ensureResponseMetadata(responsePayload);
578
- const responseId = typeof response.id === 'string' && response.id.trim()
579
- ? response.id.trim()
580
- : `resp_${crypto.randomBytes(10).toString('hex')}`;
581
- const model = typeof response.model === 'string' ? response.model : '';
582
-
583
- let sequence = 0;
584
- const nextSeq = () => {
585
- sequence += 1;
586
- return sequence;
587
- };
588
-
589
- writeSse(res, 'response.created', {
590
- type: 'response.created',
591
- response: {
592
- id: responseId,
593
- model,
594
- created_at: response.created_at
595
- }
596
- });
597
-
598
- const output = Array.isArray(response.output) ? response.output : [];
599
- for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
600
- const item = output[outputIndex];
601
- if (!item || typeof item !== 'object') continue;
602
- const itemType = typeof item.type === 'string' ? item.type : '';
603
- const itemId = typeof item.id === 'string' && item.id.trim()
604
- ? item.id.trim()
605
- : (typeof item.call_id === 'string' && item.call_id.trim() ? item.call_id.trim() : `item_${crypto.randomBytes(8).toString('hex')}`);
606
-
607
- // Emit item added so Codex can anchor subsequent deltas by output_index/content_index/item_id.
608
- writeSse(res, 'response.output_item.added', {
609
- type: 'response.output_item.added',
610
- output_index: outputIndex,
611
- item: { ...item, id: itemId }
612
- });
613
-
614
- if (itemType === 'message') {
615
- const content = Array.isArray(item.content) ? item.content : [];
616
- for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
617
- const block = content[contentIndex];
618
- if (!block || typeof block !== 'object') continue;
619
- if (block.type !== 'output_text') continue;
620
- const text = typeof block.text === 'string' ? block.text : '';
621
- if (text) {
622
- writeSse(res, 'response.output_text.delta', {
623
- type: 'response.output_text.delta',
624
- item_id: itemId,
625
- output_index: outputIndex,
626
- content_index: contentIndex,
627
- delta: text,
628
- sequence_number: nextSeq()
629
- });
630
- }
631
- writeSse(res, 'response.output_text.done', {
632
- type: 'response.output_text.done',
633
- item_id: itemId,
634
- output_index: outputIndex,
635
- content_index: contentIndex,
636
- text,
637
- sequence_number: nextSeq()
638
- });
639
- }
640
- }
641
-
642
- // Emit item done for all item types (message/function_call/etc).
643
- writeSse(res, 'response.output_item.done', {
644
- type: 'response.output_item.done',
645
- output_index: outputIndex,
646
- item: { ...item, id: itemId },
647
- sequence_number: nextSeq()
648
- });
649
- }
650
-
651
- writeSse(res, 'response.completed', { type: 'response.completed', response });
652
- writeSse(res, 'done', '[DONE]');
653
- }
654
-
655
- function extractResponsesOutputText(payload) {
656
- if (!payload || typeof payload !== 'object') return '';
657
- const output = Array.isArray(payload.output) ? payload.output : [];
658
- for (const item of output) {
659
- if (!item || typeof item !== 'object') continue;
660
- if (item.type !== 'message') continue;
661
- const content = Array.isArray(item.content) ? item.content : [];
662
- for (const block of content) {
663
- if (!block || typeof block !== 'object') continue;
664
- if (block.type !== 'output_text') continue;
665
- if (typeof block.text === 'string') return block.text;
666
- }
667
- }
668
- if (typeof payload.output_text === 'string') return payload.output_text;
669
- return '';
670
- }
671
-
672
- function toUpstreamNonStreamingResponsesPayload(payload) {
673
- const body = payload && typeof payload === 'object' ? payload : {};
674
- const normalized = { ...body, stream: false };
675
- if (Array.isArray(body.tools)) {
676
- normalized.tools = normalizeResponsesToolsForResponsesApi(body.tools);
677
- }
678
- return normalized;
679
- }
680
-
681
- function shouldFallbackFromUpstreamResponses(status, bodyText) {
682
- if (!Number.isFinite(status)) return false;
683
- // Common "unsupported" status codes for a route.
684
- if (status === 404 || status === 405 || status === 501) return true;
685
-
686
- // Some OpenAI-compatible gateways respond with 500 + "not implemented" (e.g. convert_request_failed)
687
- // instead of 404/405 for unsupported endpoints. In that case we can safely fallback to chat/completions.
688
- const text = String(bodyText || '');
689
- if (!text) return false;
690
- if (/not implemented/i.test(text)) return true;
691
- if (/convert_request_failed/i.test(text)) return true;
692
- if (/unknown (endpoint|route)/i.test(text)) return true;
693
- if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
694
- if (/does not support.*responses/i.test(text)) return true;
695
- if (/name['"`]?\s+is a required property/i.test(text) && /tools/i.test(text) && /function/i.test(text)) return true;
696
-
697
- // Best-effort parse for structured error codes.
698
- try {
699
- const parsed = JSON.parse(text);
700
- const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
701
- const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
702
- if (code === 'convert_request_failed') return true;
703
- if (/not implemented/i.test(msg)) return true;
704
- if (/unknown (endpoint|route)/i.test(msg)) return true;
705
- if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
706
- if (/does not support.*responses/i.test(msg)) return true;
707
- if (/name['"`]?\s+is a required property/i.test(msg) && /tools/i.test(msg) && /function/i.test(msg)) return true;
708
- } catch (_) {}
709
-
710
- return false;
711
- }
712
-
713
- function isLoopbackAddress(address) {
714
- if (!address) return false;
715
- const value = String(address);
716
- return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
717
- }
718
-
719
- function writeSse(res, eventName, dataObj) {
720
- if (!res || res.writableEnded || res.destroyed) return;
721
- if (eventName) {
722
- res.write(`event: ${eventName}\n`);
723
- }
724
- if (dataObj === '[DONE]') {
725
- res.write('data: [DONE]\n\n');
726
- return;
727
- }
728
- res.write(`data: ${JSON.stringify(dataObj)}\n\n`);
729
- }
730
-
731
- function appendChatStreamToolCall(target, toolCall) {
732
- if (!toolCall || typeof toolCall !== 'object') return;
733
- const index = Number.isFinite(toolCall.index) ? toolCall.index : target.length;
734
- if (!target[index]) {
735
- target[index] = {
736
- id: '',
737
- type: 'function',
738
- function: { name: '', arguments: '' }
739
- };
740
- }
741
- const current = target[index];
742
- if (typeof toolCall.id === 'string' && toolCall.id) current.id = toolCall.id;
743
- if (typeof toolCall.type === 'string' && toolCall.type) current.type = toolCall.type;
744
- const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : null;
745
- if (fn) {
746
- if (typeof fn.name === 'string' && fn.name) current.function.name = fn.name;
747
- if (typeof fn.arguments === 'string') current.function.arguments += fn.arguments;
748
- }
749
- }
750
-
751
- function writeChatCompletionChunkAsResponsesSse(state, chunk) {
752
- if (!chunk || typeof chunk !== 'object') return;
753
- if (typeof chunk.model === 'string' && chunk.model) {
754
- state.model = chunk.model;
755
- }
756
- const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
757
- for (const choice of choices) {
758
- const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
759
- if (!delta) continue;
760
-
761
- if (typeof delta.content === 'string' && delta.content) {
762
- if (!state.messageItem) {
763
- state.messageItem = {
764
- id: `msg_${crypto.randomBytes(8).toString('hex')}`,
765
- type: 'message',
766
- role: 'assistant',
767
- content: [{ type: 'output_text', text: '' }]
768
- };
769
- state.output.push(state.messageItem);
770
- writeSse(state.res, 'response.output_item.added', {
771
- type: 'response.output_item.added',
772
- output_index: state.output.length - 1,
773
- item: state.messageItem
774
- });
775
- }
776
- state.messageText += delta.content;
777
- state.messageItem.content[0].text = state.messageText;
778
- writeSse(state.res, 'response.output_text.delta', {
779
- type: 'response.output_text.delta',
780
- item_id: state.messageItem.id,
781
- output_index: state.output.length - 1,
782
- content_index: 0,
783
- delta: delta.content,
784
- sequence_number: state.nextSeq()
785
- });
786
- }
787
-
788
- if (Array.isArray(delta.tool_calls)) {
789
- for (const toolCall of delta.tool_calls) {
790
- appendChatStreamToolCall(state.toolCalls, toolCall);
791
- }
792
- }
793
- }
794
- }
795
-
796
- function finishChatStreamResponsesSse(state) {
797
- if (!state || state.finished) return;
798
- state.finished = true;
799
-
800
- if (state.messageItem) {
801
- const outputIndex = state.output.indexOf(state.messageItem);
802
- writeSse(state.res, 'response.output_text.done', {
803
- type: 'response.output_text.done',
804
- item_id: state.messageItem.id,
805
- output_index: outputIndex,
806
- content_index: 0,
807
- text: state.messageText,
808
- sequence_number: state.nextSeq()
809
- });
810
- writeSse(state.res, 'response.output_item.done', {
811
- type: 'response.output_item.done',
812
- output_index: outputIndex,
813
- item: state.messageItem,
814
- sequence_number: state.nextSeq()
815
- });
816
- }
817
-
818
- for (const toolCall of state.toolCalls) {
819
- if (!toolCall) continue;
820
- const name = toolCall.function && typeof toolCall.function.name === 'string' ? toolCall.function.name : '';
821
- if (!name) continue;
822
- const item = {
823
- type: 'function_call',
824
- call_id: toolCall.id || `call_${crypto.randomBytes(8).toString('hex')}`,
825
- name,
826
- arguments: toolCall.function && typeof toolCall.function.arguments === 'string' ? toolCall.function.arguments : ''
827
- };
828
- const outputIndex = state.output.length;
829
- state.output.push(item);
830
- writeSse(state.res, 'response.output_item.added', {
831
- type: 'response.output_item.added',
832
- output_index: outputIndex,
833
- item
834
- });
835
- writeSse(state.res, 'response.output_item.done', {
836
- type: 'response.output_item.done',
837
- output_index: outputIndex,
838
- item,
839
- sequence_number: state.nextSeq()
840
- });
841
- }
842
-
843
- const response = ensureResponseMetadata({
844
- id: state.responseId,
845
- model: state.model,
846
- created_at: state.createdAt,
847
- status: 'completed',
848
- output: state.output,
849
- output_text: state.messageText
850
- });
851
- writeSse(state.res, 'response.completed', { type: 'response.completed', response });
852
- writeSse(state.res, 'done', '[DONE]');
853
- if (!state.res.writableEnded && !state.res.destroyed) {
854
- state.res.end();
855
- }
856
- }
857
-
858
- function failChatStreamResponsesSse(state, errorMessage) {
859
- if (!state || state.finished) return;
860
- state.finished = true;
861
- writeSse(state.res, 'response.failed', {
862
- type: 'response.failed',
863
- response: ensureResponseMetadata({
864
- id: state.responseId,
865
- model: state.model,
866
- created_at: state.createdAt,
867
- status: 'failed',
868
- output: state.output,
869
- output_text: state.messageText
870
- }),
871
- error: String(errorMessage || 'upstream stream failed')
872
- });
873
- writeSse(state.res, 'done', '[DONE]');
874
- if (!state.res.writableEnded && !state.res.destroyed) {
875
- state.res.end();
876
- }
877
- }
878
-
879
- function formatUpstreamStreamError(errorValue) {
880
- if (!errorValue) return 'upstream stream failed';
881
- if (typeof errorValue === 'string') return errorValue;
882
- if (typeof errorValue === 'object') {
883
- if (typeof errorValue.message === 'string' && errorValue.message) return errorValue.message;
884
- try { return JSON.stringify(errorValue); } catch (_) {}
885
- }
886
- return String(errorValue || 'upstream stream failed');
887
- }
888
-
889
- function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
890
- const parsed = new URL(targetUrl);
891
- const transport = parsed.protocol === 'https:' ? https : http;
892
- const bodyText = options.body ? JSON.stringify(options.body) : '';
893
- const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
894
- ? Math.floor(options.maxBytes)
895
- : 0;
896
- const headers = {
897
- 'Accept': 'text/event-stream',
898
- ...(options.body ? { 'Content-Type': 'application/json' } : {}),
899
- ...(options.headers || {})
900
- };
901
- if (options.body) {
902
- headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
903
- }
904
- const timeoutMs = Number.isFinite(options.timeoutMs)
905
- ? Math.max(1000, Number(options.timeoutMs))
906
- : 30000;
907
- const res = options.res;
908
- const fallbackModel = typeof options.model === 'string' ? options.model : '';
909
-
910
- return new Promise((resolve) => {
911
- let settled = false;
912
- let upstreamReq = null;
913
- const finish = (value) => {
914
- if (settled) return;
915
- settled = true;
916
- resolve(value);
917
- };
918
- const abortUpstream = () => {
919
- if (upstreamReq) {
920
- try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {}
921
- }
922
- };
923
- if (res && typeof res.once === 'function') {
924
- res.once('close', abortUpstream);
925
- }
926
-
927
- upstreamReq = transport.request({
928
- protocol: parsed.protocol,
929
- hostname: parsed.hostname,
930
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
931
- method: options.method || 'POST',
932
- path: `${parsed.pathname}${parsed.search}`,
933
- headers,
934
- agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
935
- }, (upstreamRes) => {
936
- const status = upstreamRes.statusCode || 0;
937
- const chunks = [];
938
- let size = 0;
939
- const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
940
-
941
- const collectChunk = (chunk) => {
942
- if (!chunk) return true;
943
- if (maxBytes > 0) {
944
- size += chunk.length;
945
- if (size > maxBytes) {
946
- chunks.length = 0;
947
- try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
948
- try { upstreamReq.destroy(new Error('response too large')); } catch (_) {}
949
- finish({ ok: false, status, error: 'response too large' });
950
- return false;
951
- }
952
- }
953
- chunks.push(chunk);
954
- return true;
955
- };
956
-
957
- if (status >= 400) {
958
- upstreamRes.on('data', collectChunk);
959
- upstreamRes.on('end', () => finish({
960
- ok: false,
961
- status,
962
- bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
963
- }));
964
- return;
965
- }
966
-
967
- if (!res.headersSent) {
968
- res.writeHead(200, {
969
- 'Content-Type': 'text/event-stream; charset=utf-8',
970
- 'Cache-Control': 'no-cache',
971
- 'Connection': 'keep-alive',
972
- 'X-Accel-Buffering': 'no'
973
- });
974
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
975
- }
976
-
977
- if (!/text\/event-stream/i.test(contentType)) {
978
- upstreamRes.on('data', collectChunk);
979
- upstreamRes.on('end', () => {
980
- const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
981
- const parsedJson = parseJsonOrError(text);
982
- if (parsedJson.error) {
983
- writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
984
- writeSse(res, 'done', '[DONE]');
985
- if (!res.writableEnded && !res.destroyed) res.end();
986
- finish({ ok: true });
987
- return;
988
- }
989
- const extracted = extractChatCompletionResult(parsedJson.value);
990
- sendResponsesSse(res, buildResponsesPayloadFromChatResult(fallbackModel, extracted.text, extracted.toolCalls, parsedJson.value));
991
- if (!res.writableEnded && !res.destroyed) res.end();
992
- finish({ ok: true });
993
- });
994
- return;
995
- }
996
-
997
- let sequence = 0;
998
- const state = {
999
- res,
1000
- responseId: `resp_${crypto.randomBytes(10).toString('hex')}`,
1001
- model: fallbackModel,
1002
- createdAt: Math.floor(Date.now() / 1000),
1003
- output: [],
1004
- messageItem: null,
1005
- messageText: '',
1006
- toolCalls: [],
1007
- finished: false,
1008
- sawDone: false,
1009
- nextSeq: () => {
1010
- sequence += 1;
1011
- return sequence;
1012
- }
1013
- };
1014
- writeSse(res, 'response.created', {
1015
- type: 'response.created',
1016
- response: {
1017
- id: state.responseId,
1018
- model: state.model,
1019
- created_at: state.createdAt
1020
- }
1021
- });
1022
-
1023
- let buffer = '';
1024
- const handleEventBlock = (block) => {
1025
- const dataLines = String(block || '')
1026
- .split(/\r?\n/)
1027
- .filter((line) => line.startsWith('data:'))
1028
- .map((line) => line.slice(5).trimStart());
1029
- if (dataLines.length === 0) return;
1030
- const data = dataLines.join('\n').trim();
1031
- if (!data) return;
1032
- if (data === '[DONE]') {
1033
- state.sawDone = true;
1034
- finishChatStreamResponsesSse(state);
1035
- finish({ ok: true });
1036
- return;
1037
- }
1038
- const parsedChunk = parseJsonOrError(data);
1039
- if (!parsedChunk.error) {
1040
- if (parsedChunk.value && typeof parsedChunk.value === 'object' && parsedChunk.value.error) {
1041
- failChatStreamResponsesSse(state, formatUpstreamStreamError(parsedChunk.value.error));
1042
- finish({ ok: true });
1043
- return;
1044
- }
1045
- writeChatCompletionChunkAsResponsesSse(state, parsedChunk.value);
1046
- }
1047
- };
1048
-
1049
- upstreamRes.on('data', (chunk) => {
1050
- if (!chunk) return;
1051
- buffer += chunk.toString('utf-8');
1052
- let boundary = buffer.search(/\r?\n\r?\n/);
1053
- while (boundary >= 0) {
1054
- const block = buffer.slice(0, boundary);
1055
- const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
1056
- buffer = buffer.slice(boundary + (match ? match[0].length : 2));
1057
- handleEventBlock(block);
1058
- boundary = buffer.search(/\r?\n\r?\n/);
1059
- }
1060
- });
1061
- upstreamRes.on('end', () => {
1062
- if (buffer.trim()) handleEventBlock(buffer);
1063
- if (!state.finished && !state.sawDone) {
1064
- failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]');
1065
- finish({ ok: true });
1066
- return;
1067
- }
1068
- finishChatStreamResponsesSse(state);
1069
- finish({ ok: true });
1070
- });
1071
- upstreamRes.on('aborted', () => {
1072
- failChatStreamResponsesSse(state, 'upstream stream aborted');
1073
- finish({ ok: true });
1074
- });
1075
- upstreamRes.on('error', (err) => {
1076
- failChatStreamResponsesSse(state, err && err.message ? err.message : 'upstream stream failed');
1077
- finish({ ok: true });
1078
- });
1079
- });
1080
- upstreamReq.setTimeout(timeoutMs, () => {
1081
- try { upstreamReq.destroy(new Error('timeout')); } catch (_) {}
1082
- finish({ ok: false, error: 'timeout' });
1083
- });
1084
- upstreamReq.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
1085
- if (bodyText) upstreamReq.write(bodyText);
1086
- upstreamReq.end();
1087
- });
1088
- }
1089
-
1090
- async function proxyRequestJson(targetUrl, options = {}) {
1091
- const parsed = new URL(targetUrl);
1092
- const transport = parsed.protocol === 'https:' ? https : http;
1093
- const bodyText = options.body ? JSON.stringify(options.body) : '';
1094
- const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
1095
- ? Math.floor(options.maxBytes)
1096
- : 0;
1097
- const headers = {
1098
- 'Accept': 'application/json',
1099
- ...(options.body ? { 'Content-Type': 'application/json' } : {}),
1100
- ...(options.headers || {})
1101
- };
1102
- if (options.body) {
1103
- headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
1104
- }
1105
-
1106
- const timeoutMs = Number.isFinite(options.timeoutMs)
1107
- ? Math.max(1000, Number(options.timeoutMs))
1108
- : 30000;
1109
- return new Promise((resolve) => {
1110
- let settled = false;
1111
- const finish = (value) => {
1112
- if (settled) return;
1113
- settled = true;
1114
- resolve(value);
1115
- };
1116
- const req = transport.request({
1117
- protocol: parsed.protocol,
1118
- hostname: parsed.hostname,
1119
- port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
1120
- method: options.method || 'GET',
1121
- path: `${parsed.pathname}${parsed.search}`,
1122
- headers,
1123
- agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
1124
- }, (upstreamRes) => {
1125
- const chunks = [];
1126
- let size = 0;
1127
- upstreamRes.on('data', (chunk) => {
1128
- if (!chunk) return;
1129
- if (maxBytes > 0) {
1130
- size += chunk.length;
1131
- if (size > maxBytes) {
1132
- chunks.length = 0;
1133
- try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
1134
- try { req.destroy(new Error('response too large')); } catch (_) {}
1135
- finish({ ok: false, error: 'response too large' });
1136
- return;
1137
- }
1138
- }
1139
- chunks.push(chunk);
1140
- });
1141
- upstreamRes.on('end', () => {
1142
- const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
1143
- finish({
1144
- ok: true,
1145
- status: upstreamRes.statusCode || 0,
1146
- headers: upstreamRes.headers || {},
1147
- bodyText: text
1148
- });
1149
- });
1150
- });
1151
- req.setTimeout(timeoutMs, () => {
1152
- try { req.destroy(new Error('timeout')); } catch (_) {}
1153
- finish({ ok: false, error: 'timeout' });
1154
- });
1155
- req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
1156
- if (bodyText) {
1157
- req.write(bodyText);
1158
- }
1159
- req.end();
1160
- });
1161
- }
1162
-
1163
- function createOpenaiBridgeHttpHandler(options = {}) {
1164
- const settingsFile = options.settingsFile;
1165
- const expectedTokenRaw = typeof options.expectedToken === 'string' ? options.expectedToken.trim() : '';
1166
- const expectedToken = Object.prototype.hasOwnProperty.call(options, 'expectedToken')
1167
- ? expectedTokenRaw
1168
- : (expectedTokenRaw || DEFAULT_BRIDGE_TOKEN);
1169
- const maxBodySize = Number.isFinite(options.maxBodySize) ? options.maxBodySize : 0;
1170
- const httpAgent = options.httpAgent;
1171
- const httpsAgent = options.httpsAgent;
1172
- const maxUpstreamBytes = Number.isFinite(options.maxUpstreamBytes) && options.maxUpstreamBytes > 0
1173
- ? Math.floor(options.maxUpstreamBytes)
1174
- : Math.max(16 * 1024 * 1024, maxBodySize > 0 ? maxBodySize * 4 : 0);
1175
-
1176
- if (!settingsFile) {
1177
- throw new Error('createOpenaiBridgeHttpHandler 缺少 settingsFile');
1178
- }
1179
-
1180
- const matchPath = (requestPath) => {
1181
- const normalized = String(requestPath || '');
1182
- const prefix = '/bridge/openai/';
1183
- if (!normalized.startsWith(prefix)) return null;
1184
- const rest = normalized.slice(prefix.length);
1185
- const [provider, ...tail] = rest.split('/').filter((part) => part.length > 0);
1186
- if (!provider) return null;
1187
- const tailPath = '/' + tail.join('/');
1188
- if (!tailPath.startsWith('/v1')) return null;
1189
- const suffix = tailPath === '/v1' ? '' : tailPath.replace(/^\/v1\/?/, '');
1190
- return { provider, suffix };
1191
- };
1192
-
1193
- const handler = (req, res) => {
1194
- let parsedUrl;
1195
- try {
1196
- parsedUrl = new URL(req.url || '/', 'http://localhost');
1197
- } catch (_) {
1198
- return false;
1199
- }
1200
- const match = matchPath(parsedUrl.pathname || '/');
1201
- if (!match) return false;
1202
-
1203
- void (async () => {
1204
- try {
1205
- const token = extractAuthorizationToken(req);
1206
- // 兼容:某些客户端在自定义 base_url 时可能不带 Authorization。
1207
- // 为避免在 LAN 暴露无鉴权的代理,这里仅允许 loopback 连接缺省 token。
1208
- const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
1209
- const isLoopback = isLoopbackAddress(remoteAddr);
1210
- if (!isLoopback && !expectedToken) {
1211
- res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
1212
- res.end(JSON.stringify({ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)' }));
1213
- return;
1214
- }
1215
- if (!token && !isLoopback) {
1216
- res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
1217
- res.end(JSON.stringify({ error: 'Unauthorized' }));
1218
- return;
1219
- }
1220
- // loopback 上的本地代理:允许客户端携带任意 Authorization(例如 Codex 会附带 provider apiKey)。
1221
- // loopback 时仍强制校验 expectedToken,避免局域网被未授权调用。
1222
- if (!isLoopback && token && token !== expectedToken) {
1223
- res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
1224
- res.end(JSON.stringify({ error: 'Unauthorized' }));
1225
- return;
1226
- }
1227
-
1228
- const upstream = resolveOpenaiBridgeUpstream(settingsFile, match.provider);
1229
- if (upstream.error) {
1230
- res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
1231
- res.end(JSON.stringify({ error: upstream.error }));
1232
- return;
1233
- }
1234
-
1235
- const suffix = match.suffix || '';
1236
- const normalizedSuffix = suffix.replace(/^\/+/, '');
1237
-
1238
- const authHeader = upstream.apiKey
1239
- ? (/^bearer\s+/i.test(upstream.apiKey) ? upstream.apiKey : `Bearer ${upstream.apiKey}`)
1240
- : '';
1241
- const upstreamHeaders = upstream && upstream.headers && typeof upstream.headers === 'object' && !Array.isArray(upstream.headers)
1242
- ? upstream.headers
1243
- : {};
1244
-
1245
- if (!normalizedSuffix) {
1246
- if ((req.method || 'GET').toUpperCase() !== 'GET') {
1247
- res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
1248
- res.end(JSON.stringify({ error: 'Method Not Allowed' }));
1249
- return;
1250
- }
1251
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1252
- res.end(JSON.stringify({
1253
- object: 'codexmate.openai_bridge',
1254
- provider: match.provider,
1255
- status: 'ok',
1256
- endpoints: ['/v1/responses', '/v1/models']
1257
- }));
1258
- return;
1259
- }
1260
-
1261
- if (normalizedSuffix === 'models') {
1262
- if ((req.method || 'GET').toUpperCase() !== 'GET') {
1263
- res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
1264
- res.end(JSON.stringify({ error: 'Method Not Allowed' }));
1265
- return;
1266
- }
1267
-
1268
- const url = joinApiUrl(upstream.baseUrl, 'models');
1269
- const result = await proxyRequestJson(url, {
1270
- method: 'GET',
1271
- headers: {
1272
- ...(authHeader ? { Authorization: authHeader } : {}),
1273
- ...upstreamHeaders
1274
- },
1275
- maxBytes: maxUpstreamBytes,
1276
- httpAgent,
1277
- httpsAgent
1278
- });
1279
- if (!result.ok) {
1280
- res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1281
- res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` }));
1282
- return;
1283
- }
1284
- res.writeHead(result.status || 502, { 'Content-Type': 'application/json; charset=utf-8' });
1285
- res.end(result.bodyText || '');
1286
- return;
1287
- }
1288
-
1289
- if (normalizedSuffix !== 'responses') {
1290
- res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
1291
- res.end(JSON.stringify({ error: 'Not Found' }));
1292
- return;
1293
- }
1294
-
1295
- if ((req.method || 'GET').toUpperCase() !== 'POST') {
1296
- res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
1297
- res.end(JSON.stringify({ error: 'Method Not Allowed' }));
1298
- return;
1299
- }
1300
-
1301
- const { body, error: bodyErr } = await readRequestBody(req, maxBodySize);
1302
- if (bodyErr) {
1303
- res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
1304
- res.end(JSON.stringify({ error: bodyErr }));
1305
- return;
1306
- }
1307
- const parsed = parseJsonOrError(body);
1308
- if (parsed.error) {
1309
- res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1310
- res.end(JSON.stringify({ error: `Invalid JSON: ${parsed.error}` }));
1311
- return;
1312
- }
1313
-
1314
- const responsesRequest = parsed.value;
1315
- const streamRequested = !!(responsesRequest && typeof responsesRequest === 'object' && responsesRequest.stream === true);
1316
- const acceptHeader = req && req.headers ? (req.headers.accept || req.headers.Accept || '') : '';
1317
- const wantsSse = /text\/event-stream/i.test(String(acceptHeader || ''));
1318
-
1319
- if (streamRequested && wantsSse) {
1320
- const converted = convertResponsesRequestToChatCompletions(responsesRequest);
1321
- if (converted.error) {
1322
- res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1323
- res.end(JSON.stringify({ error: converted.error }));
1324
- return;
1325
- }
1326
- const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
1327
- const chatBody = { ...converted.chat, stream: true };
1328
- const streamed = await streamChatCompletionsAsResponsesSse(upstreamUrl, {
1329
- method: 'POST',
1330
- body: chatBody,
1331
- headers: {
1332
- ...(authHeader ? { Authorization: authHeader } : {}),
1333
- ...upstreamHeaders
1334
- },
1335
- maxBytes: maxUpstreamBytes,
1336
- httpAgent,
1337
- httpsAgent,
1338
- res,
1339
- model: typeof chatBody.model === 'string' ? chatBody.model : ''
1340
- });
1341
- if (!streamed.ok) {
1342
- if (res.writableEnded || res.destroyed) {
1343
- return;
1344
- }
1345
- if (!res.headersSent) {
1346
- res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' });
1347
- res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'Upstream request failed' }));
1348
- } else if (!res.writableEnded && !res.destroyed) {
1349
- writeSse(res, 'response.failed', { type: 'response.failed', error: streamed.error || streamed.bodyText || 'Upstream request failed' });
1350
- writeSse(res, 'done', '[DONE]');
1351
- res.end();
1352
- }
1353
- }
1354
- return;
1355
- }
1356
-
1357
- // Maxx-style behavior: prefer upstream /responses if supported.
1358
- // Fallback to /chat/completions conversion when upstream does not implement /responses (404/405).
1359
- const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
1360
- const upstreamResponsesResult = await proxyRequestJson(upstreamResponsesUrl, {
1361
- method: 'POST',
1362
- body: toUpstreamNonStreamingResponsesPayload(responsesRequest),
1363
- headers: {
1364
- ...(authHeader ? { Authorization: authHeader } : {}),
1365
- ...upstreamHeaders
1366
- },
1367
- maxBytes: maxUpstreamBytes,
1368
- httpAgent,
1369
- httpsAgent
1370
- });
1371
-
1372
- if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) {
1373
- const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText);
1374
- if (upstreamJson.error) {
1375
- res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1376
- res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
1377
- return;
1378
- }
1379
- const upstreamPayload = upstreamJson.value;
1380
- if (streamRequested && wantsSse) {
1381
- res.writeHead(200, {
1382
- 'Content-Type': 'text/event-stream; charset=utf-8',
1383
- 'Cache-Control': 'no-cache',
1384
- 'Connection': 'keep-alive',
1385
- 'X-Accel-Buffering': 'no'
1386
- });
1387
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
1388
- sendResponsesSse(res, upstreamPayload);
1389
- res.end();
1390
- return;
1391
- }
1392
-
1393
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1394
- res.end(JSON.stringify(ensureResponseMetadata(upstreamPayload)));
1395
- return;
1396
- }
1397
-
1398
- if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 400) {
1399
- if (!shouldFallbackFromUpstreamResponses(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
1400
- res.writeHead(upstreamResponsesResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
1401
- res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
1402
- return;
1403
- }
1404
- // fallthrough to chat/completions conversion
1405
- }
1406
-
1407
- if (!upstreamResponsesResult.ok) {
1408
- res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1409
- res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResponsesResult.error}` }));
1410
- return;
1411
- }
1412
-
1413
- const converted = convertResponsesRequestToChatCompletions(responsesRequest);
1414
- if (converted.error) {
1415
- res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1416
- res.end(JSON.stringify({ error: converted.error }));
1417
- return;
1418
- }
1419
-
1420
- const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
1421
- const upstreamResult = await proxyRequestJson(upstreamUrl, {
1422
- method: 'POST',
1423
- body: converted.chat,
1424
- headers: {
1425
- ...(authHeader ? { Authorization: authHeader } : {}),
1426
- ...upstreamHeaders
1427
- },
1428
- maxBytes: maxUpstreamBytes,
1429
- httpAgent,
1430
- httpsAgent
1431
- });
1432
- if (!upstreamResult.ok) {
1433
- res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1434
- res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));
1435
- return;
1436
- }
1437
-
1438
- const upstreamJson = parseJsonOrError(upstreamResult.bodyText);
1439
- if (upstreamResult.status >= 400) {
1440
- res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
1441
- res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
1442
- return;
1443
- }
1444
- if (upstreamJson.error) {
1445
- res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1446
- res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
1447
- return;
1448
- }
1449
-
1450
- const model = typeof converted.chat.model === 'string' ? converted.chat.model : '';
1451
- const extracted = extractChatCompletionResult(upstreamJson.value);
1452
- const text = extracted && typeof extracted.text === 'string' ? extracted.text : '';
1453
- const toolCalls = extracted && Array.isArray(extracted.toolCalls) ? extracted.toolCalls : [];
1454
- const responsesPayload = buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamJson.value);
1455
-
1456
- if (converted.streamRequested && wantsSse) {
1457
- res.writeHead(200, {
1458
- 'Content-Type': 'text/event-stream; charset=utf-8',
1459
- 'Cache-Control': 'no-cache',
1460
- 'Connection': 'keep-alive',
1461
- 'X-Accel-Buffering': 'no'
1462
- });
1463
- if (typeof res.flushHeaders === 'function') res.flushHeaders();
1464
- sendResponsesSse(res, responsesPayload);
1465
- res.end();
1466
- return;
1467
- }
1468
-
1469
- res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1470
- res.end(JSON.stringify(ensureResponseMetadata(responsesPayload)));
1471
- } catch (e) {
1472
- res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1473
- res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' }));
1474
- }
1475
- })();
1476
-
1477
- return true;
1478
- };
1479
-
1480
- handler.matchPath = matchPath;
1481
- return handler;
1482
- }
1483
-
1484
- module.exports = {
1485
- readOpenaiBridgeSettings,
1486
- upsertOpenaiBridgeProvider,
1487
- resolveOpenaiBridgeUpstream,
1488
- createOpenaiBridgeHttpHandler
1489
- };
1
+ const http = require('http');
2
+ const https = require('https');
3
+ const crypto = require('crypto');
4
+ const { readJsonFile, writeJsonAtomic } = require('../lib/cli-file-utils');
5
+ const { isValidHttpUrl, normalizeBaseUrl, joinApiUrl } = require('../lib/cli-utils');
6
+
7
+ const DEFAULT_BRIDGE_TOKEN = 'codexmate';
8
+ const SETTINGS_VERSION = 1;
9
+
10
+ function normalizeText(value) {
11
+ return typeof value === 'string' ? value.trim() : '';
12
+ }
13
+
14
+ function normalizeProviderName(value) {
15
+ // Provider name validation is done elsewhere; keep this conservative.
16
+ return normalizeText(value);
17
+ }
18
+
19
+ function normalizeOpenaiUpstreamBaseUrl(rawValue) {
20
+ const normalized = normalizeBaseUrl(rawValue);
21
+ if (!normalized) return '';
22
+ try {
23
+ const parsed = new URL(normalized);
24
+ let pathname = String(parsed.pathname || '').replace(/\/+$/g, '');
25
+
26
+ // If user accidentally pasted a full endpoint, strip it back to the base URL.
27
+ // Keep direct provider routes (e.g. /project/ym) intact.
28
+ pathname = pathname
29
+ .replace(/\/v1\/chat\/completions$/i, '/v1')
30
+ .replace(/\/chat\/completions$/i, '')
31
+ .replace(/\/v1\/responses$/i, '/v1')
32
+ .replace(/\/responses$/i, '')
33
+ .replace(/\/v1\/models$/i, '/v1')
34
+ .replace(/\/models$/i, '');
35
+
36
+ // Normalize empty/root path.
37
+ if (pathname === '/') pathname = '';
38
+
39
+ const rebuilt = `${parsed.origin}${pathname}`;
40
+ return normalizeBaseUrl(rebuilt);
41
+ } catch (_) {
42
+ return normalized;
43
+ }
44
+ }
45
+
46
+ function normalizeUpstreamEntry(entry) {
47
+ if (!entry || typeof entry !== 'object' || Array.isArray(entry)) {
48
+ return null;
49
+ }
50
+ const baseUrl = normalizeOpenaiUpstreamBaseUrl(entry.baseUrl || entry.base_url || '');
51
+ const apiKey = normalizeText(entry.apiKey || entry.api_key || entry.key || '');
52
+ const headersRaw = entry.headers || entry.extraHeaders || entry.extra_headers || null;
53
+ const headers = normalizeHeadersMap(headersRaw);
54
+ if (!baseUrl || !isValidHttpUrl(baseUrl)) {
55
+ return null;
56
+ }
57
+ return { baseUrl, apiKey, headers };
58
+ }
59
+
60
+ function normalizeHeadersMap(value) {
61
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
62
+ return {};
63
+ }
64
+ const forbidden = new Set([
65
+ 'authorization',
66
+ 'host',
67
+ 'content-length',
68
+ 'connection',
69
+ 'transfer-encoding',
70
+ 'keep-alive',
71
+ 'proxy-authenticate',
72
+ 'proxy-authorization',
73
+ 'te',
74
+ 'trailer',
75
+ 'upgrade'
76
+ ]);
77
+ const result = {};
78
+ for (const [rawKey, rawVal] of Object.entries(value)) {
79
+ const key = typeof rawKey === 'string' ? rawKey.trim() : '';
80
+ if (!key) continue;
81
+ const lower = key.toLowerCase();
82
+ if (forbidden.has(lower)) continue;
83
+ if (typeof rawVal !== 'string') continue;
84
+ result[key] = rawVal;
85
+ }
86
+ return result;
87
+ }
88
+
89
+ function readOpenaiBridgeSettings(filePath) {
90
+ const parsed = readJsonFile(filePath, null);
91
+ const providers = parsed && typeof parsed === 'object' && !Array.isArray(parsed)
92
+ ? parsed.providers
93
+ : null;
94
+ const providerMap = providers && typeof providers === 'object' && !Array.isArray(providers)
95
+ ? providers
96
+ : {};
97
+ return {
98
+ version: SETTINGS_VERSION,
99
+ providers: providerMap
100
+ };
101
+ }
102
+
103
+ function upsertOpenaiBridgeProvider(filePath, providerName, upstreamBaseUrl, apiKey, headers) {
104
+ const name = normalizeProviderName(providerName);
105
+ const baseUrl = normalizeOpenaiUpstreamBaseUrl(upstreamBaseUrl);
106
+ const key = normalizeText(apiKey);
107
+ const nextHeaders = normalizeHeadersMap(headers);
108
+
109
+ if (!name) {
110
+ return { error: 'Provider name is required' };
111
+ }
112
+ if (!baseUrl || !isValidHttpUrl(baseUrl)) {
113
+ return { error: 'Upstream base URL is invalid' };
114
+ }
115
+
116
+ const settings = readOpenaiBridgeSettings(filePath);
117
+ const existing = settings && settings.providers ? settings.providers[name] : null;
118
+ const existingHeaders = existing && typeof existing === 'object' && !Array.isArray(existing)
119
+ ? normalizeHeadersMap(existing.headers || existing.extraHeaders || existing.extra_headers || null)
120
+ : {};
121
+ const next = {
122
+ version: SETTINGS_VERSION,
123
+ providers: {
124
+ ...(settings.providers || {}),
125
+ [name]: {
126
+ baseUrl,
127
+ apiKey: key,
128
+ headers: Object.keys(nextHeaders).length ? nextHeaders : existingHeaders
129
+ }
130
+ }
131
+ };
132
+ writeJsonAtomic(filePath, next);
133
+ return { success: true };
134
+ }
135
+
136
+ function resolveOpenaiBridgeUpstream(filePath, providerName) {
137
+ const name = normalizeProviderName(providerName);
138
+ if (!name) return { error: 'Provider name is required' };
139
+ const settings = readOpenaiBridgeSettings(filePath);
140
+ const entry = settings.providers ? settings.providers[name] : null;
141
+ const normalized = normalizeUpstreamEntry(entry);
142
+ if (!normalized) {
143
+ return { error: `OpenAI 转换未配置: ${name}` };
144
+ }
145
+ return { provider: name, ...normalized };
146
+ }
147
+
148
+ function extractAuthorizationToken(req) {
149
+ const header = typeof req.headers.authorization === 'string' ? req.headers.authorization.trim() : '';
150
+ if (!header) return '';
151
+ if (/^bearer\s+/i.test(header)) {
152
+ return header.replace(/^bearer\s+/i, '').trim();
153
+ }
154
+ return header;
155
+ }
156
+
157
+ function readRequestBody(req, maxBytes) {
158
+ return new Promise((resolve) => {
159
+ let body = '';
160
+ let size = 0;
161
+ let aborted = false;
162
+ req.on('data', (chunk) => {
163
+ if (aborted) return;
164
+ size += chunk.length;
165
+ if (Number.isFinite(maxBytes) && maxBytes > 0 && size > maxBytes) {
166
+ aborted = true;
167
+ try { req.destroy(); } catch (_) {}
168
+ resolve({ error: '请求体过大' });
169
+ return;
170
+ }
171
+ body += chunk;
172
+ });
173
+ req.on('end', () => {
174
+ if (aborted) return;
175
+ resolve({ body });
176
+ });
177
+ req.on('error', (err) => resolve({ error: err && err.message ? err.message : 'request failed' }));
178
+ });
179
+ }
180
+
181
+ function parseJsonOrError(text) {
182
+ if (typeof text !== 'string' || !text.trim()) {
183
+ return { value: null, error: 'empty body' };
184
+ }
185
+ try {
186
+ return { value: JSON.parse(text), error: '' };
187
+ } catch (e) {
188
+ return { value: null, error: e && e.message ? e.message : 'invalid json' };
189
+ }
190
+ }
191
+
192
+ function extractChatCompletionResult(payload) {
193
+ if (!payload || typeof payload !== 'object') return { text: '', toolCalls: [] };
194
+ const choice = Array.isArray(payload.choices) ? payload.choices[0] : null;
195
+ const message = choice && typeof choice === 'object' ? choice.message : null;
196
+ const toolCalls = message && typeof message === 'object' && Array.isArray(message.tool_calls)
197
+ ? message.tool_calls
198
+ : [];
199
+ const content = message && typeof message === 'object' ? message.content : '';
200
+ let text = '';
201
+ if (typeof content === 'string') {
202
+ text = content;
203
+ } else if (Array.isArray(content)) {
204
+ text = content
205
+ .map((item) => {
206
+ if (!item) return '';
207
+ if (typeof item === 'string') return item;
208
+ if (typeof item === 'object') {
209
+ if (typeof item.text === 'string') return item.text;
210
+ if (typeof item.content === 'string') return item.content;
211
+ }
212
+ return '';
213
+ })
214
+ .filter(Boolean)
215
+ .join('');
216
+ }
217
+ return { text, toolCalls };
218
+ }
219
+
220
+ function normalizeResponsesInputToChatMessages(input) {
221
+ // 支持:
222
+ // - string
223
+ // - { role, content }(单条 message)
224
+ // - { type:"input_text"|"input_image", ... }(单个 block)
225
+ // - [{ role, content: [...] }](messages array)
226
+ // - [{ type:"input_text"|"input_image", ... }](blocks array -> 单条 user 消息)
227
+ if (typeof input === 'string') {
228
+ return [{ role: 'user', content: input }];
229
+ }
230
+
231
+ const toChatContent = (blocks) => {
232
+ if (!Array.isArray(blocks)) return '';
233
+ const out = [];
234
+ for (const block of blocks) {
235
+ if (!block || typeof block !== 'object') continue;
236
+ const type = typeof block.type === 'string' ? block.type : '';
237
+ if ((type === 'input_text' || type === 'output_text') && typeof block.text === 'string') {
238
+ out.push({ type: 'text', text: block.text });
239
+ continue;
240
+ }
241
+ if ((type === 'reasoning' || type === 'reasoning_text' || type === 'reasoning_content') && typeof block.text === 'string') {
242
+ out.push({ type: 'text', text: block.text });
243
+ continue;
244
+ }
245
+ if (type === 'input_image') {
246
+ const raw = block.image_url != null ? block.image_url : block.imageUrl;
247
+ const url = typeof raw === 'string'
248
+ ? raw
249
+ : (raw && typeof raw === 'object' && typeof raw.url === 'string' ? raw.url : '');
250
+ if (url) {
251
+ out.push({ type: 'image_url', image_url: { url } });
252
+ }
253
+ continue;
254
+ }
255
+ // 容错:兼容已是 chat content 的 {type:"text"} / {type:"image_url"}
256
+ if (type === 'text' && typeof block.text === 'string') {
257
+ out.push({ type: 'text', text: block.text });
258
+ continue;
259
+ }
260
+ if (type === 'image_url' && block.image_url) {
261
+ out.push({ type: 'image_url', image_url: block.image_url });
262
+ continue;
263
+ }
264
+ const text = typeof block.text === 'string'
265
+ ? block.text
266
+ : (typeof block.content === 'string' ? block.content : '');
267
+ if (text) {
268
+ out.push({ type: 'text', text });
269
+ continue;
270
+ }
271
+ try {
272
+ const raw = JSON.stringify(block);
273
+ if (raw) {
274
+ out.push({ type: 'text', text: raw.slice(0, 4000) });
275
+ }
276
+ } catch (_) {}
277
+ }
278
+ if (out.length === 0) return '';
279
+ return out;
280
+ };
281
+
282
+ const toRole = (value) => {
283
+ const roleRaw = typeof value === 'string' ? value.trim().toLowerCase() : '';
284
+ if (roleRaw === 'assistant') return 'assistant';
285
+ // codex 把 AGENTS.md 注入 developer 角色;Responses 的 developer 在 chat 侧等价于 system。
286
+ if (roleRaw === 'system' || roleRaw === 'developer') return 'system';
287
+ return 'user';
288
+ };
289
+
290
+ if (input && typeof input === 'object' && !Array.isArray(input)) {
291
+ if (typeof input.role === 'string' && input.content != null) {
292
+ const role = toRole(input.role);
293
+ const content = Array.isArray(input.content)
294
+ ? toChatContent(input.content)
295
+ : input.content;
296
+ return content ? [{ role, content }] : [];
297
+ }
298
+ if (typeof input.type === 'string') {
299
+ const content = toChatContent([input]);
300
+ return content ? [{ role: 'user', content }] : [];
301
+ }
302
+ return [];
303
+ }
304
+
305
+ if (!Array.isArray(input)) {
306
+ return [];
307
+ }
308
+
309
+ const messages = [];
310
+ for (const item of input) {
311
+ if (!item || typeof item !== 'object') continue;
312
+
313
+ // Tool calls (Responses): { type: "function_call", call_id, name, arguments }
314
+ // Chat Completions equivalent: assistant message with tool_calls
315
+ if (typeof item.type === 'string' && item.type === 'function_call') {
316
+ const callId = typeof item.call_id === 'string' ? item.call_id.trim() : '';
317
+ const name = typeof item.name === 'string' ? item.name.trim() : '';
318
+ const args = typeof item.arguments === 'string' ? item.arguments : '';
319
+ if (callId && name) {
320
+ messages.push({
321
+ role: 'assistant',
322
+ tool_calls: [{
323
+ id: callId,
324
+ type: 'function',
325
+ function: { name, arguments: args || '' }
326
+ }]
327
+ });
328
+ }
329
+ continue;
330
+ }
331
+
332
+ // Tool results (Responses): { type: "function_call_output", call_id, output }
333
+ // Chat Completions equivalent: { role: "tool", tool_call_id, content }
334
+ if (typeof item.type === 'string' && item.type === 'function_call_output') {
335
+ const toolCallId = typeof item.call_id === 'string' ? item.call_id.trim() : '';
336
+ let content = item.output;
337
+ if (typeof content !== 'string') {
338
+ try {
339
+ content = JSON.stringify(content);
340
+ } catch (_) {
341
+ content = String(content ?? '');
342
+ }
343
+ }
344
+ if (toolCallId) {
345
+ messages.push({ role: 'tool', tool_call_id: toolCallId, content: String(content || '') });
346
+ }
347
+ continue;
348
+ }
349
+
350
+ // message form
351
+ if (typeof item.role === 'string' && item.content != null) {
352
+ const role = toRole(item.role);
353
+ const content = Array.isArray(item.content)
354
+ ? toChatContent(item.content)
355
+ : item.content;
356
+ if (content) {
357
+ messages.push({ role, content });
358
+ }
359
+ continue;
360
+ }
361
+ }
362
+
363
+ if (messages.length > 0) {
364
+ return messages;
365
+ }
366
+
367
+ // 退化:把 input array 当作单条 user content blocks
368
+ const fallbackContent = toChatContent(input);
369
+ if (fallbackContent) {
370
+ return [{ role: 'user', content: fallbackContent }];
371
+ }
372
+ return [];
373
+ }
374
+
375
+
376
+ function normalizeResponsesToolsToChatTools(tools) {
377
+ if (!Array.isArray(tools)) return tools;
378
+ return tools
379
+ .map((tool) => {
380
+ if (!tool || typeof tool !== 'object') return null;
381
+ if (tool.type !== 'function') return null;
382
+ const sourceFn = tool.function && typeof tool.function === 'object' && !Array.isArray(tool.function)
383
+ ? tool.function
384
+ : {};
385
+ const name = typeof sourceFn.name === 'string' && sourceFn.name.trim()
386
+ ? sourceFn.name.trim()
387
+ : (typeof tool.name === 'string' ? tool.name.trim() : '');
388
+ if (!name) return null;
389
+ const parameters = sourceFn.parameters && typeof sourceFn.parameters === 'object' && !Array.isArray(sourceFn.parameters)
390
+ ? sourceFn.parameters
391
+ : (tool.parameters && typeof tool.parameters === 'object' && !Array.isArray(tool.parameters) ? tool.parameters : {});
392
+ const fn = { name, parameters };
393
+ const description = typeof sourceFn.description === 'string'
394
+ ? sourceFn.description
395
+ : (typeof tool.description === 'string' ? tool.description : undefined);
396
+ const strict = typeof sourceFn.strict === 'boolean'
397
+ ? sourceFn.strict
398
+ : (typeof tool.strict === 'boolean' ? tool.strict : undefined);
399
+ if (description !== undefined) fn.description = description;
400
+ if (strict !== undefined) fn.strict = strict;
401
+ return { type: 'function', function: fn };
402
+ })
403
+ .filter(Boolean);
404
+ }
405
+
406
+ function normalizeResponsesToolChoiceToChatToolChoice(toolChoice) {
407
+ if (!toolChoice || typeof toolChoice !== 'object' || Array.isArray(toolChoice)) return toolChoice;
408
+ if (toolChoice.type === 'function' && typeof toolChoice.name === 'string' && toolChoice.name.trim()) {
409
+ return { type: 'function', function: { name: toolChoice.name.trim() } };
410
+ }
411
+ return toolChoice;
412
+ }
413
+
414
+ function normalizeResponsesToolsForResponsesApi(tools) {
415
+ if (!Array.isArray(tools)) return tools;
416
+ return tools
417
+ .map((tool) => {
418
+ if (!tool || typeof tool !== 'object') return null;
419
+ if (tool.type !== 'function') return null;
420
+ const sourceFn = tool.function && typeof tool.function === 'object' && !Array.isArray(tool.function)
421
+ ? tool.function
422
+ : {};
423
+ const name = typeof sourceFn.name === 'string' && sourceFn.name.trim()
424
+ ? sourceFn.name.trim()
425
+ : (typeof tool.name === 'string' ? tool.name.trim() : '');
426
+ if (!name) return null;
427
+ const out = { type: 'function', name };
428
+ const description = typeof sourceFn.description === 'string'
429
+ ? sourceFn.description
430
+ : (typeof tool.description === 'string' ? tool.description : undefined);
431
+ const parameters = sourceFn.parameters && typeof sourceFn.parameters === 'object' && !Array.isArray(sourceFn.parameters)
432
+ ? sourceFn.parameters
433
+ : (tool.parameters && typeof tool.parameters === 'object' && !Array.isArray(tool.parameters) ? tool.parameters : undefined);
434
+ const strict = typeof sourceFn.strict === 'boolean'
435
+ ? sourceFn.strict
436
+ : (typeof tool.strict === 'boolean' ? tool.strict : undefined);
437
+ if (description !== undefined) out.description = description;
438
+ if (parameters !== undefined) out.parameters = parameters;
439
+ if (strict !== undefined) out.strict = strict;
440
+ return out;
441
+ })
442
+ .filter(Boolean);
443
+ }
444
+
445
+ function mergeLeadingSystemMessages(messages, leadingInstructions) {
446
+ const segments = [];
447
+ const seen = new Set();
448
+ const pushSegment = (text) => {
449
+ const trimmed = typeof text === 'string' ? text.trim() : '';
450
+ if (!trimmed || seen.has(trimmed)) return;
451
+ seen.add(trimmed);
452
+ segments.push(trimmed);
453
+ };
454
+ if (typeof leadingInstructions === 'string') {
455
+ pushSegment(leadingInstructions);
456
+ }
457
+ const rest = [];
458
+ for (const msg of messages) {
459
+ if (msg && msg.role === 'system') {
460
+ const content = msg.content;
461
+ if (typeof content === 'string') {
462
+ pushSegment(content);
463
+ } else if (Array.isArray(content)) {
464
+ for (const part of content) {
465
+ if (part && typeof part === 'object' && typeof part.text === 'string') {
466
+ pushSegment(part.text);
467
+ }
468
+ }
469
+ }
470
+ continue;
471
+ }
472
+ rest.push(msg);
473
+ }
474
+ const out = [];
475
+ if (segments.length) {
476
+ out.push({ role: 'system', content: segments.join('\n\n---\n\n') });
477
+ }
478
+ for (const msg of rest) out.push(msg);
479
+ return out;
480
+ }
481
+
482
+ function convertResponsesRequestToChatCompletions(payload) {
483
+ const body = payload && typeof payload === 'object' ? payload : {};
484
+ const model = typeof body.model === 'string' ? body.model.trim() : '';
485
+ if (!model) {
486
+ return { error: 'responses 请求缺少 model' };
487
+ }
488
+
489
+ const rawMessages = normalizeResponsesInputToChatMessages(body.input);
490
+ // codex 同时下发 body.instructions(内置 prompt)与 input 内 developer/system 消息(AGENTS.md)。
491
+ // 合流为一条领头 system,避免某些上游"只认第一条 system"导致 AGENTS.md 失效。
492
+ const messages = mergeLeadingSystemMessages(rawMessages, body.instructions);
493
+ if (!messages.length) {
494
+ // codex sometimes sends empty input for probes; tolerate.
495
+ messages.push({ role: 'user', content: '' });
496
+ }
497
+
498
+ const maxOutputTokens = Number.parseInt(String(body.max_output_tokens), 10);
499
+ const stream = body.stream === true;
500
+
501
+ const chat = {
502
+ model,
503
+ messages,
504
+ stream: false,
505
+ temperature: Number.isFinite(body.temperature) ? Number(body.temperature) : undefined,
506
+ top_p: Number.isFinite(body.top_p) ? Number(body.top_p) : undefined,
507
+ max_tokens: Number.isFinite(maxOutputTokens) && maxOutputTokens > 0 ? maxOutputTokens : undefined
508
+ };
509
+ if (Array.isArray(body.stop) && body.stop.length) {
510
+ chat.stop = body.stop.filter((item) => typeof item === 'string' && item.trim());
511
+ }
512
+ if (Array.isArray(body.tools) && body.tools.length) {
513
+ chat.tools = normalizeResponsesToolsToChatTools(body.tools);
514
+ }
515
+ if (body.tool_choice !== undefined) {
516
+ chat.tool_choice = normalizeResponsesToolChoiceToChatToolChoice(body.tool_choice);
517
+ }
518
+ if (body.response_format !== undefined) {
519
+ chat.response_format = body.response_format;
520
+ }
521
+ if (body.metadata !== undefined) {
522
+ chat.metadata = body.metadata;
523
+ }
524
+
525
+ // Remove undefined keys
526
+ Object.keys(chat).forEach((key) => chat[key] === undefined && delete chat[key]);
527
+
528
+ return { chat, streamRequested: stream };
529
+ }
530
+
531
+ function buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamPayload) {
532
+ const responseId = `resp_${crypto.randomBytes(10).toString('hex')}`;
533
+ const usage = upstreamPayload && upstreamPayload.usage && typeof upstreamPayload.usage === 'object'
534
+ ? upstreamPayload.usage
535
+ : null;
536
+ const createdAt = Math.floor(Date.now() / 1000);
537
+ const output = [];
538
+ const trimmedText = typeof text === 'string' ? text : '';
539
+ if (trimmedText) {
540
+ output.push({
541
+ id: `msg_${crypto.randomBytes(8).toString('hex')}`,
542
+ type: 'message',
543
+ role: 'assistant',
544
+ content: [{ type: 'output_text', text: trimmedText }]
545
+ });
546
+ }
547
+
548
+ // Convert chat.completions tool_calls into Responses-style function_call output items.
549
+ // This is important for Codex, which appends function_call + function_call_output back into `input`.
550
+ if (Array.isArray(toolCalls)) {
551
+ for (const call of toolCalls) {
552
+ if (!call || typeof call !== 'object') continue;
553
+ const callId = typeof call.id === 'string' && call.id.trim() ? call.id.trim() : `call_${crypto.randomBytes(8).toString('hex')}`;
554
+ const fn = call.function && typeof call.function === 'object' ? call.function : {};
555
+ const name = typeof fn.name === 'string' ? fn.name : '';
556
+ const args = typeof fn.arguments === 'string' ? fn.arguments : '';
557
+ if (!name) continue;
558
+ output.push({
559
+ type: 'function_call',
560
+ call_id: callId,
561
+ name,
562
+ arguments: args
563
+ });
564
+ }
565
+ }
566
+
567
+ const payload = {
568
+ id: responseId,
569
+ object: 'response',
570
+ model,
571
+ created_at: createdAt,
572
+ status: 'completed',
573
+ output,
574
+ output_text: trimmedText
575
+ };
576
+
577
+ if (usage) {
578
+ // Map chat.completions usage -> responses usage shape when possible.
579
+ const promptTokens = Number.isFinite(usage.prompt_tokens) ? Number(usage.prompt_tokens) : null;
580
+ const completionTokens = Number.isFinite(usage.completion_tokens) ? Number(usage.completion_tokens) : null;
581
+ const totalTokens = Number.isFinite(usage.total_tokens) ? Number(usage.total_tokens) : null;
582
+ if (promptTokens !== null || completionTokens !== null || totalTokens !== null) {
583
+ payload.usage = {
584
+ input_tokens: promptTokens ?? undefined,
585
+ output_tokens: completionTokens ?? undefined,
586
+ total_tokens: totalTokens ?? undefined
587
+ };
588
+ Object.keys(payload.usage).forEach((key) => payload.usage[key] === undefined && delete payload.usage[key]);
589
+ } else {
590
+ payload.usage = usage;
591
+ }
592
+ }
593
+
594
+ return payload;
595
+ }
596
+
597
+ function ensureResponseMetadata(response) {
598
+ const payload = response && typeof response === 'object' ? response : {};
599
+ if (typeof payload.object !== 'string' || !payload.object.trim()) {
600
+ payload.object = 'response';
601
+ }
602
+ if (typeof payload.created_at !== 'number') {
603
+ payload.created_at = Math.floor(Date.now() / 1000);
604
+ }
605
+ if (typeof payload.status !== 'string' || !payload.status.trim()) {
606
+ payload.status = 'completed';
607
+ }
608
+ if (!Array.isArray(payload.output)) {
609
+ payload.output = [];
610
+ }
611
+ return payload;
612
+ }
613
+
614
+ function sendResponsesSse(res, responsePayload) {
615
+ const response = ensureResponseMetadata(responsePayload);
616
+ const responseId = typeof response.id === 'string' && response.id.trim()
617
+ ? response.id.trim()
618
+ : `resp_${crypto.randomBytes(10).toString('hex')}`;
619
+ const model = typeof response.model === 'string' ? response.model : '';
620
+
621
+ let sequence = 0;
622
+ const nextSeq = () => {
623
+ sequence += 1;
624
+ return sequence;
625
+ };
626
+
627
+ writeSse(res, 'response.created', {
628
+ type: 'response.created',
629
+ response: {
630
+ id: responseId,
631
+ model,
632
+ created_at: response.created_at
633
+ }
634
+ });
635
+
636
+ const output = Array.isArray(response.output) ? response.output : [];
637
+ for (let outputIndex = 0; outputIndex < output.length; outputIndex += 1) {
638
+ const item = output[outputIndex];
639
+ if (!item || typeof item !== 'object') continue;
640
+ const itemType = typeof item.type === 'string' ? item.type : '';
641
+ const itemId = typeof item.id === 'string' && item.id.trim()
642
+ ? item.id.trim()
643
+ : (typeof item.call_id === 'string' && item.call_id.trim() ? item.call_id.trim() : `item_${crypto.randomBytes(8).toString('hex')}`);
644
+
645
+ // Emit item added so Codex can anchor subsequent deltas by output_index/content_index/item_id.
646
+ writeSse(res, 'response.output_item.added', {
647
+ type: 'response.output_item.added',
648
+ output_index: outputIndex,
649
+ item: { ...item, id: itemId }
650
+ });
651
+
652
+ if (itemType === 'message') {
653
+ const content = Array.isArray(item.content) ? item.content : [];
654
+ for (let contentIndex = 0; contentIndex < content.length; contentIndex += 1) {
655
+ const block = content[contentIndex];
656
+ if (!block || typeof block !== 'object') continue;
657
+ if (block.type !== 'output_text') continue;
658
+ const text = typeof block.text === 'string' ? block.text : '';
659
+ if (text) {
660
+ writeSse(res, 'response.output_text.delta', {
661
+ type: 'response.output_text.delta',
662
+ item_id: itemId,
663
+ output_index: outputIndex,
664
+ content_index: contentIndex,
665
+ delta: text,
666
+ sequence_number: nextSeq()
667
+ });
668
+ }
669
+ writeSse(res, 'response.output_text.done', {
670
+ type: 'response.output_text.done',
671
+ item_id: itemId,
672
+ output_index: outputIndex,
673
+ content_index: contentIndex,
674
+ text,
675
+ sequence_number: nextSeq()
676
+ });
677
+ }
678
+ }
679
+
680
+ // Emit item done for all item types (message/function_call/etc).
681
+ writeSse(res, 'response.output_item.done', {
682
+ type: 'response.output_item.done',
683
+ output_index: outputIndex,
684
+ item: { ...item, id: itemId },
685
+ sequence_number: nextSeq()
686
+ });
687
+ }
688
+
689
+ writeSse(res, 'response.completed', { type: 'response.completed', response });
690
+ writeSse(res, 'done', '[DONE]');
691
+ }
692
+
693
+ function extractResponsesOutputText(payload) {
694
+ if (!payload || typeof payload !== 'object') return '';
695
+ const output = Array.isArray(payload.output) ? payload.output : [];
696
+ for (const item of output) {
697
+ if (!item || typeof item !== 'object') continue;
698
+ if (item.type !== 'message') continue;
699
+ const content = Array.isArray(item.content) ? item.content : [];
700
+ for (const block of content) {
701
+ if (!block || typeof block !== 'object') continue;
702
+ if (block.type !== 'output_text') continue;
703
+ if (typeof block.text === 'string') return block.text;
704
+ }
705
+ }
706
+ if (typeof payload.output_text === 'string') return payload.output_text;
707
+ return '';
708
+ }
709
+
710
+ function toUpstreamNonStreamingResponsesPayload(payload) {
711
+ const body = payload && typeof payload === 'object' ? payload : {};
712
+ const normalized = { ...body, stream: false };
713
+ if (Array.isArray(body.tools)) {
714
+ normalized.tools = normalizeResponsesToolsForResponsesApi(body.tools);
715
+ }
716
+ return normalized;
717
+ }
718
+
719
+ function shouldFallbackFromUpstreamResponses(status, bodyText) {
720
+ if (!Number.isFinite(status)) return false;
721
+ // Common "unsupported" status codes for a route.
722
+ if (status === 404 || status === 405 || status === 501) return true;
723
+
724
+ // Some OpenAI-compatible gateways respond with 500 + "not implemented" (e.g. convert_request_failed)
725
+ // instead of 404/405 for unsupported endpoints. In that case we can safely fallback to chat/completions.
726
+ const text = String(bodyText || '');
727
+ if (!text) return false;
728
+ if (/not implemented/i.test(text)) return true;
729
+ if (/convert_request_failed/i.test(text)) return true;
730
+ if (/unknown (endpoint|route)/i.test(text)) return true;
731
+ if (/unsupported.*\/?v1\/responses/i.test(text)) return true;
732
+ if (/does not support.*responses/i.test(text)) return true;
733
+ if (/name['"`]?\s+is a required property/i.test(text) && /tools/i.test(text) && /function/i.test(text)) return true;
734
+
735
+ // Best-effort parse for structured error codes.
736
+ try {
737
+ const parsed = JSON.parse(text);
738
+ const code = parsed && parsed.error && typeof parsed.error.code === 'string' ? parsed.error.code : '';
739
+ const msg = parsed && parsed.error && typeof parsed.error.message === 'string' ? parsed.error.message : '';
740
+ if (code === 'convert_request_failed') return true;
741
+ if (/not implemented/i.test(msg)) return true;
742
+ if (/unknown (endpoint|route)/i.test(msg)) return true;
743
+ if (/unsupported.*\/?v1\/responses/i.test(msg)) return true;
744
+ if (/does not support.*responses/i.test(msg)) return true;
745
+ if (/name['"`]?\s+is a required property/i.test(msg) && /tools/i.test(msg) && /function/i.test(msg)) return true;
746
+ } catch (_) {}
747
+
748
+ return false;
749
+ }
750
+
751
+ function isLoopbackAddress(address) {
752
+ if (!address) return false;
753
+ const value = String(address);
754
+ return value === '127.0.0.1' || value === '::1' || value === '::ffff:127.0.0.1';
755
+ }
756
+
757
+ function isTransientNetworkError(error) {
758
+ const text = String(error || '').trim();
759
+ if (!text) return false;
760
+ if (/socket hang up/i.test(text)) return true;
761
+ if (/ECONNRESET|ECONNREFUSED|EPIPE|EPROTO|ETIMEDOUT/i.test(text)) return true;
762
+ if (/EAI_AGAIN/i.test(text)) return true;
763
+ if (/UND_ERR_SOCKET/i.test(text)) return true;
764
+ if (/disconnected before|secure tls|tls handshake/i.test(text)) return true;
765
+ return false;
766
+ }
767
+
768
+ const TRANSIENT_RETRY_DELAYS_MS = [200, 600];
769
+
770
+ async function retryTransientRequest(executor) {
771
+ let lastResult = null;
772
+ for (let attempt = 0; attempt <= TRANSIENT_RETRY_DELAYS_MS.length; attempt += 1) {
773
+ if (attempt > 0) {
774
+ const delay = TRANSIENT_RETRY_DELAYS_MS[attempt - 1];
775
+ // eslint-disable-next-line no-await-in-loop
776
+ await new Promise((r) => {
777
+ const t = setTimeout(r, delay);
778
+ if (typeof t.unref === 'function') t.unref();
779
+ });
780
+ }
781
+ // eslint-disable-next-line no-await-in-loop
782
+ const result = await executor(attempt);
783
+ lastResult = result;
784
+ if (!result) return result;
785
+ if (result.ok) return result;
786
+ if (result.retry) return result;
787
+ if (result.status && result.status > 0) return result;
788
+ if (!isTransientNetworkError(result.error)) return result;
789
+ }
790
+ return lastResult;
791
+ }
792
+
793
+ function writeSse(res, eventName, dataObj) {
794
+ if (!res || res.writableEnded || res.destroyed) return;
795
+ if (eventName) {
796
+ res.write(`event: ${eventName}\n`);
797
+ }
798
+ if (dataObj === '[DONE]') {
799
+ res.write('data: [DONE]\n\n');
800
+ return;
801
+ }
802
+ res.write(`data: ${JSON.stringify(dataObj)}\n\n`);
803
+ }
804
+
805
+ function appendChatStreamToolCall(target, toolCall) {
806
+ if (!toolCall || typeof toolCall !== 'object') return;
807
+ const index = Number.isFinite(toolCall.index) ? toolCall.index : target.length;
808
+ if (!target[index]) {
809
+ target[index] = {
810
+ id: '',
811
+ type: 'function',
812
+ function: { name: '', arguments: '' }
813
+ };
814
+ }
815
+ const current = target[index];
816
+ if (typeof toolCall.id === 'string' && toolCall.id) current.id = toolCall.id;
817
+ if (typeof toolCall.type === 'string' && toolCall.type) current.type = toolCall.type;
818
+ const fn = toolCall.function && typeof toolCall.function === 'object' ? toolCall.function : null;
819
+ if (fn) {
820
+ if (typeof fn.name === 'string' && fn.name) current.function.name = fn.name;
821
+ if (typeof fn.arguments === 'string') current.function.arguments += fn.arguments;
822
+ }
823
+ }
824
+
825
+ function writeChatCompletionChunkAsResponsesSse(state, chunk) {
826
+ if (!chunk || typeof chunk !== 'object') return;
827
+ if (typeof chunk.model === 'string' && chunk.model) {
828
+ state.model = chunk.model;
829
+ }
830
+ const choices = Array.isArray(chunk.choices) ? chunk.choices : [];
831
+ for (const choice of choices) {
832
+ const delta = choice && choice.delta && typeof choice.delta === 'object' ? choice.delta : null;
833
+ if (!delta) continue;
834
+
835
+ const segments = [];
836
+ // DeepSeek-style OpenAI-compatible streams may emit private reasoning in
837
+ // `reasoning_content` before the final answer. Responses `output_text`
838
+ // must stay user-visible answer text only; forwarding reasoning here
839
+ // pollutes Codex output and breaks exact-answer prompts.
840
+ if (typeof delta.content === 'string' && delta.content) {
841
+ segments.push(delta.content);
842
+ }
843
+ for (const seg of segments) {
844
+ if (!state.messageItem) {
845
+ state.messageItem = {
846
+ id: `msg_${crypto.randomBytes(8).toString('hex')}`,
847
+ type: 'message',
848
+ role: 'assistant',
849
+ content: [{ type: 'output_text', text: '' }]
850
+ };
851
+ state.output.push(state.messageItem);
852
+ writeSse(state.res, 'response.output_item.added', {
853
+ type: 'response.output_item.added',
854
+ output_index: state.output.length - 1,
855
+ item: state.messageItem
856
+ });
857
+ }
858
+ state.messageText += seg;
859
+ state.messageItem.content[0].text = state.messageText;
860
+ writeSse(state.res, 'response.output_text.delta', {
861
+ type: 'response.output_text.delta',
862
+ item_id: state.messageItem.id,
863
+ output_index: state.output.length - 1,
864
+ content_index: 0,
865
+ delta: seg,
866
+ sequence_number: state.nextSeq()
867
+ });
868
+ }
869
+
870
+ if (Array.isArray(delta.tool_calls)) {
871
+ for (const toolCall of delta.tool_calls) {
872
+ appendChatStreamToolCall(state.toolCalls, toolCall);
873
+ }
874
+ }
875
+
876
+ if (typeof choice.finish_reason === 'string' && choice.finish_reason) {
877
+ state.sawFinishReason = true;
878
+ }
879
+ }
880
+ }
881
+
882
+ function finishChatStreamResponsesSse(state) {
883
+ if (!state || state.finished) return;
884
+ state.finished = true;
885
+
886
+ if (state.messageItem) {
887
+ const outputIndex = state.output.indexOf(state.messageItem);
888
+ writeSse(state.res, 'response.output_text.done', {
889
+ type: 'response.output_text.done',
890
+ item_id: state.messageItem.id,
891
+ output_index: outputIndex,
892
+ content_index: 0,
893
+ text: state.messageText,
894
+ sequence_number: state.nextSeq()
895
+ });
896
+ writeSse(state.res, 'response.output_item.done', {
897
+ type: 'response.output_item.done',
898
+ output_index: outputIndex,
899
+ item: state.messageItem,
900
+ sequence_number: state.nextSeq()
901
+ });
902
+ }
903
+
904
+ for (const toolCall of state.toolCalls) {
905
+ if (!toolCall) continue;
906
+ const name = toolCall.function && typeof toolCall.function.name === 'string' ? toolCall.function.name : '';
907
+ if (!name) continue;
908
+ const item = {
909
+ type: 'function_call',
910
+ call_id: toolCall.id || `call_${crypto.randomBytes(8).toString('hex')}`,
911
+ name,
912
+ arguments: toolCall.function && typeof toolCall.function.arguments === 'string' ? toolCall.function.arguments : ''
913
+ };
914
+ const outputIndex = state.output.length;
915
+ state.output.push(item);
916
+ writeSse(state.res, 'response.output_item.added', {
917
+ type: 'response.output_item.added',
918
+ output_index: outputIndex,
919
+ item
920
+ });
921
+ writeSse(state.res, 'response.output_item.done', {
922
+ type: 'response.output_item.done',
923
+ output_index: outputIndex,
924
+ item,
925
+ sequence_number: state.nextSeq()
926
+ });
927
+ }
928
+
929
+ const response = ensureResponseMetadata({
930
+ id: state.responseId,
931
+ model: state.model,
932
+ created_at: state.createdAt,
933
+ status: 'completed',
934
+ output: state.output,
935
+ output_text: state.messageText
936
+ });
937
+ writeSse(state.res, 'response.completed', { type: 'response.completed', response });
938
+ writeSse(state.res, 'done', '[DONE]');
939
+ if (!state.res.writableEnded && !state.res.destroyed) {
940
+ state.res.end();
941
+ }
942
+ }
943
+
944
+ function failChatStreamResponsesSse(state, errorMessage) {
945
+ if (!state || state.finished) return;
946
+ state.finished = true;
947
+ writeSse(state.res, 'response.failed', {
948
+ type: 'response.failed',
949
+ response: ensureResponseMetadata({
950
+ id: state.responseId,
951
+ model: state.model,
952
+ created_at: state.createdAt,
953
+ status: 'failed',
954
+ output: state.output,
955
+ output_text: state.messageText
956
+ }),
957
+ error: String(errorMessage || 'upstream stream failed')
958
+ });
959
+ writeSse(state.res, 'done', '[DONE]');
960
+ if (!state.res.writableEnded && !state.res.destroyed) {
961
+ state.res.end();
962
+ }
963
+ }
964
+
965
+ function formatUpstreamStreamError(errorValue) {
966
+ if (!errorValue) return 'upstream stream failed';
967
+ if (typeof errorValue === 'string') return errorValue;
968
+ if (typeof errorValue === 'object') {
969
+ if (typeof errorValue.message === 'string' && errorValue.message) return errorValue.message;
970
+ try { return JSON.stringify(errorValue); } catch (_) {}
971
+ }
972
+ return String(errorValue || 'upstream stream failed');
973
+ }
974
+
975
+ function streamChatCompletionsAsResponsesSse(targetUrl, options = {}) {
976
+ const parsed = new URL(targetUrl);
977
+ const transport = parsed.protocol === 'https:' ? https : http;
978
+ const bodyText = options.body ? JSON.stringify(options.body) : '';
979
+ const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
980
+ ? Math.floor(options.maxBytes)
981
+ : 0;
982
+ const headers = {
983
+ 'Accept': 'text/event-stream',
984
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
985
+ ...(options.headers || {})
986
+ };
987
+ if (options.body) {
988
+ headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
989
+ }
990
+ const timeoutMs = Number.isFinite(options.timeoutMs)
991
+ ? Math.max(1000, Number(options.timeoutMs))
992
+ : 30000;
993
+ const res = options.res;
994
+ const fallbackModel = typeof options.model === 'string' ? options.model : '';
995
+
996
+ return new Promise((resolve) => {
997
+ let settled = false;
998
+ let upstreamReq = null;
999
+ const finish = (value) => {
1000
+ if (settled) return;
1001
+ settled = true;
1002
+ resolve(value);
1003
+ };
1004
+ const abortUpstream = () => {
1005
+ if (upstreamReq) {
1006
+ try { upstreamReq.destroy(new Error('client aborted')); } catch (_) {}
1007
+ }
1008
+ };
1009
+ if (res && typeof res.once === 'function') {
1010
+ res.once('close', abortUpstream);
1011
+ }
1012
+
1013
+ upstreamReq = transport.request({
1014
+ protocol: parsed.protocol,
1015
+ hostname: parsed.hostname,
1016
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
1017
+ method: options.method || 'POST',
1018
+ path: `${parsed.pathname}${parsed.search}`,
1019
+ headers,
1020
+ agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
1021
+ }, (upstreamRes) => {
1022
+ const status = upstreamRes.statusCode || 0;
1023
+ const chunks = [];
1024
+ let size = 0;
1025
+ const contentType = String(upstreamRes.headers && upstreamRes.headers['content-type'] || '');
1026
+
1027
+ const collectChunk = (chunk) => {
1028
+ if (!chunk) return true;
1029
+ if (maxBytes > 0) {
1030
+ size += chunk.length;
1031
+ if (size > maxBytes) {
1032
+ chunks.length = 0;
1033
+ try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
1034
+ try { upstreamReq.destroy(new Error('response too large')); } catch (_) {}
1035
+ finish({ ok: false, status, error: 'response too large' });
1036
+ return false;
1037
+ }
1038
+ }
1039
+ chunks.push(chunk);
1040
+ return true;
1041
+ };
1042
+
1043
+ if (status >= 400) {
1044
+ upstreamRes.on('data', collectChunk);
1045
+ upstreamRes.on('end', () => finish({
1046
+ ok: false,
1047
+ status,
1048
+ bodyText: chunks.length ? Buffer.concat(chunks).toString('utf-8') : ''
1049
+ }));
1050
+ return;
1051
+ }
1052
+
1053
+ if (!res.headersSent) {
1054
+ res.writeHead(200, {
1055
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1056
+ 'Cache-Control': 'no-cache',
1057
+ 'Connection': 'keep-alive',
1058
+ 'X-Accel-Buffering': 'no'
1059
+ });
1060
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
1061
+ }
1062
+
1063
+ if (!/text\/event-stream/i.test(contentType)) {
1064
+ upstreamRes.on('data', collectChunk);
1065
+ upstreamRes.on('end', () => {
1066
+ const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
1067
+ const parsedJson = parseJsonOrError(text);
1068
+ if (parsedJson.error) {
1069
+ writeSse(res, 'response.failed', { type: 'response.failed', error: `invalid upstream response: ${parsedJson.error}` });
1070
+ writeSse(res, 'done', '[DONE]');
1071
+ if (!res.writableEnded && !res.destroyed) res.end();
1072
+ finish({ ok: true });
1073
+ return;
1074
+ }
1075
+ const extracted = extractChatCompletionResult(parsedJson.value);
1076
+ sendResponsesSse(res, buildResponsesPayloadFromChatResult(fallbackModel, extracted.text, extracted.toolCalls, parsedJson.value));
1077
+ if (!res.writableEnded && !res.destroyed) res.end();
1078
+ finish({ ok: true });
1079
+ });
1080
+ return;
1081
+ }
1082
+
1083
+ let sequence = 0;
1084
+ const state = {
1085
+ res,
1086
+ responseId: `resp_${crypto.randomBytes(10).toString('hex')}`,
1087
+ model: fallbackModel,
1088
+ createdAt: Math.floor(Date.now() / 1000),
1089
+ output: [],
1090
+ messageItem: null,
1091
+ messageText: '',
1092
+ toolCalls: [],
1093
+ finished: false,
1094
+ sawDone: false,
1095
+ sawFinishReason: false,
1096
+ nextSeq: () => {
1097
+ sequence += 1;
1098
+ return sequence;
1099
+ }
1100
+ };
1101
+ writeSse(res, 'response.created', {
1102
+ type: 'response.created',
1103
+ response: {
1104
+ id: state.responseId,
1105
+ model: state.model,
1106
+ created_at: state.createdAt
1107
+ }
1108
+ });
1109
+
1110
+ let buffer = '';
1111
+ const handleEventBlock = (block) => {
1112
+ const dataLines = String(block || '')
1113
+ .split(/\r?\n/)
1114
+ .filter((line) => line.startsWith('data:'))
1115
+ .map((line) => line.slice(5).trimStart());
1116
+ if (dataLines.length === 0) return;
1117
+ const data = dataLines.join('\n').trim();
1118
+ if (!data) return;
1119
+ if (data === '[DONE]') {
1120
+ state.sawDone = true;
1121
+ finishChatStreamResponsesSse(state);
1122
+ finish({ ok: true });
1123
+ return;
1124
+ }
1125
+ const parsedChunk = parseJsonOrError(data);
1126
+ if (!parsedChunk.error) {
1127
+ if (parsedChunk.value && typeof parsedChunk.value === 'object' && parsedChunk.value.error) {
1128
+ failChatStreamResponsesSse(state, formatUpstreamStreamError(parsedChunk.value.error));
1129
+ finish({ ok: true });
1130
+ return;
1131
+ }
1132
+ writeChatCompletionChunkAsResponsesSse(state, parsedChunk.value);
1133
+ }
1134
+ };
1135
+
1136
+ upstreamRes.on('data', (chunk) => {
1137
+ if (!chunk) return;
1138
+ buffer += chunk.toString('utf-8');
1139
+ let boundary = buffer.search(/\r?\n\r?\n/);
1140
+ while (boundary >= 0) {
1141
+ const block = buffer.slice(0, boundary);
1142
+ const match = buffer.slice(boundary).match(/^\r?\n\r?\n/);
1143
+ buffer = buffer.slice(boundary + (match ? match[0].length : 2));
1144
+ handleEventBlock(block);
1145
+ boundary = buffer.search(/\r?\n\r?\n/);
1146
+ }
1147
+ });
1148
+ upstreamRes.on('end', () => {
1149
+ if (buffer.trim()) handleEventBlock(buffer);
1150
+ if (!state.finished && !state.sawDone && !state.sawFinishReason) {
1151
+ failChatStreamResponsesSse(state, 'upstream stream ended before [DONE]');
1152
+ finish({ ok: true });
1153
+ return;
1154
+ }
1155
+ finishChatStreamResponsesSse(state);
1156
+ finish({ ok: true });
1157
+ });
1158
+ upstreamRes.on('aborted', () => {
1159
+ failChatStreamResponsesSse(state, 'upstream stream aborted');
1160
+ finish({ ok: true });
1161
+ });
1162
+ upstreamRes.on('error', (err) => {
1163
+ failChatStreamResponsesSse(state, err && err.message ? err.message : 'upstream stream failed');
1164
+ finish({ ok: true });
1165
+ });
1166
+ });
1167
+ upstreamReq.setTimeout(timeoutMs, () => {
1168
+ try { upstreamReq.destroy(new Error('timeout')); } catch (_) {}
1169
+ finish({ ok: false, error: 'timeout' });
1170
+ });
1171
+ upstreamReq.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
1172
+ if (bodyText) upstreamReq.write(bodyText);
1173
+ upstreamReq.end();
1174
+ });
1175
+ }
1176
+
1177
+ async function proxyRequestJson(targetUrl, options = {}) {
1178
+ const parsed = new URL(targetUrl);
1179
+ const transport = parsed.protocol === 'https:' ? https : http;
1180
+ const bodyText = options.body ? JSON.stringify(options.body) : '';
1181
+ const maxBytes = Number.isFinite(options.maxBytes) && options.maxBytes > 0
1182
+ ? Math.floor(options.maxBytes)
1183
+ : 0;
1184
+ const headers = {
1185
+ 'Accept': 'application/json',
1186
+ ...(options.body ? { 'Content-Type': 'application/json' } : {}),
1187
+ ...(options.headers || {})
1188
+ };
1189
+ if (options.body) {
1190
+ headers['Content-Length'] = Buffer.byteLength(bodyText, 'utf-8');
1191
+ }
1192
+
1193
+ const timeoutMs = Number.isFinite(options.timeoutMs)
1194
+ ? Math.max(1000, Number(options.timeoutMs))
1195
+ : 30000;
1196
+ return new Promise((resolve) => {
1197
+ let settled = false;
1198
+ const finish = (value) => {
1199
+ if (settled) return;
1200
+ settled = true;
1201
+ resolve(value);
1202
+ };
1203
+ const req = transport.request({
1204
+ protocol: parsed.protocol,
1205
+ hostname: parsed.hostname,
1206
+ port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
1207
+ method: options.method || 'GET',
1208
+ path: `${parsed.pathname}${parsed.search}`,
1209
+ headers,
1210
+ agent: parsed.protocol === 'https:' ? options.httpsAgent : options.httpAgent
1211
+ }, (upstreamRes) => {
1212
+ const chunks = [];
1213
+ let size = 0;
1214
+ upstreamRes.on('data', (chunk) => {
1215
+ if (!chunk) return;
1216
+ if (maxBytes > 0) {
1217
+ size += chunk.length;
1218
+ if (size > maxBytes) {
1219
+ chunks.length = 0;
1220
+ try { upstreamRes.destroy(new Error('response too large')); } catch (_) {}
1221
+ try { req.destroy(new Error('response too large')); } catch (_) {}
1222
+ finish({ ok: false, error: 'response too large' });
1223
+ return;
1224
+ }
1225
+ }
1226
+ chunks.push(chunk);
1227
+ });
1228
+ upstreamRes.on('end', () => {
1229
+ const text = chunks.length ? Buffer.concat(chunks).toString('utf-8') : '';
1230
+ finish({
1231
+ ok: true,
1232
+ status: upstreamRes.statusCode || 0,
1233
+ headers: upstreamRes.headers || {},
1234
+ bodyText: text
1235
+ });
1236
+ });
1237
+ });
1238
+ req.setTimeout(timeoutMs, () => {
1239
+ try { req.destroy(new Error('timeout')); } catch (_) {}
1240
+ finish({ ok: false, error: 'timeout' });
1241
+ });
1242
+ req.on('error', (err) => finish({ ok: false, error: err && err.message ? err.message : 'request failed' }));
1243
+ if (bodyText) {
1244
+ req.write(bodyText);
1245
+ }
1246
+ req.end();
1247
+ });
1248
+ }
1249
+
1250
+ function createOpenaiBridgeHttpHandler(options = {}) {
1251
+ const settingsFile = options.settingsFile;
1252
+ const expectedTokenRaw = typeof options.expectedToken === 'string' ? options.expectedToken.trim() : '';
1253
+ const expectedToken = Object.prototype.hasOwnProperty.call(options, 'expectedToken')
1254
+ ? expectedTokenRaw
1255
+ : (expectedTokenRaw || DEFAULT_BRIDGE_TOKEN);
1256
+ const maxBodySize = Number.isFinite(options.maxBodySize) ? options.maxBodySize : 0;
1257
+ const httpAgent = options.httpAgent;
1258
+ const httpsAgent = options.httpsAgent;
1259
+ const maxUpstreamBytes = Number.isFinite(options.maxUpstreamBytes) && options.maxUpstreamBytes > 0
1260
+ ? Math.floor(options.maxUpstreamBytes)
1261
+ : Math.max(16 * 1024 * 1024, maxBodySize > 0 ? maxBodySize * 4 : 0);
1262
+
1263
+ if (!settingsFile) {
1264
+ throw new Error('createOpenaiBridgeHttpHandler 缺少 settingsFile');
1265
+ }
1266
+
1267
+ const matchPath = (requestPath) => {
1268
+ const normalized = String(requestPath || '');
1269
+ const prefix = '/bridge/openai/';
1270
+ if (!normalized.startsWith(prefix)) return null;
1271
+ const rest = normalized.slice(prefix.length);
1272
+ const [provider, ...tail] = rest.split('/').filter((part) => part.length > 0);
1273
+ if (!provider) return null;
1274
+ const tailPath = '/' + tail.join('/');
1275
+ if (!tailPath.startsWith('/v1')) return null;
1276
+ const suffix = tailPath === '/v1' ? '' : tailPath.replace(/^\/v1\/?/, '');
1277
+ return { provider, suffix };
1278
+ };
1279
+
1280
+ const handler = (req, res) => {
1281
+ let parsedUrl;
1282
+ try {
1283
+ parsedUrl = new URL(req.url || '/', 'http://localhost');
1284
+ } catch (_) {
1285
+ return false;
1286
+ }
1287
+ const match = matchPath(parsedUrl.pathname || '/');
1288
+ if (!match) return false;
1289
+
1290
+ void (async () => {
1291
+ try {
1292
+ const token = extractAuthorizationToken(req);
1293
+ // 兼容:某些客户端在自定义 base_url 时可能不带 Authorization。
1294
+ // 为避免在 LAN 暴露无鉴权的代理,这里仅允许 loopback 连接缺省 token。
1295
+ const remoteAddr = req && req.socket ? req.socket.remoteAddress : '';
1296
+ const isLoopback = isLoopbackAddress(remoteAddr);
1297
+ if (!isLoopback && !expectedToken) {
1298
+ res.writeHead(403, { 'Content-Type': 'application/json; charset=utf-8' });
1299
+ res.end(JSON.stringify({ error: 'Remote access is disabled (set CODEXMATE_HTTP_TOKEN)' }));
1300
+ return;
1301
+ }
1302
+ if (!token && !isLoopback) {
1303
+ res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
1304
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1305
+ return;
1306
+ }
1307
+ // loopback 上的本地代理:允许客户端携带任意 Authorization(例如 Codex 会附带 provider apiKey)。
1308
+ // loopback 时仍强制校验 expectedToken,避免局域网被未授权调用。
1309
+ if (!isLoopback && token && token !== expectedToken) {
1310
+ res.writeHead(401, { 'Content-Type': 'application/json; charset=utf-8' });
1311
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
1312
+ return;
1313
+ }
1314
+
1315
+ const upstream = resolveOpenaiBridgeUpstream(settingsFile, match.provider);
1316
+ if (upstream.error) {
1317
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
1318
+ res.end(JSON.stringify({ error: upstream.error }));
1319
+ return;
1320
+ }
1321
+
1322
+ const suffix = match.suffix || '';
1323
+ const normalizedSuffix = suffix.replace(/^\/+/, '');
1324
+
1325
+ const authHeader = upstream.apiKey
1326
+ ? (/^bearer\s+/i.test(upstream.apiKey) ? upstream.apiKey : `Bearer ${upstream.apiKey}`)
1327
+ : '';
1328
+ const upstreamHeaders = upstream && upstream.headers && typeof upstream.headers === 'object' && !Array.isArray(upstream.headers)
1329
+ ? upstream.headers
1330
+ : {};
1331
+
1332
+ if (!normalizedSuffix) {
1333
+ if ((req.method || 'GET').toUpperCase() !== 'GET') {
1334
+ res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
1335
+ res.end(JSON.stringify({ error: 'Method Not Allowed' }));
1336
+ return;
1337
+ }
1338
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1339
+ res.end(JSON.stringify({
1340
+ object: 'codexmate.openai_bridge',
1341
+ provider: match.provider,
1342
+ status: 'ok',
1343
+ endpoints: ['/v1/responses', '/v1/models']
1344
+ }));
1345
+ return;
1346
+ }
1347
+
1348
+ if (normalizedSuffix === 'models') {
1349
+ if ((req.method || 'GET').toUpperCase() !== 'GET') {
1350
+ res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
1351
+ res.end(JSON.stringify({ error: 'Method Not Allowed' }));
1352
+ return;
1353
+ }
1354
+
1355
+ const url = joinApiUrl(upstream.baseUrl, 'models');
1356
+ const result = await retryTransientRequest(() => proxyRequestJson(url, {
1357
+ method: 'GET',
1358
+ headers: {
1359
+ ...(authHeader ? { Authorization: authHeader } : {}),
1360
+ ...upstreamHeaders
1361
+ },
1362
+ maxBytes: maxUpstreamBytes,
1363
+ httpAgent,
1364
+ httpsAgent
1365
+ }));
1366
+ if (!result.ok) {
1367
+ res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1368
+ res.end(JSON.stringify({ error: `Upstream request failed: ${result.error}` }));
1369
+ return;
1370
+ }
1371
+ res.writeHead(result.status || 502, { 'Content-Type': 'application/json; charset=utf-8' });
1372
+ res.end(result.bodyText || '');
1373
+ return;
1374
+ }
1375
+
1376
+ if (normalizedSuffix !== 'responses') {
1377
+ res.writeHead(404, { 'Content-Type': 'application/json; charset=utf-8' });
1378
+ res.end(JSON.stringify({ error: 'Not Found' }));
1379
+ return;
1380
+ }
1381
+
1382
+ if ((req.method || 'GET').toUpperCase() !== 'POST') {
1383
+ res.writeHead(405, { 'Content-Type': 'application/json; charset=utf-8' });
1384
+ res.end(JSON.stringify({ error: 'Method Not Allowed' }));
1385
+ return;
1386
+ }
1387
+
1388
+ const { body, error: bodyErr } = await readRequestBody(req, maxBodySize);
1389
+ if (bodyErr) {
1390
+ res.writeHead(413, { 'Content-Type': 'application/json; charset=utf-8' });
1391
+ res.end(JSON.stringify({ error: bodyErr }));
1392
+ return;
1393
+ }
1394
+ const parsed = parseJsonOrError(body);
1395
+ if (parsed.error) {
1396
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1397
+ res.end(JSON.stringify({ error: `Invalid JSON: ${parsed.error}` }));
1398
+ return;
1399
+ }
1400
+
1401
+ const responsesRequest = parsed.value;
1402
+ const streamRequested = !!(responsesRequest && typeof responsesRequest === 'object' && responsesRequest.stream === true);
1403
+ const acceptHeader = req && req.headers ? (req.headers.accept || req.headers.Accept || '') : '';
1404
+ const wantsSse = /text\/event-stream/i.test(String(acceptHeader || ''));
1405
+
1406
+ if (streamRequested && wantsSse) {
1407
+ const converted = convertResponsesRequestToChatCompletions(responsesRequest);
1408
+ if (converted.error) {
1409
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1410
+ res.end(JSON.stringify({ error: converted.error }));
1411
+ return;
1412
+ }
1413
+ const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
1414
+ const chatBody = { ...converted.chat, stream: true };
1415
+ const streamed = await retryTransientRequest(() => streamChatCompletionsAsResponsesSse(upstreamUrl, {
1416
+ method: 'POST',
1417
+ body: chatBody,
1418
+ headers: {
1419
+ ...(authHeader ? { Authorization: authHeader } : {}),
1420
+ ...upstreamHeaders
1421
+ },
1422
+ maxBytes: maxUpstreamBytes,
1423
+ httpAgent,
1424
+ httpsAgent,
1425
+ res,
1426
+ model: typeof chatBody.model === 'string' ? chatBody.model : ''
1427
+ }));
1428
+ if (!streamed.ok) {
1429
+ if (res.writableEnded || res.destroyed) {
1430
+ return;
1431
+ }
1432
+ if (!res.headersSent) {
1433
+ res.writeHead(streamed.status && streamed.status >= 400 ? streamed.status : 502, { 'Content-Type': 'application/json; charset=utf-8' });
1434
+ res.end(streamed.bodyText || JSON.stringify({ error: streamed.error || 'Upstream request failed' }));
1435
+ } else if (!res.writableEnded && !res.destroyed) {
1436
+ writeSse(res, 'response.failed', { type: 'response.failed', error: streamed.error || streamed.bodyText || 'Upstream request failed' });
1437
+ writeSse(res, 'done', '[DONE]');
1438
+ res.end();
1439
+ }
1440
+ }
1441
+ return;
1442
+ }
1443
+
1444
+ // Maxx-style behavior: prefer upstream /responses if supported.
1445
+ // Fallback to /chat/completions conversion when upstream does not implement /responses (404/405).
1446
+ const upstreamResponsesUrl = joinApiUrl(upstream.baseUrl, 'responses');
1447
+ const upstreamResponsesResult = await retryTransientRequest(() => proxyRequestJson(upstreamResponsesUrl, {
1448
+ method: 'POST',
1449
+ body: toUpstreamNonStreamingResponsesPayload(responsesRequest),
1450
+ headers: {
1451
+ ...(authHeader ? { Authorization: authHeader } : {}),
1452
+ ...upstreamHeaders
1453
+ },
1454
+ maxBytes: maxUpstreamBytes,
1455
+ httpAgent,
1456
+ httpsAgent
1457
+ }));
1458
+
1459
+ if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 200 && upstreamResponsesResult.status < 300) {
1460
+ const upstreamJson = parseJsonOrError(upstreamResponsesResult.bodyText);
1461
+ if (upstreamJson.error) {
1462
+ res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1463
+ res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
1464
+ return;
1465
+ }
1466
+ const upstreamPayload = upstreamJson.value;
1467
+ if (streamRequested && wantsSse) {
1468
+ res.writeHead(200, {
1469
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1470
+ 'Cache-Control': 'no-cache',
1471
+ 'Connection': 'keep-alive',
1472
+ 'X-Accel-Buffering': 'no'
1473
+ });
1474
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
1475
+ sendResponsesSse(res, upstreamPayload);
1476
+ res.end();
1477
+ return;
1478
+ }
1479
+
1480
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1481
+ res.end(JSON.stringify(ensureResponseMetadata(upstreamPayload)));
1482
+ return;
1483
+ }
1484
+
1485
+ if (upstreamResponsesResult.ok && upstreamResponsesResult.status >= 400) {
1486
+ if (!shouldFallbackFromUpstreamResponses(upstreamResponsesResult.status, upstreamResponsesResult.bodyText)) {
1487
+ res.writeHead(upstreamResponsesResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
1488
+ res.end(upstreamResponsesResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
1489
+ return;
1490
+ }
1491
+ // fallthrough to chat/completions conversion
1492
+ }
1493
+
1494
+ if (!upstreamResponsesResult.ok) {
1495
+ res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1496
+ res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResponsesResult.error}` }));
1497
+ return;
1498
+ }
1499
+
1500
+ const converted = convertResponsesRequestToChatCompletions(responsesRequest);
1501
+ if (converted.error) {
1502
+ res.writeHead(400, { 'Content-Type': 'application/json; charset=utf-8' });
1503
+ res.end(JSON.stringify({ error: converted.error }));
1504
+ return;
1505
+ }
1506
+
1507
+ const upstreamUrl = joinApiUrl(upstream.baseUrl, 'chat/completions');
1508
+ const upstreamResult = await retryTransientRequest(() => proxyRequestJson(upstreamUrl, {
1509
+ method: 'POST',
1510
+ body: converted.chat,
1511
+ headers: {
1512
+ ...(authHeader ? { Authorization: authHeader } : {}),
1513
+ ...upstreamHeaders
1514
+ },
1515
+ maxBytes: maxUpstreamBytes,
1516
+ httpAgent,
1517
+ httpsAgent
1518
+ }));
1519
+ if (!upstreamResult.ok) {
1520
+ res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1521
+ res.end(JSON.stringify({ error: `Upstream request failed: ${upstreamResult.error}` }));
1522
+ return;
1523
+ }
1524
+
1525
+ const upstreamJson = parseJsonOrError(upstreamResult.bodyText);
1526
+ if (upstreamResult.status >= 400) {
1527
+ res.writeHead(upstreamResult.status, { 'Content-Type': 'application/json; charset=utf-8' });
1528
+ res.end(upstreamResult.bodyText || JSON.stringify({ error: 'Upstream error' }));
1529
+ return;
1530
+ }
1531
+ if (upstreamJson.error) {
1532
+ res.writeHead(502, { 'Content-Type': 'application/json; charset=utf-8' });
1533
+ res.end(JSON.stringify({ error: `Upstream JSON parse failed: ${upstreamJson.error}` }));
1534
+ return;
1535
+ }
1536
+
1537
+ const model = typeof converted.chat.model === 'string' ? converted.chat.model : '';
1538
+ const extracted = extractChatCompletionResult(upstreamJson.value);
1539
+ const text = extracted && typeof extracted.text === 'string' ? extracted.text : '';
1540
+ const toolCalls = extracted && Array.isArray(extracted.toolCalls) ? extracted.toolCalls : [];
1541
+ const responsesPayload = buildResponsesPayloadFromChatResult(model, text, toolCalls, upstreamJson.value);
1542
+
1543
+ if (converted.streamRequested && wantsSse) {
1544
+ res.writeHead(200, {
1545
+ 'Content-Type': 'text/event-stream; charset=utf-8',
1546
+ 'Cache-Control': 'no-cache',
1547
+ 'Connection': 'keep-alive',
1548
+ 'X-Accel-Buffering': 'no'
1549
+ });
1550
+ if (typeof res.flushHeaders === 'function') res.flushHeaders();
1551
+ sendResponsesSse(res, responsesPayload);
1552
+ res.end();
1553
+ return;
1554
+ }
1555
+
1556
+ res.writeHead(200, { 'Content-Type': 'application/json; charset=utf-8' });
1557
+ res.end(JSON.stringify(ensureResponseMetadata(responsesPayload)));
1558
+ } catch (e) {
1559
+ res.writeHead(500, { 'Content-Type': 'application/json; charset=utf-8' });
1560
+ res.end(JSON.stringify({ error: e && e.message ? e.message : 'Internal Error' }));
1561
+ }
1562
+ })();
1563
+
1564
+ return true;
1565
+ };
1566
+
1567
+ handler.matchPath = matchPath;
1568
+ return handler;
1569
+ }
1570
+
1571
+ module.exports = {
1572
+ readOpenaiBridgeSettings,
1573
+ upsertOpenaiBridgeProvider,
1574
+ resolveOpenaiBridgeUpstream,
1575
+ createOpenaiBridgeHttpHandler
1576
+ };