pikiloom 0.4.12 → 0.4.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dashboard/dist/assets/{AgentTab-DdA7lBTM.js → AgentTab-Ce9nOgKB.js} +1 -1
- package/dashboard/dist/assets/{DirBrowser-ChaxfTLA.js → DirBrowser-B5hxg2zn.js} +1 -1
- package/dashboard/dist/assets/{ExtensionsTab-DS1QHNtt.js → ExtensionsTab-C2FAUsui.js} +1 -1
- package/dashboard/dist/assets/{IMAccessTab-wj6_hdBo.js → IMAccessTab-CS-2-ENn.js} +1 -1
- package/dashboard/dist/assets/{Modal-Bhdq3fQH.js → Modal-BF2CycPZ.js} +1 -1
- package/dashboard/dist/assets/Modals-BHYtxTUE.js +1 -0
- package/dashboard/dist/assets/{Select-DRA-XB6z.js → Select--CwQ1vbY.js} +1 -1
- package/dashboard/dist/assets/{SessionPanel-Cdje64_Q.js → SessionPanel-D0h4d0Nw.js} +1 -1
- package/dashboard/dist/assets/{SystemTab-B7Y5IjIC.js → SystemTab-B_hq7KIo.js} +1 -1
- package/dashboard/dist/assets/{index-DDITAW7a.js → index-Dws-2k-J.js} +3 -3
- package/dashboard/dist/assets/{index-BLei0B_k.js → index-jCpvbF9B.js} +3 -3
- package/dashboard/dist/assets/{shared-vqgXV5IK.js → shared-D1ruCzXL.js} +1 -1
- package/dashboard/dist/index.html +1 -1
- package/dist/agent/mcp/bridge.js +53 -2
- package/dist/dashboard/routes/extensions.js +6 -0
- package/dist/model/injector.js +167 -28
- package/dist/model/responses-bridge.js +366 -0
- package/package.json +1 -1
- package/dashboard/dist/assets/Modals-DhEz9IH-.js +0 -1
package/dist/model/injector.js
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { resolveCredential } from '../core/secrets/index.js';
|
|
10
10
|
import { getActiveProfile, getProvider } from './store.js';
|
|
11
11
|
import { peekProviderModelInfo, prefetchProviderModels } from './provider-models.js';
|
|
12
|
+
import { ensureResponsesBridge, upstreamToken } from './responses-bridge.js';
|
|
12
13
|
const EMPTY = { env: {}, argvAppend: [], detail: '' };
|
|
13
14
|
// ---------------------------------------------------------------------------
|
|
14
15
|
// Shared host-based provider identification
|
|
@@ -53,7 +54,13 @@ function providerSlug(provider) {
|
|
|
53
54
|
return 'doubao';
|
|
54
55
|
if (host.includes('openrouter'))
|
|
55
56
|
return 'openrouter';
|
|
56
|
-
|
|
57
|
+
// Unknown host: derive a stable slug from the hostname's leading label. (The
|
|
58
|
+
// old `return 'openrouter'` fallback mis-slugged every unrecognised provider —
|
|
59
|
+
// including localhost Ollama — as openrouter.) This never collides with
|
|
60
|
+
// codex's reserved built-in `openai`/`oss`/`ollama` ids, which are routed
|
|
61
|
+
// before we ever reach providerSlug.
|
|
62
|
+
const label = host.replace(/:\d+$/, '').replace(/^(www|api)\./, '').split('.')[0].replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
63
|
+
return label || 'byok';
|
|
57
64
|
}
|
|
58
65
|
/**
|
|
59
66
|
* Canonical env-var name(s) carrying the credential for a provider. Returned
|
|
@@ -152,6 +159,24 @@ function claudeAnthropicBaseURL(provider) {
|
|
|
152
159
|
}
|
|
153
160
|
return raw.replace(/\/v1$/, '');
|
|
154
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* First-party Anthropic = the official API host (`api.anthropic.com` / any
|
|
164
|
+
* `*.anthropic.com`). A Claude route counts as "direct" when it lands here —
|
|
165
|
+
* both the subscription path and an own-key BYOK profile pointed at
|
|
166
|
+
* api.anthropic.com. Everything else (OpenRouter, DeepSeek, domestic series, a
|
|
167
|
+
* self-hosted relay, localhost) is a third-party proxy. Unparseable → treat as
|
|
168
|
+
* proxy (safe default: suppressing attribution is harmless, churning isn't).
|
|
169
|
+
*/
|
|
170
|
+
function isFirstPartyAnthropic(baseURL) {
|
|
171
|
+
let host;
|
|
172
|
+
try {
|
|
173
|
+
host = new URL(baseURL).hostname.toLowerCase();
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
return false;
|
|
177
|
+
}
|
|
178
|
+
return host === 'anthropic.com' || host.endsWith('.anthropic.com');
|
|
179
|
+
}
|
|
155
180
|
/**
|
|
156
181
|
* Claude Code respects `ANTHROPIC_BASE_URL` + `ANTHROPIC_API_KEY` (or
|
|
157
182
|
* `ANTHROPIC_AUTH_TOKEN`) as a BYOK route. The CLI itself is unchanged.
|
|
@@ -170,50 +195,159 @@ const claudeInjector = (provider, profile, apiKey) => {
|
|
|
170
195
|
detail: `Claude BYOK requires Anthropic or OpenAI-compatible (Anthropic-API-shaped) provider; got ${provider.kind}.`,
|
|
171
196
|
};
|
|
172
197
|
}
|
|
198
|
+
const baseURL = claudeAnthropicBaseURL(provider);
|
|
199
|
+
const env = {
|
|
200
|
+
ANTHROPIC_BASE_URL: baseURL,
|
|
201
|
+
ANTHROPIC_API_KEY: apiKey,
|
|
202
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
203
|
+
};
|
|
204
|
+
// Claude Code >= 2.1.36 stamps a per-request `x-anthropic-billing-header`
|
|
205
|
+
// (cc_version / cc_entrypoint / cch=… — the cch token churns every turn).
|
|
206
|
+
// Third-party proxies (OpenRouter, DeepSeek /anthropic, domestic series, any
|
|
207
|
+
// OpenAI-compat or self-hosted Anthropic-shaped front) often key their
|
|
208
|
+
// prefix/KV cache on request headers, so the churn forces a full prompt
|
|
209
|
+
// reprocess every turn — slow and expensive. `0` makes claude omit the header
|
|
210
|
+
// (env-bool: 0/false/no/off). Only on proxy routes: first-party Anthropic
|
|
211
|
+
// (api.anthropic.com — subscription OR own-key direct) is left exactly as
|
|
212
|
+
// shipped; its cache is content/breakpoint based, so attribution is irrelevant
|
|
213
|
+
// there and we don't touch it.
|
|
214
|
+
if (!isFirstPartyAnthropic(baseURL)) {
|
|
215
|
+
env.CLAUDE_CODE_ATTRIBUTION_HEADER = '0';
|
|
216
|
+
}
|
|
173
217
|
return {
|
|
174
|
-
env
|
|
175
|
-
ANTHROPIC_BASE_URL: claudeAnthropicBaseURL(provider),
|
|
176
|
-
ANTHROPIC_API_KEY: apiKey,
|
|
177
|
-
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
178
|
-
},
|
|
218
|
+
env,
|
|
179
219
|
argvAppend: [],
|
|
180
220
|
modelOverride: profile.modelId,
|
|
181
221
|
detail: `Claude BYOK → ${provider.name} / ${profile.modelId}`,
|
|
182
222
|
};
|
|
183
223
|
};
|
|
224
|
+
function providerHostname(provider) {
|
|
225
|
+
try {
|
|
226
|
+
return new URL(provider.baseURL).hostname.toLowerCase();
|
|
227
|
+
}
|
|
228
|
+
catch {
|
|
229
|
+
return '';
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
/** True for localhost endpoints (Ollama / LM Studio / llama.cpp). */
|
|
233
|
+
function isLocalProvider(provider) {
|
|
234
|
+
const h = providerHostname(provider);
|
|
235
|
+
return h === 'localhost' || h === '127.0.0.1' || h === '0.0.0.0' || h === '::1';
|
|
236
|
+
}
|
|
237
|
+
/** Providers that natively implement the OpenAI Responses API (codex talks to them directly). */
|
|
238
|
+
function isResponsesNativeProvider(provider) {
|
|
239
|
+
return providerHost(provider).includes('openrouter');
|
|
240
|
+
}
|
|
241
|
+
/** codex's built-in local provider id for a localhost endpoint. */
|
|
242
|
+
function codexLocalProvider(provider) {
|
|
243
|
+
let port = '';
|
|
244
|
+
try {
|
|
245
|
+
port = new URL(provider.baseURL).port;
|
|
246
|
+
}
|
|
247
|
+
catch { /* ignore */ }
|
|
248
|
+
if (port === '1234' || /lm\s*studio/i.test(provider.name))
|
|
249
|
+
return 'lmstudio';
|
|
250
|
+
return 'ollama';
|
|
251
|
+
}
|
|
184
252
|
/**
|
|
185
|
-
*
|
|
186
|
-
*
|
|
187
|
-
*
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
253
|
+
* Decide how codex should reach a provider. Codex 0.140+ speaks ONLY the
|
|
254
|
+
* Responses API, so the route depends on what the provider implements:
|
|
255
|
+
* openai-native genuine OpenAI → built-in `openai` provider
|
|
256
|
+
* local-oss localhost Ollama/LMStudio → built-in `ollama`/`lmstudio` (responses)
|
|
257
|
+
* responses-native OpenRouter, … → custom provider, responses direct
|
|
258
|
+
* bridge chat-only (DeepSeek, Kimi, MiniMax, 豆包, Qwen, Zhipu, …)
|
|
259
|
+
* → local Responses↔Chat bridge
|
|
260
|
+
*/
|
|
261
|
+
function codexRoute(provider) {
|
|
262
|
+
if (provider.kind === 'openai')
|
|
263
|
+
return 'openai-native';
|
|
264
|
+
if (isLocalProvider(provider))
|
|
265
|
+
return 'local-oss';
|
|
266
|
+
if (isResponsesNativeProvider(provider))
|
|
267
|
+
return 'responses-native';
|
|
268
|
+
return 'bridge';
|
|
269
|
+
}
|
|
270
|
+
/**
|
|
271
|
+
* Codex CLI honours `model_providers.<slug>` definitions in `config.toml` and
|
|
272
|
+
* binds the active one via `model_provider="<slug>"`. The credential lives in
|
|
273
|
+
* the env var named by `env_key`, picked host-aware (e.g. `DEEPSEEK_API_KEY`).
|
|
191
274
|
*
|
|
192
|
-
*
|
|
193
|
-
*
|
|
194
|
-
*
|
|
275
|
+
* Codex 0.140+ dropped Chat Completions (`wire_api = "chat"` is rejected at
|
|
276
|
+
* config load) — it speaks ONLY the Responses API. So this injector routes per
|
|
277
|
+
* `codexRoute()`: responses-capable providers (OpenAI, OpenRouter, local
|
|
278
|
+
* Ollama/LM Studio) are reached directly with the default `responses` wire;
|
|
279
|
+
* chat-only providers (DeepSeek and the domestic series) are routed through the
|
|
280
|
+
* in-process Responses↔Chat bridge, which codex sees as just another
|
|
281
|
+
* responses-speaking provider on localhost.
|
|
195
282
|
*/
|
|
196
|
-
const codexInjector = (provider, profile, apiKey) => {
|
|
283
|
+
const codexInjector = async (provider, profile, apiKey) => {
|
|
197
284
|
if (provider.kind !== 'openai' && provider.kind !== 'openai-compatible') {
|
|
198
285
|
return {
|
|
199
286
|
...EMPTY,
|
|
200
|
-
detail: `Codex BYOK requires OpenAI-compatible provider; got ${provider.kind}.`,
|
|
287
|
+
detail: `Codex BYOK requires an OpenAI-compatible provider; got ${provider.kind}.`,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
const model = profile.modelId;
|
|
291
|
+
const route = codexRoute(provider);
|
|
292
|
+
// Local Ollama / LM Studio: codex's built-in provider already speaks the
|
|
293
|
+
// Responses API to the local server. Just select it — no custom provider, no
|
|
294
|
+
// API key. (Defining `model_providers.<built-in>` is rejected: "Built-in
|
|
295
|
+
// providers cannot be overridden.")
|
|
296
|
+
if (route === 'local-oss') {
|
|
297
|
+
const local = codexLocalProvider(provider);
|
|
298
|
+
return {
|
|
299
|
+
env: {}, argvAppend: [],
|
|
300
|
+
codexConfigOverrides: [`model_provider="${local}"`],
|
|
301
|
+
modelOverride: model,
|
|
302
|
+
detail: `Codex local → ${provider.name} / ${model} (built-in ${local}, responses)`,
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
// Genuine OpenAI: use the built-in `openai` provider; inject the key (+ base).
|
|
306
|
+
if (route === 'openai-native') {
|
|
307
|
+
const env = { OPENAI_API_KEY: apiKey };
|
|
308
|
+
if (provider.baseURL)
|
|
309
|
+
env.OPENAI_BASE_URL = provider.baseURL;
|
|
310
|
+
return {
|
|
311
|
+
env, argvAppend: [],
|
|
312
|
+
codexConfigOverrides: ['model_provider="openai"'],
|
|
313
|
+
modelOverride: model,
|
|
314
|
+
detail: `Codex BYOK → OpenAI / ${model}`,
|
|
201
315
|
};
|
|
202
316
|
}
|
|
203
317
|
const slug = providerSlug(provider);
|
|
204
318
|
const envKey = codexEnvKey(provider);
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
319
|
+
// Chat-only providers: route through the local Responses↔Chat bridge. Codex
|
|
320
|
+
// forwards `Authorization: Bearer <key>` (from env_key) to the bridge, which
|
|
321
|
+
// relays it to the upstream chat endpoint — the bridge never stores secrets.
|
|
322
|
+
if (route === 'bridge') {
|
|
323
|
+
const port = await ensureResponsesBridge();
|
|
324
|
+
const base = `http://127.0.0.1:${port}/u/${upstreamToken(provider.baseURL)}`;
|
|
325
|
+
return {
|
|
326
|
+
env: { [envKey]: apiKey },
|
|
327
|
+
argvAppend: [],
|
|
328
|
+
codexConfigOverrides: [
|
|
329
|
+
`model_providers.${slug}.name="${tomlEscape(provider.name)}"`,
|
|
330
|
+
`model_providers.${slug}.base_url="${tomlEscape(base)}"`,
|
|
331
|
+
`model_providers.${slug}.env_key="${envKey}"`,
|
|
332
|
+
`model_provider="${slug}"`,
|
|
333
|
+
],
|
|
334
|
+
modelOverride: model,
|
|
335
|
+
detail: `Codex BYOK → ${provider.name} / ${model} via Responses↔Chat bridge (provider=${slug})`,
|
|
336
|
+
};
|
|
337
|
+
}
|
|
338
|
+
// responses-native (OpenRouter, …): point codex straight at the provider's
|
|
339
|
+
// Responses endpoint (wire_api omitted ⇒ codex default `responses`).
|
|
211
340
|
return {
|
|
212
341
|
env: { [envKey]: apiKey },
|
|
213
342
|
argvAppend: [],
|
|
214
|
-
codexConfigOverrides:
|
|
215
|
-
|
|
216
|
-
|
|
343
|
+
codexConfigOverrides: [
|
|
344
|
+
`model_providers.${slug}.name="${tomlEscape(provider.name)}"`,
|
|
345
|
+
`model_providers.${slug}.base_url="${tomlEscape(provider.baseURL)}"`,
|
|
346
|
+
`model_providers.${slug}.env_key="${envKey}"`,
|
|
347
|
+
`model_provider="${slug}"`,
|
|
348
|
+
],
|
|
349
|
+
modelOverride: model,
|
|
350
|
+
detail: `Codex BYOK → ${provider.name} / ${model} (provider=${slug}, native responses)`,
|
|
217
351
|
};
|
|
218
352
|
};
|
|
219
353
|
/** Gemini CLI accepts `GEMINI_API_KEY` but does not allow custom baseURL. */
|
|
@@ -289,12 +423,17 @@ export async function resolveAgentInjection(agentId) {
|
|
|
289
423
|
const injector = AGENT_INJECT_TABLE[agentId];
|
|
290
424
|
if (!injector)
|
|
291
425
|
return null;
|
|
292
|
-
|
|
426
|
+
// Local providers (Ollama / LM Studio / llama.cpp) need no credential — codex
|
|
427
|
+
// reaches them via its built-in localhost provider with no auth. Don't let a
|
|
428
|
+
// missing/placeholder key block an otherwise-valid local binding.
|
|
429
|
+
let apiKey = '';
|
|
293
430
|
try {
|
|
294
431
|
apiKey = await resolveCredential(provider.credential);
|
|
295
432
|
}
|
|
296
433
|
catch (e) {
|
|
297
|
-
|
|
434
|
+
if (!isLocalProvider(provider)) {
|
|
435
|
+
throw new Error(`Failed to resolve credential for ${provider.name}: ${e?.message || e}`);
|
|
436
|
+
}
|
|
298
437
|
}
|
|
299
438
|
const result = await injector(provider, profile, apiKey);
|
|
300
439
|
// Attach the provider display name so renders can surface "via <provider>"
|
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Responses↔Chat bridge.
|
|
3
|
+
*
|
|
4
|
+
* Codex 0.140+ speaks ONLY the OpenAI Responses API (`wire_api = "chat"` was
|
|
5
|
+
* removed). Many OpenAI-compatible providers — DeepSeek, Kimi/Moonshot,
|
|
6
|
+
* MiniMax, 豆包/Doubao, Qwen/DashScope, Zhipu, … — implement ONLY the Chat
|
|
7
|
+
* Completions API. This in-process HTTP server bridges the two so codex can
|
|
8
|
+
* drive any chat-only provider:
|
|
9
|
+
*
|
|
10
|
+
* codex ──(Responses API)──▶ bridge ──(Chat Completions)──▶ upstream provider
|
|
11
|
+
*
|
|
12
|
+
* One server instance routes every upstream: the upstream base URL is encoded
|
|
13
|
+
* (base64url) into the request path (`/u/<token>/responses`). The caller's
|
|
14
|
+
* Authorization header is forwarded verbatim, so the bridge never reads or
|
|
15
|
+
* stores credentials — codex injects `Authorization: Bearer <key>` from the
|
|
16
|
+
* provider's `env_key`, and we relay it upstream.
|
|
17
|
+
*
|
|
18
|
+
* Translation is intentionally NON-incremental: we call the upstream with
|
|
19
|
+
* `stream:false`, then synthesise a complete, spec-shaped Responses SSE stream.
|
|
20
|
+
* Codex rebuilds a turn from `response.output_item.done` items plus the final
|
|
21
|
+
* `response.completed`, so a fully-populated terminal payload is authoritative;
|
|
22
|
+
* this sidesteps fragile per-token delta bookkeeping while still surfacing
|
|
23
|
+
* assistant text AND tool/function calls (apply_patch, shell, MCP tools).
|
|
24
|
+
*/
|
|
25
|
+
import http from 'node:http';
|
|
26
|
+
import { writeScopedLog } from '../core/logging.js';
|
|
27
|
+
const SCOPE = 'model-bridge';
|
|
28
|
+
const log = (m) => { writeScopedLog(SCOPE, m); };
|
|
29
|
+
const warn = (m) => { writeScopedLog(SCOPE, m, { level: 'warn', stream: 'stderr' }); };
|
|
30
|
+
let server = null;
|
|
31
|
+
let listenPort = 0;
|
|
32
|
+
let starting = null;
|
|
33
|
+
let idCounter = 0;
|
|
34
|
+
function genId(prefix) {
|
|
35
|
+
idCounter += 1;
|
|
36
|
+
return `${prefix}_${Date.now().toString(36)}${idCounter.toString(36)}`;
|
|
37
|
+
}
|
|
38
|
+
function num(v) { return typeof v === 'number' && Number.isFinite(v) ? v : 0; }
|
|
39
|
+
/** base64url-encode an upstream base URL so it survives as a single path segment. */
|
|
40
|
+
export function upstreamToken(baseURL) {
|
|
41
|
+
return Buffer.from(baseURL, 'utf8').toString('base64url');
|
|
42
|
+
}
|
|
43
|
+
function decodeUpstream(token) {
|
|
44
|
+
try {
|
|
45
|
+
return Buffer.from(token, 'base64url').toString('utf8') || null;
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return null;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/** Start (or reuse) the singleton bridge server; resolves to its localhost port. */
|
|
52
|
+
export async function ensureResponsesBridge() {
|
|
53
|
+
if (server && listenPort)
|
|
54
|
+
return listenPort;
|
|
55
|
+
if (starting)
|
|
56
|
+
return starting;
|
|
57
|
+
starting = new Promise((resolve, reject) => {
|
|
58
|
+
const srv = http.createServer(handleRequest);
|
|
59
|
+
srv.on('error', err => { warn(`server error: ${err?.message || err}`); reject(err); });
|
|
60
|
+
srv.listen(0, '127.0.0.1', () => {
|
|
61
|
+
server = srv;
|
|
62
|
+
const addr = srv.address();
|
|
63
|
+
listenPort = typeof addr === 'object' && addr ? addr.port : 0;
|
|
64
|
+
log(`listening on 127.0.0.1:${listenPort}`);
|
|
65
|
+
resolve(listenPort);
|
|
66
|
+
});
|
|
67
|
+
});
|
|
68
|
+
try {
|
|
69
|
+
return await starting;
|
|
70
|
+
}
|
|
71
|
+
finally {
|
|
72
|
+
starting = null;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
export function shutdownResponsesBridge() {
|
|
76
|
+
try {
|
|
77
|
+
server?.close();
|
|
78
|
+
}
|
|
79
|
+
catch { /* ignore */ }
|
|
80
|
+
server = null;
|
|
81
|
+
listenPort = 0;
|
|
82
|
+
}
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// HTTP handling
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
function handleRequest(req, res) {
|
|
87
|
+
const url = new URL(req.url || '/', 'http://127.0.0.1');
|
|
88
|
+
const m = url.pathname.match(/^\/u\/([^/]+)\/(responses|models)$/);
|
|
89
|
+
if (!m) {
|
|
90
|
+
res.writeHead(404).end('not found');
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const upstreamBase = decodeUpstream(m[1]);
|
|
94
|
+
if (!upstreamBase) {
|
|
95
|
+
res.writeHead(400).end('bad upstream token');
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
if (m[2] === 'models') {
|
|
99
|
+
// Codex's model-catalog refresh is best-effort; an empty list keeps it quiet
|
|
100
|
+
// and never blocks the turn.
|
|
101
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
102
|
+
res.end(JSON.stringify({ object: 'list', data: [], models: [] }));
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (req.method !== 'POST') {
|
|
106
|
+
res.writeHead(405).end('method not allowed');
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
const chunks = [];
|
|
110
|
+
req.on('data', c => chunks.push(c));
|
|
111
|
+
req.on('end', () => {
|
|
112
|
+
let body = {};
|
|
113
|
+
try {
|
|
114
|
+
body = JSON.parse(Buffer.concat(chunks).toString('utf8') || '{}');
|
|
115
|
+
}
|
|
116
|
+
catch {
|
|
117
|
+
body = {};
|
|
118
|
+
}
|
|
119
|
+
handleResponses(req, res, upstreamBase, body).catch(err => {
|
|
120
|
+
warn(`handler error: ${err?.message || err}`);
|
|
121
|
+
sendResponsesError(res, `bridge error: ${err?.message || err}`);
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
async function handleResponses(req, res, upstreamBase, body) {
|
|
126
|
+
const chatReq = toChatRequest(body);
|
|
127
|
+
const auth = req.headers['authorization'];
|
|
128
|
+
const upstreamUrl = chatCompletionsUrl(upstreamBase);
|
|
129
|
+
log(`-> ${upstreamUrl} model=${chatReq.model} msgs=${chatReq.messages.length} tools=${chatReq.tools?.length ?? 0}`);
|
|
130
|
+
let upstreamResp;
|
|
131
|
+
try {
|
|
132
|
+
upstreamResp = await fetch(upstreamUrl, {
|
|
133
|
+
method: 'POST',
|
|
134
|
+
headers: {
|
|
135
|
+
'content-type': 'application/json',
|
|
136
|
+
...(auth ? { authorization: Array.isArray(auth) ? auth[0] : auth } : {}),
|
|
137
|
+
},
|
|
138
|
+
body: JSON.stringify(chatReq),
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
catch (e) {
|
|
142
|
+
sendResponsesError(res, `upstream fetch failed: ${e?.message || e}`);
|
|
143
|
+
return;
|
|
144
|
+
}
|
|
145
|
+
const raw = await upstreamResp.text();
|
|
146
|
+
if (!upstreamResp.ok) {
|
|
147
|
+
warn(`upstream ${upstreamResp.status}: ${raw.slice(0, 300)}`);
|
|
148
|
+
sendResponsesError(res, `upstream ${upstreamResp.status}: ${raw.slice(0, 500)}`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
let chat;
|
|
152
|
+
try {
|
|
153
|
+
chat = JSON.parse(raw);
|
|
154
|
+
}
|
|
155
|
+
catch {
|
|
156
|
+
sendResponsesError(res, `bad upstream JSON: ${raw.slice(0, 200)}`);
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
const events = buildResponsesEvents(chat, chatReq.model);
|
|
160
|
+
res.writeHead(200, {
|
|
161
|
+
'content-type': 'text/event-stream',
|
|
162
|
+
'cache-control': 'no-cache',
|
|
163
|
+
connection: 'keep-alive',
|
|
164
|
+
});
|
|
165
|
+
for (const ev of events) {
|
|
166
|
+
res.write(`event: ${ev.type}\n`);
|
|
167
|
+
res.write(`data: ${JSON.stringify(ev)}\n\n`);
|
|
168
|
+
}
|
|
169
|
+
res.end();
|
|
170
|
+
}
|
|
171
|
+
function sendResponsesError(res, message) {
|
|
172
|
+
if (res.headersSent) {
|
|
173
|
+
try {
|
|
174
|
+
res.end();
|
|
175
|
+
}
|
|
176
|
+
catch { /* ignore */ }
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
res.writeHead(200, {
|
|
180
|
+
'content-type': 'text/event-stream',
|
|
181
|
+
'cache-control': 'no-cache',
|
|
182
|
+
connection: 'keep-alive',
|
|
183
|
+
});
|
|
184
|
+
const id = genId('resp');
|
|
185
|
+
let seq = 0;
|
|
186
|
+
const emit = (e) => { res.write(`event: ${e.type}\n`); res.write(`data: ${JSON.stringify({ ...e, sequence_number: seq++ })}\n\n`); };
|
|
187
|
+
emit({ type: 'response.created', response: { id, object: 'response', status: 'in_progress', output: [] } });
|
|
188
|
+
emit({ type: 'response.failed', response: { id, object: 'response', status: 'failed', error: { code: 'bridge_error', message }, output: [] } });
|
|
189
|
+
res.end();
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Request translation: Responses → Chat Completions
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
function asText(content) {
|
|
195
|
+
if (typeof content === 'string')
|
|
196
|
+
return content;
|
|
197
|
+
if (Array.isArray(content)) {
|
|
198
|
+
return content
|
|
199
|
+
.map((c) => (typeof c === 'string' ? c : (typeof c?.text === 'string' ? c.text : '')))
|
|
200
|
+
.join('');
|
|
201
|
+
}
|
|
202
|
+
return '';
|
|
203
|
+
}
|
|
204
|
+
function toChatRequest(body) {
|
|
205
|
+
const messages = [];
|
|
206
|
+
if (typeof body.instructions === 'string' && body.instructions.trim()) {
|
|
207
|
+
messages.push({ role: 'system', content: body.instructions });
|
|
208
|
+
}
|
|
209
|
+
const input = Array.isArray(body.input) ? body.input : (body.input != null ? [body.input] : []);
|
|
210
|
+
for (const item of input) {
|
|
211
|
+
if (typeof item === 'string') {
|
|
212
|
+
messages.push({ role: 'user', content: item });
|
|
213
|
+
continue;
|
|
214
|
+
}
|
|
215
|
+
const type = item?.type;
|
|
216
|
+
if (type === 'message' || (!type && item?.role)) {
|
|
217
|
+
const role = item.role === 'assistant' ? 'assistant' : item.role === 'system' ? 'system' : 'user';
|
|
218
|
+
messages.push({ role, content: asText(item.content) });
|
|
219
|
+
}
|
|
220
|
+
else if (type === 'function_call') {
|
|
221
|
+
messages.push({
|
|
222
|
+
role: 'assistant',
|
|
223
|
+
content: typeof item.text === 'string' ? item.text : null,
|
|
224
|
+
tool_calls: [{
|
|
225
|
+
id: item.call_id || item.id,
|
|
226
|
+
type: 'function',
|
|
227
|
+
function: {
|
|
228
|
+
name: item.name,
|
|
229
|
+
arguments: typeof item.arguments === 'string' ? item.arguments : JSON.stringify(item.arguments ?? {}),
|
|
230
|
+
},
|
|
231
|
+
}],
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
else if (type === 'function_call_output') {
|
|
235
|
+
const out = item.output;
|
|
236
|
+
messages.push({
|
|
237
|
+
role: 'tool',
|
|
238
|
+
tool_call_id: item.call_id,
|
|
239
|
+
content: typeof out === 'string' ? out : JSON.stringify(out ?? ''),
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
else if (type === 'reasoning') {
|
|
243
|
+
// Chat models cannot ingest prior reasoning items — drop.
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
const tools = Array.isArray(body.tools)
|
|
247
|
+
? body.tools.map(toChatTool).filter((t) => t)
|
|
248
|
+
: undefined;
|
|
249
|
+
const req = { model: body.model, messages, stream: false };
|
|
250
|
+
if (tools && tools.length)
|
|
251
|
+
req.tools = tools;
|
|
252
|
+
if (body.tool_choice != null)
|
|
253
|
+
req.tool_choice = toChatToolChoice(body.tool_choice);
|
|
254
|
+
if (typeof body.temperature === 'number')
|
|
255
|
+
req.temperature = body.temperature;
|
|
256
|
+
if (typeof body.top_p === 'number')
|
|
257
|
+
req.top_p = body.top_p;
|
|
258
|
+
if (typeof body.max_output_tokens === 'number')
|
|
259
|
+
req.max_tokens = body.max_output_tokens;
|
|
260
|
+
if (typeof body.parallel_tool_calls === 'boolean' && req.tools)
|
|
261
|
+
req.parallel_tool_calls = body.parallel_tool_calls;
|
|
262
|
+
return req;
|
|
263
|
+
}
|
|
264
|
+
function toChatTool(t) {
|
|
265
|
+
if (!t)
|
|
266
|
+
return null;
|
|
267
|
+
if (t.type === 'function') {
|
|
268
|
+
if (t.function && typeof t.function === 'object')
|
|
269
|
+
return { type: 'function', function: t.function };
|
|
270
|
+
return {
|
|
271
|
+
type: 'function',
|
|
272
|
+
function: {
|
|
273
|
+
name: t.name,
|
|
274
|
+
description: t.description,
|
|
275
|
+
parameters: t.parameters || { type: 'object', properties: {} },
|
|
276
|
+
},
|
|
277
|
+
};
|
|
278
|
+
}
|
|
279
|
+
// Codex built-in custom tools (e.g. local_shell) and web_search aren't
|
|
280
|
+
// expressible as chat functions — drop; codex falls back to its function tools.
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
function toChatToolChoice(tc) {
|
|
284
|
+
if (typeof tc === 'string')
|
|
285
|
+
return tc; // auto | none | required
|
|
286
|
+
if (tc?.type === 'function' && tc.name)
|
|
287
|
+
return { type: 'function', function: { name: tc.name } };
|
|
288
|
+
if (tc?.type === 'function' && tc.function)
|
|
289
|
+
return tc;
|
|
290
|
+
return 'auto';
|
|
291
|
+
}
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
// Response synthesis: Chat Completion → Responses SSE events
|
|
294
|
+
// ---------------------------------------------------------------------------
|
|
295
|
+
function buildResponsesEvents(chat, model) {
|
|
296
|
+
const choice = chat?.choices?.[0] || {};
|
|
297
|
+
const msg = choice.message || {};
|
|
298
|
+
const items = [];
|
|
299
|
+
const text = typeof msg.content === 'string'
|
|
300
|
+
? msg.content
|
|
301
|
+
: (Array.isArray(msg.content) ? msg.content.map((c) => c?.text || '').join('') : '');
|
|
302
|
+
if (text && text.trim()) {
|
|
303
|
+
items.push({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text }] });
|
|
304
|
+
}
|
|
305
|
+
const toolCalls = Array.isArray(msg.tool_calls) ? msg.tool_calls : [];
|
|
306
|
+
for (const tc of toolCalls) {
|
|
307
|
+
const fn = tc.function || {};
|
|
308
|
+
items.push({
|
|
309
|
+
type: 'function_call',
|
|
310
|
+
name: fn.name,
|
|
311
|
+
arguments: typeof fn.arguments === 'string' ? fn.arguments : JSON.stringify(fn.arguments ?? {}),
|
|
312
|
+
call_id: tc.id || genId('call'),
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
// Always emit at least one item so codex sees a well-formed turn.
|
|
316
|
+
if (!items.length)
|
|
317
|
+
items.push({ type: 'message', role: 'assistant', content: [{ type: 'output_text', text: '' }] });
|
|
318
|
+
const respId = genId('resp');
|
|
319
|
+
const usage = chat?.usage || {};
|
|
320
|
+
const usageOut = {
|
|
321
|
+
input_tokens: num(usage.prompt_tokens),
|
|
322
|
+
output_tokens: num(usage.completion_tokens),
|
|
323
|
+
total_tokens: num(usage.total_tokens) || (num(usage.prompt_tokens) + num(usage.completion_tokens)),
|
|
324
|
+
};
|
|
325
|
+
const responseObj = (status, output) => ({
|
|
326
|
+
id: respId, object: 'response', created_at: Math.floor(Date.now() / 1000),
|
|
327
|
+
status, model, output, usage: usageOut,
|
|
328
|
+
});
|
|
329
|
+
let seq = 0;
|
|
330
|
+
const events = [];
|
|
331
|
+
const push = (e) => { events.push({ ...e, sequence_number: seq++ }); };
|
|
332
|
+
push({ type: 'response.created', response: responseObj('in_progress', []) });
|
|
333
|
+
push({ type: 'response.in_progress', response: responseObj('in_progress', []) });
|
|
334
|
+
const finalItems = [];
|
|
335
|
+
items.forEach((item, idx) => {
|
|
336
|
+
const id = genId(item.type === 'function_call' ? 'fc' : 'msg');
|
|
337
|
+
const full = { ...item, id };
|
|
338
|
+
finalItems.push(full);
|
|
339
|
+
push({ type: 'response.output_item.added', output_index: idx, item: skeleton(full) });
|
|
340
|
+
if (item.type === 'message') {
|
|
341
|
+
const t = item.content?.[0]?.text || '';
|
|
342
|
+
if (t) {
|
|
343
|
+
push({ type: 'response.output_text.delta', item_id: id, output_index: idx, content_index: 0, delta: t });
|
|
344
|
+
push({ type: 'response.output_text.done', item_id: id, output_index: idx, content_index: 0, text: t });
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
else if (item.type === 'function_call') {
|
|
348
|
+
push({ type: 'response.function_call_arguments.delta', item_id: id, output_index: idx, delta: item.arguments });
|
|
349
|
+
push({ type: 'response.function_call_arguments.done', item_id: id, output_index: idx, arguments: item.arguments });
|
|
350
|
+
}
|
|
351
|
+
push({ type: 'response.output_item.done', output_index: idx, item: full });
|
|
352
|
+
});
|
|
353
|
+
push({ type: 'response.completed', response: responseObj('completed', finalItems) });
|
|
354
|
+
return events;
|
|
355
|
+
}
|
|
356
|
+
function skeleton(item) {
|
|
357
|
+
if (item.type === 'message')
|
|
358
|
+
return { id: item.id, type: 'message', role: item.role, content: [], status: 'in_progress' };
|
|
359
|
+
if (item.type === 'function_call')
|
|
360
|
+
return { id: item.id, type: 'function_call', name: item.name, arguments: '', call_id: item.call_id, status: 'in_progress' };
|
|
361
|
+
return item;
|
|
362
|
+
}
|
|
363
|
+
function chatCompletionsUrl(base) {
|
|
364
|
+
const b = base.replace(/\/+$/, '');
|
|
365
|
+
return b.endsWith('/chat/completions') ? b : `${b}/chat/completions`;
|
|
366
|
+
}
|
package/package.json
CHANGED