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.
@@ -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
- return 'openrouter';
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
- * Codex CLI honours `model_providers.<slug>` definitions in `config.toml`.
186
- * Setting `OPENAI_BASE_URL` alone is not enough Codex still routes through
187
- * the default `openai` provider's auth flow. The robust path is to declare a
188
- * one-shot `model_providers.<slug>` via `-c` overrides and bind it via
189
- * `model_provider="<slug>"`. The credential lives in the env var named by
190
- * `env_key`, picked host-aware (e.g. `OPENROUTER_API_KEY` for openrouter.ai).
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
- * Note on `wire_api`: codex 0.130 dropped `"chat"` ("no longer supported"); we
193
- * omit the field entirely so codex picks its current default (`responses`),
194
- * which OpenRouter and other major OpenAI-compatible providers accept.
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
- const overrides = [
206
- `model_providers.${slug}.name="${tomlEscape(provider.name)}"`,
207
- `model_providers.${slug}.base_url="${tomlEscape(provider.baseURL)}"`,
208
- `model_providers.${slug}.env_key="${envKey}"`,
209
- `model_provider="${slug}"`,
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: overrides,
215
- modelOverride: profile.modelId,
216
- detail: `Codex BYOK → ${provider.name} / ${profile.modelId} (provider=${slug})`,
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
- let apiKey;
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
- throw new Error(`Failed to resolve credential for ${provider.name}: ${e?.message || e}`);
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pikiloom",
3
- "version": "0.4.12",
3
+ "version": "0.4.14",
4
4
  "description": "Put the world's smartest AI agents in your pocket. Command local Claude & Gemini via IM. | 让最好用的 IM 变成你电脑上的顶级 Agent 控制台",
5
5
  "type": "module",
6
6
  "bin": {