@bitkyc08/opencodex 2.6.26-preview.20260705 → 2.6.28-preview.20260707
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/bin/ocx.mjs +4 -4
- package/gui/dist/assets/index-ByGC8-Bm.css +1 -0
- package/gui/dist/assets/index-CkV5xFA8.js +15 -0
- package/gui/dist/index.html +2 -2
- package/package.json +4 -4
- package/src/adapters/anthropic-image-guard.ts +195 -0
- package/src/adapters/anthropic.ts +85 -14
- package/src/adapters/cursor/cursor-errors.ts +2 -2
- package/src/adapters/cursor/live-transport.ts +1 -1
- package/src/adapters/cursor/transport-retry.ts +2 -2
- package/src/adapters/google-errors.ts +1 -1
- package/src/adapters/google-http.ts +1 -1
- package/src/adapters/google-truncation.ts +1 -1
- package/src/adapters/google.ts +1 -1
- package/src/adapters/kiro-errors.ts +1 -1
- package/src/adapters/kiro-retry.ts +1 -1
- package/src/adapters/kiro-truncation.ts +1 -1
- package/src/adapters/kiro.ts +1 -1
- package/src/adapters/openai-chat.ts +12 -2
- package/src/adapters/openai-responses.ts +126 -3
- package/src/bridge.ts +164 -7
- package/src/{doctor.ts → cli/doctor.ts} +21 -4
- package/src/{cli-help.ts → cli/help.ts} +1 -1
- package/src/cli/index.ts +584 -0
- package/src/{init.ts → cli/init.ts} +6 -6
- package/src/{cli-models.ts → cli/models.ts} +2 -2
- package/src/{cli-provider.ts → cli/provider.ts} +12 -8
- package/src/{star-prompt.ts → cli/star-prompt.ts} +1 -1
- package/src/{cli-status.ts → cli/status.ts} +7 -7
- package/src/cli.ts +9 -575
- package/src/{codex-account-label.ts → codex/account-label.ts} +1 -1
- package/src/{codex-account-lifecycle.ts → codex/account-lifecycle.ts} +6 -6
- package/src/{codex-account-store.ts → codex/account-store.ts} +2 -2
- package/src/{codex-account-usability.ts → codex/account-usability.ts} +4 -4
- package/src/{codex-auth-api.ts → codex/auth-api.ts} +18 -18
- package/src/{codex-auth-collision.ts → codex/auth-collision.ts} +4 -4
- package/src/{codex-auth-context.ts → codex/auth-context.ts} +9 -9
- package/src/{codex-catalog.ts → codex/catalog.ts} +49 -20
- package/src/codex/history-migration-guardian.ts +102 -0
- package/src/{codex-history-provider.ts → codex/history-provider.ts} +111 -7
- package/src/{codex-home.ts → codex/home.ts} +1 -1
- package/src/{codex-inject.ts → codex/inject.ts} +204 -26
- package/src/{codex-journal.ts → codex/journal.ts} +2 -2
- package/src/{codex-main-account.ts → codex/main-account.ts} +2 -2
- package/src/{model-cache.ts → codex/model-cache.ts} +1 -1
- package/src/{codex-paths.ts → codex/paths.ts} +2 -2
- package/src/{codex-plugins-doctor.ts → codex/plugins-doctor.ts} +2 -2
- package/src/{codex-refresh.ts → codex/refresh.ts} +4 -4
- package/src/{codex-routing.ts → codex/routing.ts} +8 -8
- package/src/{codex-shim.ts → codex/shim.ts} +6 -5
- package/src/{codex-sync.ts → codex/sync.ts} +4 -4
- package/src/{codex-websocket-registry.ts → codex/websocket-registry.ts} +1 -1
- package/src/config.ts +2 -0
- package/src/generated/jawcode-model-metadata.ts +2 -0
- package/src/{bun-runtime.ts → lib/bun-runtime.ts} +1 -1
- package/src/{crash-guard.ts → lib/crash-guard.ts} +1 -1
- package/src/{process-control.ts → lib/process-control.ts} +1 -1
- package/src/{service-secrets.ts → lib/service-secrets.ts} +1 -1
- package/src/oauth/callback-server.ts +1 -1
- package/src/oauth/google-antigravity.ts +7 -4
- package/src/oauth/index.ts +67 -16
- package/src/oauth/login-cli.ts +2 -2
- package/src/oauth/store.ts +236 -20
- package/src/oauth/token-guardian.ts +24 -20
- package/src/oauth/types.ts +16 -0
- package/src/providers/api-keys.ts +121 -0
- package/src/{provider-context-cap.ts → providers/context-cap.ts} +1 -1
- package/src/providers/derive.ts +2 -0
- package/src/providers/key-failover.ts +145 -0
- package/src/{provider-label.ts → providers/label.ts} +1 -1
- package/src/{provider-quota.ts → providers/quota.ts} +12 -7
- package/src/providers/registry.ts +66 -4
- package/src/responses/compaction.ts +117 -0
- package/src/responses/parser.ts +89 -13
- package/src/responses/reasoning-envelope.ts +52 -0
- package/src/responses/schema.ts +15 -3
- package/src/responses/state.ts +117 -2
- package/src/router.ts +2 -0
- package/src/server/auth-cors.ts +231 -0
- package/src/server/index.ts +523 -0
- package/src/server/lifecycle.ts +73 -0
- package/src/server/management-api.ts +628 -0
- package/src/{proxy-liveness.ts → server/proxy-liveness.ts} +1 -1
- package/src/server/relay.ts +534 -0
- package/src/server/request-decompress.ts +46 -0
- package/src/server/request-log.ts +310 -0
- package/src/server/responses.ts +775 -0
- package/src/{ws-bridge.ts → server/ws-bridge.ts} +44 -13
- package/src/service.ts +19 -11
- package/src/types.ts +27 -0
- package/src/{update.ts → update/index.ts} +5 -5
- package/src/{update-job.ts → update/job.ts} +7 -6
- package/src/{update-notify.ts → update/notify.ts} +3 -3
- package/src/{usage-debug.ts → usage/debug.ts} +3 -3
- package/src/{usage-log.ts → usage/log.ts} +3 -3
- package/src/{usage-summary.ts → usage/summary.ts} +3 -3
- package/src/{usage-totals.ts → usage/totals.ts} +1 -1
- package/src/vision/describe.ts +3 -3
- package/src/vision/index.ts +21 -1
- package/src/web-search/executor.ts +4 -4
- package/src/web-search/index.ts +1 -1
- package/src/web-search/loop.ts +84 -24
- package/gui/dist/assets/index-BcHhxo1I.css +0 -1
- package/gui/dist/assets/index-DCC1q_Jx.js +0 -15
- package/src/server.ts +0 -2501
- /package/src/{codex-account-runtime-state.ts → codex/account-runtime-state.ts} +0 -0
- /package/src/{codex-quota.ts → codex/quota.ts} +0 -0
- /package/src/{abort.ts → lib/abort.ts} +0 -0
- /package/src/{debug.ts → lib/debug.ts} +0 -0
- /package/src/{errors.ts → lib/errors.ts} +0 -0
- /package/src/{open-url.ts → lib/open-url.ts} +0 -0
- /package/src/{privacy.ts → lib/privacy.ts} +0 -0
- /package/src/{redact.ts → lib/redact.ts} +0 -0
- /package/src/{sidecar-tracker.ts → lib/sidecar-tracker.ts} +0 -0
- /package/src/{upstream-retry.ts → lib/upstream-retry.ts} +0 -0
- /package/src/{win-paths.ts → lib/win-paths.ts} +0 -0
- /package/src/{ports.ts → server/ports.ts} +0 -0
package/src/responses/parser.ts
CHANGED
|
@@ -12,6 +12,8 @@ import type {
|
|
|
12
12
|
} from "../types";
|
|
13
13
|
import { namespacedToolName } from "../types";
|
|
14
14
|
import { responsesRequestSchema } from "./schema";
|
|
15
|
+
import { compactionItemToText } from "./compaction";
|
|
16
|
+
import { decodeReasoningEnvelope } from "./reasoning-envelope";
|
|
15
17
|
import { extractHostedWebSearch, WEB_SEARCH_TOOL_NAME } from "../web-search/synthetic-tool";
|
|
16
18
|
|
|
17
19
|
function isObj(v: unknown): v is Record<string, unknown> {
|
|
@@ -37,7 +39,7 @@ function inputContentParts(blocks: unknown[] | string | undefined): string | Ocx
|
|
|
37
39
|
if (b.image_url) {
|
|
38
40
|
// Preserve the image as a structured part — adapters send it as a native image block.
|
|
39
41
|
// NEVER inline the (often base64 data-URL) image_url as text: that explodes the token count.
|
|
40
|
-
parts.push({ type: "image", imageUrl: b.image_url, ...(b.detail ? { detail: b.detail } : {}) });
|
|
42
|
+
parts.push({ type: "image", imageUrl: b.image_url, ...(b.detail ? { detail: normalizeImageDetail(b.detail) } : {}) });
|
|
41
43
|
} else {
|
|
42
44
|
parts.push({ type: "text", text: `[image: ${b.file_id ?? "?"}]` }); // file_id ref → no inline data
|
|
43
45
|
}
|
|
@@ -180,19 +182,30 @@ function outputToToolResultContent(output: string | unknown[] | undefined): stri
|
|
|
180
182
|
let hasImage = false;
|
|
181
183
|
for (const raw of output) {
|
|
182
184
|
if (!isObj(raw)) continue;
|
|
183
|
-
if (raw.type === "output_text" || raw.type === "text") {
|
|
185
|
+
if (raw.type === "output_text" || raw.type === "text" || raw.type === "input_text") {
|
|
184
186
|
if (typeof raw.text === "string") parts.push({ type: "text", text: raw.text });
|
|
185
187
|
} else if (raw.type === "refusal" && typeof raw.refusal === "string") {
|
|
186
188
|
parts.push({ type: "text", text: `[refusal: ${raw.refusal}]` });
|
|
187
189
|
} else if (raw.type === "input_image" && typeof raw.image_url === "string") {
|
|
188
|
-
parts.push({ type: "image", imageUrl: raw.image_url, ...(typeof raw.detail === "string" ? { detail: raw.detail } : {}) });
|
|
190
|
+
parts.push({ type: "image", imageUrl: raw.image_url, ...(typeof raw.detail === "string" ? { detail: normalizeImageDetail(raw.detail) } : {}) });
|
|
189
191
|
hasImage = true;
|
|
192
|
+
} else if (raw.type === "encrypted_content") {
|
|
193
|
+
// codex-rs FunctionCallOutputContentItem::EncryptedContent — opaque to routed models.
|
|
194
|
+
parts.push({ type: "text", text: "[encrypted content omitted]" });
|
|
190
195
|
}
|
|
191
196
|
}
|
|
192
197
|
if (!hasImage) return parts.map(p => (p.type === "text" ? p.text : "")).join("");
|
|
193
198
|
return parts;
|
|
194
199
|
}
|
|
195
200
|
|
|
201
|
+
/**
|
|
202
|
+
* codex-rs ImageDetail allows "original", but chat-completions providers only accept
|
|
203
|
+
* auto|low|high on image_url.detail — degrade "original" to "high" (the codex default).
|
|
204
|
+
*/
|
|
205
|
+
function normalizeImageDetail(detail: string): string {
|
|
206
|
+
return detail === "original" ? "high" : detail;
|
|
207
|
+
}
|
|
208
|
+
|
|
196
209
|
function findToolById(messages: OcxMessage[], callId: string): { name: string; namespace?: string } {
|
|
197
210
|
for (let i = messages.length - 1; i >= 0; i--) {
|
|
198
211
|
const m = messages[i];
|
|
@@ -218,6 +231,9 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
218
231
|
// Tool specs surfaced by a prior tool_search (deferred tools, e.g. subagents). Codex does not
|
|
219
232
|
// re-list these in `tools`, but chat models can only call listed tools — so we re-inject them.
|
|
220
233
|
const loadedToolSpecs: unknown[] = [];
|
|
234
|
+
// Remote compaction v2: the input tail carries `{type:"compaction_trigger"}` and Codex expects a
|
|
235
|
+
// synthetic `{type:"compaction"}` output item (src/responses/compaction.ts). Flagged for the server.
|
|
236
|
+
let compactionRequest = false;
|
|
221
237
|
|
|
222
238
|
if (typeof data.instructions === "string" && data.instructions.length > 0) {
|
|
223
239
|
systemPrompt.push(data.instructions);
|
|
@@ -229,6 +245,27 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
229
245
|
for (const item of data.input) {
|
|
230
246
|
const effectiveType = (item as { type?: string }).type ?? ("role" in item ? "message" : undefined);
|
|
231
247
|
|
|
248
|
+
if (effectiveType === "compaction_trigger") {
|
|
249
|
+
compactionRequest = true;
|
|
250
|
+
continue;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (effectiveType === "compaction" || effectiveType === "compaction_summary" || effectiveType === "context_compaction") {
|
|
254
|
+
// A stored summary from a previous compaction. Decode our ocx1 envelope into plain text so
|
|
255
|
+
// the routed model keeps the compacted context; real OpenAI-encrypted blobs degrade to a note.
|
|
256
|
+
// `context_compaction` (encrypted_content optional) is codex-rs's local-compaction marker;
|
|
257
|
+
// with no payload it is a pure marker (the summary follows as its own user message), so it
|
|
258
|
+
// is dropped silently. It must NOT flag _compactionRequest.
|
|
259
|
+
const encrypted = (item as { encrypted_content?: unknown }).encrypted_content;
|
|
260
|
+
if (effectiveType === "context_compaction" && typeof encrypted !== "string") continue;
|
|
261
|
+
messages.push({
|
|
262
|
+
role: "user",
|
|
263
|
+
content: compactionItemToText(typeof encrypted === "string" ? encrypted : undefined),
|
|
264
|
+
timestamp: now,
|
|
265
|
+
});
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
|
|
232
269
|
if (effectiveType === "message") {
|
|
233
270
|
const msg = item as { role?: string; content?: unknown };
|
|
234
271
|
switch (msg.role) {
|
|
@@ -254,13 +291,20 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
254
291
|
}
|
|
255
292
|
|
|
256
293
|
if (effectiveType === "reasoning") {
|
|
257
|
-
const reasoning = item as { id?: string; summary?: { text: string }[]; content?: { text: string }[] };
|
|
294
|
+
const reasoning = item as { id?: string; summary?: { text: string }[]; content?: { text: string }[]; encrypted_content?: string };
|
|
258
295
|
const fromSummary = (reasoning.summary ?? []).map(c => c.text).join("");
|
|
259
296
|
const text = fromSummary || (reasoning.content ?? []).map(c => c.text).join("");
|
|
297
|
+
// ocxr1 envelope: the REAL Anthropic signature (+ redacted blocks, + hidden signed text)
|
|
298
|
+
// captured by the bridge. Native OpenAI-encrypted blobs decode to null and keep today's
|
|
299
|
+
// placeholder signature (which the anthropic adapter correctly rejects on replay).
|
|
300
|
+
const envelope = typeof reasoning.encrypted_content === "string"
|
|
301
|
+
? decodeReasoningEnvelope(reasoning.encrypted_content)
|
|
302
|
+
: null;
|
|
260
303
|
const thinking: OcxThinkingContent = {
|
|
261
304
|
type: "thinking",
|
|
262
|
-
thinking: text,
|
|
263
|
-
signature: JSON.stringify(reasoning),
|
|
305
|
+
thinking: envelope?.txt || text,
|
|
306
|
+
signature: envelope?.sig ?? JSON.stringify(reasoning),
|
|
307
|
+
...(envelope?.red ? { redacted: envelope.red } : {}),
|
|
264
308
|
...(reasoning.id ? { itemId: reasoning.id } : {}),
|
|
265
309
|
};
|
|
266
310
|
ensureAssistantPlaceholder(messages, data.model, now).content.push(thinking);
|
|
@@ -302,6 +346,32 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
302
346
|
continue;
|
|
303
347
|
}
|
|
304
348
|
|
|
349
|
+
if (effectiveType === "local_shell_call") {
|
|
350
|
+
// codex-rs LocalShellCall replay: pair it as an assistant toolCall so the subsequent
|
|
351
|
+
// function_call_output (same call_id) doesn't become an orphaned tool result.
|
|
352
|
+
const call = item as { id?: string; call_id?: string; action?: { type?: string; command?: string[] } };
|
|
353
|
+
const callId = call.call_id ?? call.id;
|
|
354
|
+
if (callId) {
|
|
355
|
+
const command = Array.isArray(call.action?.command) ? call.action.command : [];
|
|
356
|
+
ensureAssistantPlaceholder(messages, data.model, now).content.push({
|
|
357
|
+
type: "toolCall", id: callId, name: "shell",
|
|
358
|
+
arguments: command.length > 0 ? { command } : {},
|
|
359
|
+
});
|
|
360
|
+
}
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (effectiveType === "web_search_call") {
|
|
365
|
+
// Replayed hosted web-search evidence. Textify it into assistant history so the model
|
|
366
|
+
// knows the search already ran (prevents re-search loops); there is no output to pair.
|
|
367
|
+
const call = item as { action?: { type?: string; query?: string } };
|
|
368
|
+
const query = typeof call.action?.query === "string" ? call.action.query : "";
|
|
369
|
+
ensureAssistantPlaceholder(messages, data.model, now).content.push({
|
|
370
|
+
type: "text", text: query ? `[web search performed: ${query}]` : "[web search performed]",
|
|
371
|
+
});
|
|
372
|
+
continue;
|
|
373
|
+
}
|
|
374
|
+
|
|
305
375
|
if (effectiveType === "tool_search_call") {
|
|
306
376
|
// Preserve the model's prior tool_search call as an assistant tool call so multi-turn
|
|
307
377
|
// history stays complete (otherwise the model re-issues tool_search forever).
|
|
@@ -316,7 +386,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
316
386
|
|
|
317
387
|
if (effectiveType === "tool_search_output") {
|
|
318
388
|
// Pair the tool_search call with its result so the model sees what was loaded.
|
|
319
|
-
const out = item as { call_id?: string; tools?: unknown[] };
|
|
389
|
+
const out = item as { call_id?: string; status?: string; tools?: unknown[] };
|
|
320
390
|
const specs = Array.isArray(out.tools) ? (out.tools as Record<string, unknown>[]) : [];
|
|
321
391
|
loadedToolSpecs.push(...specs);
|
|
322
392
|
// List the EXACT wire names the model must call (flattened for namespaced specs), matching
|
|
@@ -331,12 +401,15 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
331
401
|
wireNames.push(spec.name);
|
|
332
402
|
}
|
|
333
403
|
}
|
|
404
|
+
const failed = typeof out.status === "string" && out.status !== "completed" && out.status !== "success";
|
|
334
405
|
messages.push({
|
|
335
406
|
role: "toolResult", toolCallId: out.call_id ?? "", toolName: "tool_search",
|
|
336
|
-
content: wireNames.length
|
|
337
|
-
? `Tool search
|
|
338
|
-
:
|
|
339
|
-
|
|
407
|
+
content: failed && wireNames.length === 0
|
|
408
|
+
? `Tool search failed (status: ${out.status}).`
|
|
409
|
+
: wireNames.length
|
|
410
|
+
? `Tool search loaded these tools — they are now in your available tools. Call one by its EXACT name: ${wireNames.join(", ")}.`
|
|
411
|
+
: "Tool search returned no tools.",
|
|
412
|
+
isError: failed && wireNames.length === 0, timestamp: now,
|
|
340
413
|
});
|
|
341
414
|
continue;
|
|
342
415
|
}
|
|
@@ -353,12 +426,14 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
353
426
|
}
|
|
354
427
|
|
|
355
428
|
if (effectiveType === "custom_tool_call_output") {
|
|
356
|
-
const output = item as { call_id: string; output: string };
|
|
429
|
+
const output = item as { call_id: string; output: string | unknown[] };
|
|
357
430
|
const toolInfo = findToolById(messages, output.call_id);
|
|
358
431
|
messages.push({
|
|
359
432
|
role: "toolResult", toolCallId: output.call_id,
|
|
360
433
|
toolName: toolInfo.name, toolNamespace: toolInfo.namespace,
|
|
361
|
-
|
|
434
|
+
// Same payload shape as function_call_output (codex-rs FunctionCallOutputPayload):
|
|
435
|
+
// string or content items — normalize arrays instead of leaking raw wire blocks.
|
|
436
|
+
content: outputToToolResultContent(output.output), isError: false, timestamp: now,
|
|
362
437
|
});
|
|
363
438
|
}
|
|
364
439
|
}
|
|
@@ -416,6 +491,7 @@ export function parseRequest(body: unknown): OcxParsedRequest {
|
|
|
416
491
|
_rawBody: body,
|
|
417
492
|
...(webSearch ? { _webSearch: webSearch } : {}),
|
|
418
493
|
...(structuredOutput ? { _structuredOutput: true } : {}),
|
|
494
|
+
...(compactionRequest ? { _compactionRequest: true } : {}),
|
|
419
495
|
};
|
|
420
496
|
}
|
|
421
497
|
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Anthropic extended-thinking signature round-trip through Codex's `encrypted_content` slot.
|
|
3
|
+
*
|
|
4
|
+
* Anthropic requires the previous assistant turn's `thinking`/`redacted_thinking` blocks to be
|
|
5
|
+
* replayed VERBATIM (with their signatures) while extended thinking is enabled; a signature-less
|
|
6
|
+
* replay 400s ("Expected `thinking` or `redacted_thinking`, but found `tool_use`"). Codex round-trips
|
|
7
|
+
* whatever `encrypted_content` a reasoning output item carries (include: reasoning.encrypted_content
|
|
8
|
+
* is set whenever reasoning is on — codex-rs client.rs), so the proxy smuggles the real Anthropic
|
|
9
|
+
* signature (and any redacted blocks) inside a transparent `ocxr1:` + base64(JSON) envelope.
|
|
10
|
+
*
|
|
11
|
+
* Native OpenAI-encrypted blobs (no ocxr1 prefix) are left untouched by the decoder, and the
|
|
12
|
+
* passthrough scrub strips ocxr1 envelopes before native forwarding.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export const OCX_REASONING_PREFIX = "ocxr1:";
|
|
16
|
+
|
|
17
|
+
export interface ReasoningEnvelope {
|
|
18
|
+
/** Anthropic thinking-block signature (signature_delta), if captured. */
|
|
19
|
+
sig?: string;
|
|
20
|
+
/** Raw redacted_thinking block data payloads, order preserved. */
|
|
21
|
+
red?: string[];
|
|
22
|
+
/**
|
|
23
|
+
* Hidden thinking text (hideThinkingSummary providers): the signature signs this exact text,
|
|
24
|
+
* so replay needs it even though the visible summary was suppressed.
|
|
25
|
+
*/
|
|
26
|
+
txt?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function encodeReasoningEnvelope(envelope: ReasoningEnvelope): string {
|
|
30
|
+
return OCX_REASONING_PREFIX + Buffer.from(JSON.stringify(envelope), "utf-8").toString("base64");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Decode an ocxr1 envelope; returns null for native (OpenAI-encrypted) blobs or garbage. */
|
|
34
|
+
export function decodeReasoningEnvelope(encryptedContent: string): ReasoningEnvelope | null {
|
|
35
|
+
if (!encryptedContent.startsWith(OCX_REASONING_PREFIX)) return null;
|
|
36
|
+
try {
|
|
37
|
+
const parsed: unknown = JSON.parse(Buffer.from(encryptedContent.slice(OCX_REASONING_PREFIX.length), "base64").toString("utf-8"));
|
|
38
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
|
|
39
|
+
const obj = parsed as { sig?: unknown; red?: unknown };
|
|
40
|
+
const envelope: ReasoningEnvelope = {};
|
|
41
|
+
if (typeof obj.sig === "string") envelope.sig = obj.sig;
|
|
42
|
+
if (Array.isArray(obj.red)) {
|
|
43
|
+
const red = obj.red.filter((r): r is string => typeof r === "string");
|
|
44
|
+
if (red.length > 0) envelope.red = red;
|
|
45
|
+
}
|
|
46
|
+
const txt = (parsed as { txt?: unknown }).txt;
|
|
47
|
+
if (typeof txt === "string" && txt.length > 0) envelope.txt = txt;
|
|
48
|
+
return envelope.sig || envelope.red || envelope.txt ? envelope : null;
|
|
49
|
+
} catch {
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
}
|
package/src/responses/schema.ts
CHANGED
|
@@ -4,7 +4,8 @@ const inputTextSchema = z.object({ type: z.literal("input_text"), text: z.string
|
|
|
4
4
|
const plainTextSchema = z.object({ type: z.literal("text"), text: z.string() });
|
|
5
5
|
const inputImageBlockSchema = z.object({
|
|
6
6
|
type: z.literal("input_image"),
|
|
7
|
-
|
|
7
|
+
// codex-rs ImageDetail: auto|low|high|original (view_image --detail original).
|
|
8
|
+
detail: z.enum(["auto", "low", "high", "original"]).optional(),
|
|
8
9
|
image_url: z.string().optional(),
|
|
9
10
|
file_id: z.string().optional(),
|
|
10
11
|
}).refine(v => typeof v.image_url === "string" || typeof v.file_id === "string", {
|
|
@@ -20,9 +21,17 @@ const outputTextSchema = z.object({ type: z.literal("output_text"), text: z.stri
|
|
|
20
21
|
const outputRefusalSchema = z.object({ type: z.literal("refusal"), refusal: z.string() });
|
|
21
22
|
const summaryTextSchema = z.object({ type: z.literal("summary_text"), text: z.string() });
|
|
22
23
|
const reasoningTextSchema = z.object({ type: z.literal("reasoning_text"), text: z.string() });
|
|
24
|
+
// codex-rs FunctionCallOutputContentItem (protocol/src/models.rs): input_text | input_image | encrypted_content.
|
|
25
|
+
const encryptedContentBlockSchema = z.object({ type: z.literal("encrypted_content"), encrypted_content: z.string() });
|
|
23
26
|
|
|
24
27
|
const inputContentBlockSchema = z.union([inputTextSchema, plainTextSchema, inputImageBlockSchema, inputFileBlockSchema]);
|
|
25
28
|
const outputContentBlockSchema = z.union([outputTextSchema, plainTextSchema, outputRefusalSchema]);
|
|
29
|
+
// Tool outputs on the wire mix codex-rs FunctionCallOutputContentItem with legacy output blocks.
|
|
30
|
+
const toolOutputContentBlockSchema = z.union([
|
|
31
|
+
outputTextSchema, plainTextSchema, outputRefusalSchema,
|
|
32
|
+
inputTextSchema, inputImageBlockSchema, encryptedContentBlockSchema,
|
|
33
|
+
]);
|
|
34
|
+
const toolOutputSchema = z.union([z.string(), z.array(toolOutputContentBlockSchema)]);
|
|
26
35
|
|
|
27
36
|
const userMessageItemSchema = z.object({
|
|
28
37
|
type: z.literal("message").optional(),
|
|
@@ -44,6 +53,8 @@ const reasoningItemSchema = z.object({
|
|
|
44
53
|
id: z.string().optional(),
|
|
45
54
|
summary: z.array(summaryTextSchema).optional(),
|
|
46
55
|
content: z.array(reasoningTextSchema).optional(),
|
|
56
|
+
// Round-tripped opaque payload (native OpenAI encryption OR the proxy's ocxr1 envelope).
|
|
57
|
+
encrypted_content: z.string().optional(),
|
|
47
58
|
});
|
|
48
59
|
const functionCallItemSchema = z.object({
|
|
49
60
|
type: z.literal("function_call"),
|
|
@@ -56,7 +67,7 @@ const functionCallItemSchema = z.object({
|
|
|
56
67
|
const functionCallOutputItemSchema = z.object({
|
|
57
68
|
type: z.literal("function_call_output"),
|
|
58
69
|
call_id: z.string().min(1),
|
|
59
|
-
output:
|
|
70
|
+
output: toolOutputSchema.optional(),
|
|
60
71
|
});
|
|
61
72
|
const customToolCallItemSchema = z.object({
|
|
62
73
|
type: z.literal("custom_tool_call"),
|
|
@@ -68,7 +79,8 @@ const customToolCallItemSchema = z.object({
|
|
|
68
79
|
const customToolCallOutputItemSchema = z.object({
|
|
69
80
|
type: z.literal("custom_tool_call_output"),
|
|
70
81
|
call_id: z.string().min(1),
|
|
71
|
-
|
|
82
|
+
// codex-rs CustomToolCallOutput carries FunctionCallOutputPayload: string OR content items.
|
|
83
|
+
output: toolOutputSchema,
|
|
72
84
|
});
|
|
73
85
|
|
|
74
86
|
export const inputItemSchema = z.union([
|
package/src/responses/state.ts
CHANGED
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, unlinkSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
import { atomicWriteFile, getConfigDir } from "../config";
|
|
4
|
+
|
|
1
5
|
const MAX_STORED_RESPONSES = 1_000;
|
|
2
6
|
const RESPONSE_TTL_MS = 60 * 60 * 1_000;
|
|
7
|
+
const SNAPSHOT_DEBOUNCE_MS = 2_000;
|
|
8
|
+
/** Entries whose serialized size exceeds this are kept in memory but skipped on disk: inputs can
|
|
9
|
+
* carry base64 `input_image` data URLs, and one screenshot-heavy thread must not balloon the file. */
|
|
10
|
+
const SNAPSHOT_ENTRY_MAX_BYTES = 2 * 1024 * 1024;
|
|
11
|
+
const SNAPSHOT_TOTAL_MAX_BYTES = 24 * 1024 * 1024;
|
|
3
12
|
|
|
4
13
|
interface StoredResponseState {
|
|
5
14
|
createdAt: number;
|
|
@@ -9,11 +18,92 @@ interface StoredResponseState {
|
|
|
9
18
|
}
|
|
10
19
|
|
|
11
20
|
const states = new Map<string, StoredResponseState>();
|
|
21
|
+
let loaded = false;
|
|
22
|
+
let persistTimer: ReturnType<typeof setTimeout> | null = null;
|
|
23
|
+
let pendingPersistPath: string | null = null;
|
|
12
24
|
|
|
13
25
|
function now(): number {
|
|
14
26
|
return Date.now();
|
|
15
27
|
}
|
|
16
28
|
|
|
29
|
+
function snapshotPath(): string {
|
|
30
|
+
return join(getConfigDir(), "responses-state.json");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Best-effort disk snapshot so previous_response_id chains survive a proxy restart (the
|
|
35
|
+
* dominant expansion-miss cause: an in-memory-only store dies with the process, and the next
|
|
36
|
+
* chained turn then reaches the upstream as a naked delta). Load is lazy on first store access;
|
|
37
|
+
* persistence is debounced + unref'd so the hot path never blocks and the process can exit.
|
|
38
|
+
* Every disk failure is swallowed — the snapshot is a cache, not a source of truth.
|
|
39
|
+
*/
|
|
40
|
+
function ensureLoaded(): void {
|
|
41
|
+
if (loaded) return;
|
|
42
|
+
loaded = true;
|
|
43
|
+
try {
|
|
44
|
+
const path = snapshotPath();
|
|
45
|
+
if (!existsSync(path)) return;
|
|
46
|
+
const raw = JSON.parse(readFileSync(path, "utf-8")) as { version?: unknown; states?: unknown };
|
|
47
|
+
if (raw.version !== 1 || !Array.isArray(raw.states)) return;
|
|
48
|
+
for (const entry of raw.states) {
|
|
49
|
+
if (!Array.isArray(entry) || entry.length !== 2) continue;
|
|
50
|
+
const [id, state] = entry as [unknown, unknown];
|
|
51
|
+
if (typeof id !== "string" || !state || typeof state !== "object") continue;
|
|
52
|
+
const rec = state as StoredResponseState;
|
|
53
|
+
if (typeof rec.createdAt !== "number" || !Array.isArray(rec.items)) continue;
|
|
54
|
+
states.set(id, rec);
|
|
55
|
+
}
|
|
56
|
+
pruneResponses();
|
|
57
|
+
} catch {
|
|
58
|
+
/* missing/corrupt snapshot: start empty */
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function persistNow(path: string): void {
|
|
63
|
+
if (persistTimer) {
|
|
64
|
+
clearTimeout(persistTimer);
|
|
65
|
+
persistTimer = null;
|
|
66
|
+
}
|
|
67
|
+
pendingPersistPath = null;
|
|
68
|
+
try {
|
|
69
|
+
const entries: [string, StoredResponseState][] = [];
|
|
70
|
+
let total = 0;
|
|
71
|
+
// Newest-first so the most recent chains survive both caps.
|
|
72
|
+
for (const entry of [...states].reverse()) {
|
|
73
|
+
const size = JSON.stringify(entry).length;
|
|
74
|
+
if (size > SNAPSHOT_ENTRY_MAX_BYTES) continue;
|
|
75
|
+
if (total + size > SNAPSHOT_TOTAL_MAX_BYTES) break;
|
|
76
|
+
total += size;
|
|
77
|
+
entries.push(entry);
|
|
78
|
+
}
|
|
79
|
+
entries.reverse();
|
|
80
|
+
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
81
|
+
// mkdirSync's mode only applies on creation — re-harden an existing config dir so the
|
|
82
|
+
// conversation-content snapshot never lands in a group/world-readable directory.
|
|
83
|
+
try { chmodSync(dirname(path), 0o700); } catch { /* best-effort (e.g. Windows) */ }
|
|
84
|
+
atomicWriteFile(path, JSON.stringify({ version: 1, states: entries }));
|
|
85
|
+
} catch {
|
|
86
|
+
/* best-effort: disk trouble must never affect request handling */
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function schedulePersist(): void {
|
|
91
|
+
if (persistTimer) return;
|
|
92
|
+
// Resolve the target path NOW: tests (and anything else) may swap OPENCODEX_HOME before the
|
|
93
|
+
// debounce fires, and a late write must land in the home that owned the recorded state.
|
|
94
|
+
pendingPersistPath = snapshotPath();
|
|
95
|
+
const path = pendingPersistPath;
|
|
96
|
+
persistTimer = setTimeout(() => persistNow(path), SNAPSHOT_DEBOUNCE_MS);
|
|
97
|
+
(persistTimer as { unref?: () => void }).unref?.();
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Flush any pending debounced snapshot write (graceful shutdown / deterministic tests). */
|
|
101
|
+
export function flushResponseState(): void {
|
|
102
|
+
if (!persistTimer) return;
|
|
103
|
+
// Use the path captured when the write was scheduled — OPENCODEX_HOME may have moved since.
|
|
104
|
+
persistNow(pendingPersistPath ?? snapshotPath());
|
|
105
|
+
}
|
|
106
|
+
|
|
17
107
|
function inputItems(input: unknown): unknown[] {
|
|
18
108
|
if (input === undefined) return [];
|
|
19
109
|
if (Array.isArray(input)) return input;
|
|
@@ -37,6 +127,7 @@ export function expandPreviousResponseInput(body: unknown): unknown {
|
|
|
37
127
|
const request = body as Record<string, unknown>;
|
|
38
128
|
const previousId = typeof request.previous_response_id === "string" ? request.previous_response_id : undefined;
|
|
39
129
|
if (!previousId) return body;
|
|
130
|
+
ensureLoaded();
|
|
40
131
|
pruneResponses();
|
|
41
132
|
const previous = states.get(previousId);
|
|
42
133
|
if (!previous) return body;
|
|
@@ -48,6 +139,7 @@ export function expandPreviousResponseInput(body: unknown): unknown {
|
|
|
48
139
|
|
|
49
140
|
export function previousResponseConversationId(responseId: string | undefined): string | undefined {
|
|
50
141
|
if (!responseId) return undefined;
|
|
142
|
+
ensureLoaded();
|
|
51
143
|
pruneResponses();
|
|
52
144
|
return states.get(responseId)?.conversationId;
|
|
53
145
|
}
|
|
@@ -56,12 +148,19 @@ export function rememberResponseState(
|
|
|
56
148
|
requestBody: unknown,
|
|
57
149
|
response: { id?: unknown; output?: unknown; status?: unknown },
|
|
58
150
|
conversationId?: string,
|
|
151
|
+
opts?: { force?: boolean },
|
|
59
152
|
): void {
|
|
60
153
|
if (!requestBody || typeof requestBody !== "object" || Array.isArray(requestBody)) return;
|
|
61
154
|
const request = requestBody as Record<string, unknown>;
|
|
62
|
-
|
|
155
|
+
// `force` bypasses only the store:false skip: Codex sends `store:false` on every non-Azure
|
|
156
|
+
// HTTP request (and WS inherits it), yet its WS turns still chain with previous_response_id.
|
|
157
|
+
// The passthrough branch records with force so those chains can be expanded locally; the
|
|
158
|
+
// store stays in-memory with a 1h TTL, so this is a proxy-internal continuation cache, not
|
|
159
|
+
// real server-side response storage.
|
|
160
|
+
if (request.store === false && !opts?.force) return;
|
|
63
161
|
if (typeof response.id !== "string" || !Array.isArray(response.output)) return;
|
|
64
162
|
if (response.status !== undefined && response.status !== "completed") return;
|
|
163
|
+
ensureLoaded();
|
|
65
164
|
states.set(response.id, {
|
|
66
165
|
createdAt: now(),
|
|
67
166
|
items: [...inputItems(request.input), ...response.output],
|
|
@@ -76,8 +175,24 @@ export function rememberResponseState(
|
|
|
76
175
|
}),
|
|
77
176
|
});
|
|
78
177
|
pruneResponses();
|
|
178
|
+
schedulePersist();
|
|
79
179
|
}
|
|
80
180
|
|
|
81
|
-
|
|
181
|
+
/** Memory-only reset (simulates a process restart: the snapshot file survives). */
|
|
182
|
+
export function clearResponseStateMemoryForTests(): void {
|
|
183
|
+
if (persistTimer) {
|
|
184
|
+
clearTimeout(persistTimer);
|
|
185
|
+
persistTimer = null;
|
|
186
|
+
}
|
|
82
187
|
states.clear();
|
|
188
|
+
loaded = false;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
export function clearResponseStateForTests(): void {
|
|
192
|
+
clearResponseStateMemoryForTests();
|
|
193
|
+
try {
|
|
194
|
+
unlinkSync(snapshotPath());
|
|
195
|
+
} catch {
|
|
196
|
+
/* no snapshot on disk */
|
|
197
|
+
}
|
|
83
198
|
}
|
package/src/router.ts
CHANGED
|
@@ -96,6 +96,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
|
|
|
96
96
|
const noPenaltyModels = mergeStringArray(registryEntry.noPenaltyModels, provider.noPenaltyModels);
|
|
97
97
|
const autoToolChoiceOnlyModels = mergeStringArray(registryEntry.autoToolChoiceOnlyModels, provider.autoToolChoiceOnlyModels);
|
|
98
98
|
const preserveReasoningContentModels = mergeStringArray(registryEntry.preserveReasoningContentModels, provider.preserveReasoningContentModels);
|
|
99
|
+
const thinkingToggleModels = mergeStringArray(registryEntry.thinkingToggleModels, provider.thinkingToggleModels);
|
|
99
100
|
|
|
100
101
|
return {
|
|
101
102
|
...provider,
|
|
@@ -124,6 +125,7 @@ function routedProviderConfig(providerName: string, provider: OcxProviderConfig)
|
|
|
124
125
|
...(noPenaltyModels ? { noPenaltyModels } : {}),
|
|
125
126
|
...(autoToolChoiceOnlyModels ? { autoToolChoiceOnlyModels } : {}),
|
|
126
127
|
...(preserveReasoningContentModels ? { preserveReasoningContentModels } : {}),
|
|
128
|
+
...(thinkingToggleModels ? { thinkingToggleModels } : {}),
|
|
127
129
|
};
|
|
128
130
|
}
|
|
129
131
|
|