@bitkyc08/opencodex 2.89.0 → 2.90.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/gui/dist/assets/App-DgxkE6Md.js +58 -0
  2. package/gui/dist/assets/{Tray-7z1COnmz.js → Tray-DbTNc_88.js} +1 -1
  3. package/gui/dist/assets/{index-uchD-ngf.js → index-TmaHa54u.js} +5 -5
  4. package/gui/dist/assets/quota-summary-B16x5DZZ.js +1 -0
  5. package/gui/dist/index.html +1 -1
  6. package/package.json +2 -2
  7. package/src/adapters/base.ts +4 -0
  8. package/src/adapters/claude-cli/adapter.ts +13 -7
  9. package/src/adapters/claude-cli/usage-admission.ts +14 -2
  10. package/src/adapters/coding-agent/turn.ts +14 -4
  11. package/src/adapters/cursor/protobuf-request.ts +3 -0
  12. package/src/adapters/google-progress-history.ts +19 -1
  13. package/src/adapters/google.ts +28 -9
  14. package/src/adapters/image.ts +5 -3
  15. package/src/adapters/input-media-guard.ts +5 -2
  16. package/src/adapters/kiro-refusal.ts +41 -1
  17. package/src/adapters/ollama-native.ts +1 -0
  18. package/src/adapters/openai-chat/messages.ts +34 -4
  19. package/src/adapters/openai-chat/passthrough.ts +37 -4
  20. package/src/adapters/openai-chat/reasoning-replay.ts +98 -13
  21. package/src/adapters/openai-chat/serialized-tool-call-content.ts +95 -134
  22. package/src/adapters/openai-chat/serialized-tool-call-markup.ts +108 -0
  23. package/src/adapters/openai-chat/standalone-bare-tool-calls.ts +136 -0
  24. package/src/adapters/openai-chat.ts +7 -6
  25. package/src/adapters/openai-responses/opengateway-input.ts +139 -0
  26. package/src/adapters/openai-responses/passthrough.ts +5 -1
  27. package/src/adapters/openai-responses/transport.ts +13 -1
  28. package/src/claude/desktop-3p-library.ts +43 -3
  29. package/src/claude/desktop-3p.ts +20 -10
  30. package/src/claude/desktop-picker-profile.ts +18 -9
  31. package/src/claude/desktop-remote-store-io.ts +2 -2
  32. package/src/cli/ensure-desktop-cold-start.ts +2 -2
  33. package/src/codex/account-lifecycle.ts +19 -6
  34. package/src/codex/account-store.ts +27 -8
  35. package/src/codex/account-usability.ts +4 -1
  36. package/src/codex/auth-api/account-list.ts +10 -5
  37. package/src/codex/auth-api/account-pause-group.ts +2 -22
  38. package/src/codex/auth-api/login-collision.ts +59 -0
  39. package/src/codex/auth-api/login-flow.ts +12 -7
  40. package/src/codex/auth-api/pool-mode-gate.ts +81 -13
  41. package/src/codex/auth-collision.ts +31 -4
  42. package/src/codex/auth-context.ts +48 -6
  43. package/src/codex/cli-update-plan.ts +18 -3
  44. package/src/codex/collision-identity.ts +78 -0
  45. package/src/codex/main-account-hard-lock.ts +14 -0
  46. package/src/codex/routing/cooldown-math.ts +5 -0
  47. package/src/codex/routing/failure-window.ts +61 -9
  48. package/src/codex/routing.ts +4 -3
  49. package/src/codex/shim-diagnostics.ts +2 -1
  50. package/src/codex/shim-fingerprint.ts +11 -2
  51. package/src/codex/shim-templates.ts +16 -5
  52. package/src/codex/shim-windows-refresh.ts +224 -0
  53. package/src/codex/shim.ts +45 -21
  54. package/src/combos/failover.ts +8 -1
  55. package/src/config/provider-validation.ts +7 -2
  56. package/src/generated/compatibility-version.json +168 -108
  57. package/src/generated/model-metadata.ts +1 -1
  58. package/src/images/loop.ts +2 -2
  59. package/src/lib/bun-transpiler-cache-scope.mjs +31 -17
  60. package/src/lib/provider-admission-wait.ts +14 -0
  61. package/src/lib/service-secrets.ts +37 -2
  62. package/src/lib/translator-budget.ts +1 -1
  63. package/src/lib/upstream-retry.ts +15 -0
  64. package/src/oauth/account-import/google-antigravity-adapter.ts +38 -2
  65. package/src/oauth/account-import/parser.ts +15 -2
  66. package/src/oauth/account-import/types.ts +7 -0
  67. package/src/oauth/chatgpt-shared.ts +10 -1
  68. package/src/oauth/dsh.ts +45 -2
  69. package/src/oauth/kiro.ts +44 -1
  70. package/src/oauth/login-flow-state.ts +11 -2
  71. package/src/providers/claude-cli-usage.ts +20 -5
  72. package/src/providers/initial-model-selection.ts +2 -2
  73. package/src/providers/key-failover.ts +10 -2
  74. package/src/providers/key-store.ts +8 -3
  75. package/src/providers/mistral-transport.ts +207 -0
  76. package/src/providers/openai-sidecar.ts +5 -0
  77. package/src/providers/quota/anthropic-account-quota.ts +18 -6
  78. package/src/providers/quota/antigravity.ts +6 -2
  79. package/src/providers/quota/failure-codes.ts +18 -0
  80. package/src/providers/quota/vendor-probes-oauth.ts +12 -3
  81. package/src/providers/quota-routing-cache.ts +1 -1
  82. package/src/providers/quota.ts +5 -1
  83. package/src/providers/registry/entries-extended.ts +6 -1
  84. package/src/providers/routed-config.ts +1 -1
  85. package/src/providers/xai-transport.ts +2 -1
  86. package/src/responses/audio.ts +46 -0
  87. package/src/responses/input-media.ts +37 -15
  88. package/src/responses/parser-content.ts +22 -27
  89. package/src/responses/reasoning-retention.ts +218 -11
  90. package/src/responses/schema.ts +3 -4
  91. package/src/responses/web-search-replay.ts +11 -2
  92. package/src/server/chat-native.ts +8 -7
  93. package/src/server/index/serve-options.ts +8 -2
  94. package/src/server/index/websocket-handler.ts +10 -0
  95. package/src/server/lifecycle.ts +17 -1
  96. package/src/server/management/oauth-account-routes.ts +2 -1
  97. package/src/server/messages-native.ts +11 -0
  98. package/src/server/request-log.ts +16 -7
  99. package/src/server/responses/adapter-continuation.ts +2 -2
  100. package/src/server/responses/adapter-dispatch.ts +2 -2
  101. package/src/server/responses/codex-ws-exchange.ts +3 -0
  102. package/src/server/responses/codex-ws-metadata.ts +44 -0
  103. package/src/server/responses/codex-ws-session.ts +21 -1
  104. package/src/server/responses/combo-stream-preflight.ts +7 -2
  105. package/src/server/responses/compact.ts +2 -0
  106. package/src/server/responses/core-auth.ts +20 -0
  107. package/src/server/responses/core-codex-account.ts +21 -14
  108. package/src/server/responses/core-combo.ts +3 -0
  109. package/src/server/responses/core-opaque-recovery.ts +20 -11
  110. package/src/server/responses/deepseek-image-files.ts +121 -0
  111. package/src/server/responses/empty-completion-guard.ts +8 -0
  112. package/src/server/responses/fetch-helpers.ts +30 -10
  113. package/src/server/responses/input-admission.ts +6 -2
  114. package/src/server/responses/main-hard-lock-routing.ts +33 -8
  115. package/src/server/responses/opengateway-completion-guard.ts +232 -0
  116. package/src/server/responses/passthrough-completion.ts +41 -0
  117. package/src/server/responses/passthrough-delivery.ts +2 -0
  118. package/src/server/responses/passthrough-dispatch.ts +13 -13
  119. package/src/server/responses/passthrough-key-rotation.ts +40 -0
  120. package/src/server/responses/reasoning-archive-error.ts +21 -0
  121. package/src/server/responses/request-prepare.ts +11 -1
  122. package/src/server/responses/request-transport.ts +61 -3
  123. package/src/service/orchestration.ts +17 -0
  124. package/src/types/provider.ts +2 -0
  125. package/src/types/request.ts +16 -1
  126. package/src/types.ts +1 -0
  127. package/src/web-search/loop.ts +2 -2
  128. package/gui/dist/assets/App-BnRSujOq.js +0 -58
  129. package/gui/dist/assets/quota-summary-BY_10IEz.js +0 -1
@@ -0,0 +1,108 @@
1
+ const OPEN_TAG = "<tool_call>";
2
+ const FUNCTION_TAG = "<function=";
3
+ // MiMo gateways append an opaque hexadecimal identity to both delimiters.
4
+ export const TAG_SUFFIX = "(?:_[a-fA-F0-9]{1,64})?";
5
+ export const MAX_TAG_LENGTH = "</tool_call_".length + 64 + 1;
6
+ const OPENING = new RegExp(`^<tool_call${TAG_SUFFIX}>`);
7
+ const OPEN_PREFIX = /^<tool_call(?:_[a-fA-F0-9]{0,64})?$/;
8
+
9
+ export interface TextContext {
10
+ fence: string | null;
11
+ lineStart: boolean;
12
+ inlineTicks?: number;
13
+ literalLine?: boolean;
14
+ }
15
+
16
+ /** Splits safe visible text from a possible control block while carrying Markdown context across chunks. */
17
+ export function splitAtPossibleSerializedToolCall(
18
+ text: string,
19
+ initialContext: TextContext = { fence: null, lineStart: true },
20
+ final = false,
21
+ allowMidLineBare = false,
22
+ ): {
23
+ emit: string;
24
+ defer: string;
25
+ hasOpenTag: boolean;
26
+ context: TextContext;
27
+ } {
28
+ const context = { ...initialContext };
29
+ const split = (at: number, hasOpenTag = false) => ({
30
+ emit: text.slice(0, at), defer: text.slice(at), hasOpenTag, context,
31
+ });
32
+ for (let index = 0; index < text.length; index++) {
33
+ if (context.lineStart) {
34
+ const rest = text.slice(index);
35
+ const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)/.exec(rest);
36
+ if (!context.inlineTicks && fence && (!context.fence || (fence[1]![0] === context.fence[0]
37
+ && fence[1]!.length >= context.fence.length && /^[ \t\r]*$/.test(fence[2]!)))) {
38
+ if (!final && !rest.includes("\n")) return split(index);
39
+ context.fence = context.fence ? null : fence[1]!;
40
+ index += fence[0].length - 1;
41
+ context.lineStart = false;
42
+ continue;
43
+ }
44
+ if (!final && /^ {0,3}(`*|~*)$/.test(rest)) return split(index);
45
+ context.literalLine = /[ \t>]/.test(text[index]!);
46
+ }
47
+ // Plain wrappers retain their bare-line rule. Nonced wrappers also occur after prose;
48
+ // quotes, indentation and Markdown code remain literal, even beside a matching call.
49
+ if (!context.fence && !context.inlineTicks && !context.literalLine && text[index] === "<") {
50
+ const rest = text.slice(index);
51
+ const opening = OPENING.exec(rest)?.[0];
52
+ if (opening && (context.lineStart || opening !== OPEN_TAG || allowMidLineBare)) {
53
+ const header = rest.slice(opening.length).trimStart();
54
+ if (/^<function=[^>\r\n]+>/.test(header)) return split(index, true);
55
+ if (!final && (FUNCTION_TAG.startsWith(header)
56
+ || (header.startsWith(FUNCTION_TAG) && !/[>\r\n]/.test(header.slice(FUNCTION_TAG.length))))) {
57
+ return split(index);
58
+ }
59
+ } else if (!final && (OPEN_TAG.startsWith(rest) || OPEN_PREFIX.test(rest))) return split(index);
60
+ }
61
+ if (!context.fence && text[index] === "`") {
62
+ let end = index + 1;
63
+ while (text[end] === "`") end++;
64
+ if (!final && end === text.length) return split(index);
65
+ const ticks = end - index;
66
+ if (!context.inlineTicks) context.inlineTicks = ticks;
67
+ else if (context.inlineTicks === ticks) context.inlineTicks = undefined;
68
+ index = end - 1;
69
+ }
70
+ context.lineStart = text[index] === "\n";
71
+ }
72
+ return split(text.length);
73
+ }
74
+
75
+ /**
76
+ * The line, fence and inline-code state after `text`. Serialized blocks are neutralised first so
77
+ * the scan runs through the whole text instead of stopping at the first opening tag; a block ends
78
+ * mid-line, which is exactly what the neutral spelling reports too.
79
+ */
80
+ export function contextAfter(text: string, context: TextContext): TextContext {
81
+ if (text.length === 0) return context;
82
+ return splitAtPossibleSerializedToolCall(text.replace(new RegExp(`<tool_call${TAG_SUFFIX}>`, "g"), "<tool-call>"), context, true).context;
83
+ }
84
+
85
+ /**
86
+ * A deferred header/fence can itself be long. While its unresolved portion only grows, a
87
+ * chunk-local check suffices; the first resolving character goes back through the full splitter.
88
+ * Each unbounded phase is scanned once on entry and once on exit, preserving its Markdown rules.
89
+ */
90
+ export function deferredContinuationBreak(text: string, context: TextContext): RegExp | undefined {
91
+ const opening = OPENING.exec(text)?.[0];
92
+ if (opening) {
93
+ const header = text.slice(opening.length);
94
+ if (/^\s*$/.test(header)) return /\S/;
95
+ if (/^\s*<function=[^>\r\n]*$/.test(header)) return /[>\r\n]/;
96
+ }
97
+ if (!context.inlineTicks && context.lineStart) {
98
+ const fence = /^ {0,3}(`{3,}|~{3,})([^\n]*)$/.exec(text);
99
+ if (fence) {
100
+ if (!context.fence) return /\n/;
101
+ // Closing-fence ticks may grow only before its optional whitespace suffix.
102
+ if (!fence[2]) return fence[1]![0] === "`" ? /[^`]/ : /[^~]/;
103
+ return /[^ \t\r]/;
104
+ }
105
+ }
106
+ if (/^`+$/.test(text)) return /[^`]/;
107
+ return undefined;
108
+ }
@@ -0,0 +1,136 @@
1
+ import { TRANSLATOR_MAX_CALL_ARGUMENT_BYTES, type TranslatorBudget } from "../../lib/translator-budget";
2
+ import { jsonUtf8Bytes } from "../../lib/json-byte-size";
3
+ import type { AdapterEvent } from "../../types";
4
+ import {
5
+ hasNestedSerializedToolCallHeader, MAX_STANDALONE_CANDIDATES, recoverStandaloneBareBlocks, reconcileSerializedToolCallEvents, scanSerializedToolCalls, SerializedToolCallContentBuffer,
6
+ type FreeformToolIdentity, type StructuredToolCallReference,
7
+ } from "./serialized-tool-call-content";
8
+
9
+ const MAX_CANDIDATE_BYTES = 4 * 1024 * 1024;
10
+ type BareCall = Pick<ReturnType<typeof recoverStandaloneBareBlocks>[number], "name" | "input">;
11
+ const isMiMo = (model: string | undefined) => /(?:^|[/-])mimo-v2(?:\.|$)/i.test(model ?? "");
12
+ const argumentsFor = (call: BareCall) => JSON.stringify({ input: call.input });
13
+ function* callEvents(call: BareCall, id: string, restore: (name: string) => string): Generator<AdapterEvent> {
14
+ yield { type: "tool_call_start", id, name: restore(call.name) };
15
+ yield { type: "tool_call_delta", arguments: argumentsFor(call) };
16
+ yield { type: "tool_call_end" };
17
+ }
18
+
19
+ /** An explicitly separate policy for bare blocks with NO structured calls, never identified wrappers. */
20
+ export class StandaloneBareToolCallBuffer extends SerializedToolCallContentBuffer {
21
+ private candidates: BareCall[] = [];
22
+ private candidateBytes = 0;
23
+ private disabled = false;
24
+ private readonly eligible: boolean;
25
+
26
+ constructor(
27
+ private readonly recoveryBudget: TranslatorBudget,
28
+ model: string | undefined,
29
+ private readonly freeformTools: ReadonlyMap<string, FreeformToolIdentity>,
30
+ ) {
31
+ super(recoveryBudget, isMiMo(model) && freeformTools.size > 0);
32
+ this.eligible = isMiMo(model) && freeformTools.size > 0;
33
+ }
34
+
35
+ /** Retain candidate inputs only, with aggregate and per-call limits; overflow disables the batch. */
36
+ private remember(calls: BareCall[]): void {
37
+ if (this.disabled || calls.length === 0) return;
38
+ const sizes = calls.map(call => jsonUtf8Bytes({ name: call.name, input: call.input }));
39
+ const bytes = sizes.reduce((sum, size) => sum + size, 0);
40
+ if (sizes.some(size => size > TRANSLATOR_MAX_CALL_ARGUMENT_BYTES)
41
+ || this.candidateBytes + bytes > MAX_CANDIDATE_BYTES || this.candidates.length + calls.length > MAX_STANDALONE_CANDIDATES) {
42
+ this.clearCandidates();
43
+ this.disabled = true;
44
+ return;
45
+ }
46
+ const retained = this.recoveryBudget.reserveTransient(bytes, { kind: "live_transient" });
47
+ try {
48
+ const temporary = this.recoveryBudget.reserveTransient(bytes, { kind: "live_transient" });
49
+ try {
50
+ // Detach small inputs from a large released answer's backing string.
51
+ const copies = calls.map(({ name, input }) => JSON.parse(JSON.stringify({ name, input })) as BareCall);
52
+ this.candidates.push(...copies);
53
+ this.candidateBytes += bytes;
54
+ retained.commitRetained();
55
+ } finally { temporary.release(); }
56
+ } catch (error) {
57
+ retained.release();
58
+ throw error;
59
+ }
60
+ }
61
+
62
+ protected override releaseBounded(): AdapterEvent[] {
63
+ if (!this.eligible || this.disabled) return super.releaseBounded();
64
+ const text = this.current();
65
+ const scan = scanSerializedToolCalls(text, this.context, true);
66
+ if (scan.overflow || scan.incomplete || scan.calls.some(call => hasNestedSerializedToolCallHeader(call.body))) {
67
+ // We cannot safely recognize a later inner block after losing its unresolved outer body.
68
+ this.clearCandidates();
69
+ this.disabled = true;
70
+ return super.releaseBounded();
71
+ }
72
+ this.remember(recoverStandaloneBareBlocks(text, this.freeformTools, this.context));
73
+ return super.releaseBounded();
74
+ }
75
+
76
+ *recoverStandalone(noStructuredCalls: boolean, nextId: () => string, restore: (name: string) => string): Generator<AdapterEvent> {
77
+ if (!noStructuredCalls) {
78
+ this.clearCandidates();
79
+ this.disabled = true;
80
+ return;
81
+ }
82
+ if (!this.eligible || this.disabled) return;
83
+ if (scanSerializedToolCalls(this.current(), this.context, true).overflow) {
84
+ this.clearCandidates();
85
+ this.disabled = true;
86
+ return;
87
+ }
88
+ const held = recoverStandaloneBareBlocks(this.current(), this.freeformTools, this.context);
89
+ this.remember(held);
90
+ // The first terminal flush is the only recovery decision; content after it cannot authorize a call.
91
+ if (this.disabled || this.candidates.length === 0) { this.disabled = true; return; }
92
+ yield* this.drainRanges(held);
93
+ for (const call of this.candidates) yield* callEvents(call, nextId(), restore);
94
+ this.clearCandidates();
95
+ this.disabled = true;
96
+ }
97
+
98
+ private clearCandidates(): void {
99
+ this.recoveryBudget.releaseRetained(this.candidateBytes, { kind: "live_transient" });
100
+ this.candidateBytes = 0;
101
+ this.candidates = [];
102
+ }
103
+
104
+ override dispose(): void {
105
+ this.clearCandidates();
106
+ super.dispose();
107
+ }
108
+ }
109
+
110
+ /** Buffered counterpart; the existing structured-call/identified-wrapper reconciler owns its path. */
111
+ export function reconcileStandaloneBareToolCallEvents(
112
+ events: AdapterEvent[], start: number, end: number,
113
+ structuredCalls: readonly StructuredToolCallReference[], budget: TranslatorBudget,
114
+ model: string | undefined, freeformTools: ReadonlyMap<string, FreeformToolIdentity>, restore: (name: string) => string,
115
+ ): void {
116
+ if (structuredCalls.length > 0 || !isMiMo(model) || freeformTools.size === 0) {
117
+ reconcileSerializedToolCallEvents(events, start, end, structuredCalls, budget);
118
+ return;
119
+ }
120
+ const buffer = new StandaloneBareToolCallBuffer(budget, model, freeformTools);
121
+ const reconciled: AdapterEvent[] = [];
122
+ try {
123
+ for (const event of events.slice(start, end)) {
124
+ if (event.type !== "text_delta") reconciled.push(...buffer.hold(event).filter(held => held.type !== "heartbeat"));
125
+ else {
126
+ const text = buffer.ingest(event.text);
127
+ if (text) reconciled.push({ type: "text_delta", text });
128
+ }
129
+ }
130
+ let sequence = 0;
131
+ reconciled.push(...buffer.recoverStandalone(true, () => `call_${++sequence}`, restore), ...buffer.drain([]));
132
+ } finally {
133
+ buffer.dispose();
134
+ }
135
+ events.splice(start, end - start, ...reconciled);
136
+ }
@@ -47,7 +47,8 @@ import { messagesToChatFormat } from "./openai-chat/messages";
47
47
  import { withOpenAIChatToolNames } from "./openai-chat/tool-name-registry";
48
48
  import { openAIChatTransport, stripBracketedModelSuffix } from "./openai-chat/wire";
49
49
  import { toolChoiceToChatFormat, toolsToChatFormatForProvider } from "./openai-chat/tool-schema";
50
- import { freeformToolsByWireName, type FreeformToolIdentity, reconcileSerializedToolCallEvents, reconcileStructuredToolCall, reconcileStructuredToolCalls, SerializedToolCallContentBuffer } from "./openai-chat/serialized-tool-call-content";
50
+ import { freeformToolsByWireName, type FreeformToolIdentity, reconcileStructuredToolCall, reconcileStructuredToolCalls } from "./openai-chat/serialized-tool-call-content";
51
+ import { StandaloneBareToolCallBuffer, reconcileStandaloneBareToolCallEvents } from "./openai-chat/standalone-bare-tool-calls";
51
52
 
52
53
  export { stripBracketedModelSuffix } from "./openai-chat/wire";
53
54
  export { buildOpenAIChatPassthroughRequest } from "./openai-chat/passthrough";
@@ -300,7 +301,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
300
301
  sawArgumentsString: boolean;
301
302
  }
302
303
  const pendingToolCalls: PendingToolCall[] = [];
303
- const toolCallContent = new SerializedToolCallContentBuffer(budget);
304
+ const toolCallContent = new StandaloneBareToolCallBuffer(budget, lastRequestedModelId, freeformTools);
304
305
  const heldText = (): AdapterEvent[] => toolCallContent.drain([]);
305
306
  let toolCallSeq = 0;
306
307
  const closeToolCalls = (): PendingToolCall[] => {
@@ -322,7 +323,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
322
323
  // Returns "terminate" when a pending call cannot be dispatched, so every flush site
323
324
  // stops the turn instead of emitting an unusable call. `closeToolCalls()` runs first,
324
325
  // so budget reservations are released for every pending call even on the early return.
325
- const flushToolCalls = function* (): Generator<AdapterEvent, "continue" | "terminate"> {
326
+ const flushToolCalls = function* (terminal = true): Generator<AdapterEvent, "continue" | "terminate"> {
326
327
  const calls = closeToolCalls();
327
328
  for (const call of calls) {
328
329
  // Ingest already proved `name` is a string; the typeof guard keeps this branch
@@ -335,9 +336,9 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
335
336
  return yield* terminateWithError(unnamedToolCallEvent(pendingUsage));
336
337
  }
337
338
  }
338
- // Held markup is released only now, as one batch per response: the doubled-input repair needs every call.
339
339
  const references = reconcileStructuredToolCalls(calls.map(call => ({ wireName: call.name, restoredName: toolNames.restore(call.name), argumentsText: call.args, freeformTool: freeformTools.get(call.name) })), toolCallContent.current(), !toolCallContent.releasedAnswerText && /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? ""));
340
340
  calls.forEach((call, index) => { call.args = references[index]!.argumentsText; });
341
+ yield* toolCallContent.recoverStandalone(calls.length === 0 && terminal, () => `call_${++toolCallSeq}`, name => toolNames.restore(name));
341
342
  yield* toolCallContent.drain(references);
342
343
  for (const call of calls) {
343
344
  if (!call.id) call.id = `call_${++toolCallSeq}`;
@@ -657,7 +658,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
657
658
  yield* terminateWithError({ type: "error", message: "upstream stream ended without a terminal signal ([DONE] or finish_reason) — possible truncation" });
658
659
  return;
659
660
  }
660
- if ((yield* flushToolCalls()) === "terminate") return;
661
+ if ((yield* flushToolCalls(sawFinish)) === "terminate") return; // A cut stream never synthesizes a bare call.
661
662
  const stopReason = stopReasonFor(finishReason);
662
663
  yield { type: "done", usage: pendingUsage, ...(stopReason ? { stopReason } : {}) };
663
664
  } catch (error) {
@@ -804,7 +805,7 @@ export function createOpenAIChatAdapter(provider: OcxProviderConfig): ProviderAd
804
805
  }
805
806
  const references = reconcileStructuredToolCalls(structuredCalls, answerText, /(?:^|[/-])mimo-v2(?:\.|$)/i.test(lastRequestedModelId ?? ""));
806
807
  structuredCalls.forEach((call, index) => { call.delta.arguments = references[index]!.argumentsText; });
807
- reconcileSerializedToolCallEvents(events, contentStart, contentEnd, references, budget);
808
+ reconcileStandaloneBareToolCallEvents(events, contentStart, contentEnd, references, budget, lastRequestedModelId, freeformTools, name => toolNames.restore(name));
808
809
  const stopReason = stopReasonFor(choice.finish_reason);
809
810
  events.push({
810
811
  type: "done",
@@ -0,0 +1,139 @@
1
+ import { isPlainObject } from "./internal";
2
+ import { normalizeResponsesToolResultAdjacency } from "./tool-output-recovery";
3
+
4
+ export class OpenGatewayInputCompatibilityError extends Error {
5
+ constructor() {
6
+ super("OpenGateway cannot preserve this tool result; use text or valid input_image parts with a call_id.");
7
+ this.name = "OpenGatewayInputCompatibilityError";
8
+ }
9
+ }
10
+
11
+ const TOOL_ROUND_TYPES = new Set([
12
+ "function_call",
13
+ "function_call_output",
14
+ "custom_tool_call",
15
+ "custom_tool_call_output",
16
+ "local_shell_call",
17
+ "local_shell_call_output",
18
+ ]);
19
+
20
+ function normalizeImageDetail(part: unknown): unknown {
21
+ return isPlainObject(part) && part.type === "input_image" && part.detail === "original"
22
+ ? { ...part, detail: "high" }
23
+ : part;
24
+ }
25
+
26
+ function isToolRound(item: unknown): boolean {
27
+ return isPlainObject(item) && typeof item.type === "string" && TOOL_ROUND_TYPES.has(item.type);
28
+ }
29
+
30
+ function pairedCallId(item: Record<string, unknown>): string | undefined {
31
+ return typeof item.call_id === "string" && item.call_id.length > 0 ? item.call_id : undefined;
32
+ }
33
+
34
+ type ProjectedInput = { readonly changed: boolean; readonly input: unknown[] };
35
+
36
+ function projectOpenGatewayInput(input: readonly unknown[]): ProjectedInput {
37
+ let changed = false;
38
+ const projected: unknown[] = [];
39
+ const pendingImages: unknown[] = [];
40
+ const flushImages = (): void => {
41
+ if (pendingImages.length === 0) return;
42
+ projected.push(...pendingImages);
43
+ pendingImages.length = 0;
44
+ };
45
+ for (const item of input) {
46
+ if (!isToolRound(item)) flushImages();
47
+ if (!isPlainObject(item)) {
48
+ projected.push(item);
49
+ continue;
50
+ }
51
+ if (item.type === "function_call_output" && Array.isArray(item.output)) {
52
+ const callId = pairedCallId(item);
53
+ const content: Record<string, unknown>[] = [];
54
+ const text: string[] = [];
55
+ let supported = callId !== undefined;
56
+ let hasImage = false;
57
+ if (supported) {
58
+ for (const part of item.output) {
59
+ if (!isPlainObject(part)) {
60
+ supported = false;
61
+ break;
62
+ }
63
+ switch (part.type) {
64
+ case "text":
65
+ case "input_text":
66
+ case "output_text":
67
+ if (typeof part.text !== "string") {
68
+ supported = false;
69
+ break;
70
+ }
71
+ text.push(part.text);
72
+ content.push({ type: "input_text", text: part.text });
73
+ break;
74
+ case "input_image":
75
+ if (!((typeof part.image_url === "string" && part.image_url.length > 0)
76
+ || (typeof part.file_id === "string" && part.file_id.length > 0))
77
+ || (part.image_url !== undefined && typeof part.image_url !== "string")
78
+ || (part.file_id !== undefined && typeof part.file_id !== "string")
79
+ || (part.detail !== undefined && (typeof part.detail !== "string"
80
+ || !["auto", "low", "high", "original"].includes(part.detail)))) {
81
+ supported = false;
82
+ break;
83
+ }
84
+ content.push({ ...part, ...(part.detail === "original" ? { detail: "high" } : {}) });
85
+ hasImage = true;
86
+ break;
87
+ default:
88
+ supported = false;
89
+ }
90
+ if (!supported) break;
91
+ }
92
+ }
93
+ if (supported && callId !== undefined) {
94
+ changed = true;
95
+ const marker = `[tool output for ${callId}]`;
96
+ projected.push({
97
+ ...item,
98
+ output: text.length > 0 ? text.join("\n") : hasImage ? marker : "",
99
+ });
100
+ if (hasImage) {
101
+ pendingImages.push({
102
+ type: "message",
103
+ role: "user",
104
+ content: [{ type: "input_text", text: marker }, ...content],
105
+ });
106
+ }
107
+ continue;
108
+ }
109
+ throw new OpenGatewayInputCompatibilityError();
110
+ }
111
+ if (Array.isArray(item.content)) {
112
+ const originalContent = item.content;
113
+ const content = originalContent.map(normalizeImageDetail);
114
+ if (content.some((part, index) => part !== originalContent[index])) {
115
+ changed = true;
116
+ projected.push({ ...item, content });
117
+ continue;
118
+ }
119
+ }
120
+ projected.push(item);
121
+ }
122
+ flushImages();
123
+ return { changed, input: projected };
124
+ }
125
+
126
+ /** OpenGateway requires string tool results and auto/low/high image detail. */
127
+ export function normalizeOpenGatewayResponsesInput(body: unknown, url: string): unknown {
128
+ // Same exact-URL boundary as completion recovery; a query-routed variant is a different destination.
129
+ if (url !== "https://apis.opengateway.ai/v1/responses" || !isPlainObject(body) || !Array.isArray(body.input)) return body;
130
+
131
+ const direct = projectOpenGatewayInput(body.input);
132
+ if (!direct.changed) return body;
133
+ const aligned = normalizeResponsesToolResultAdjacency(body);
134
+ if (!isPlainObject(aligned) || !Array.isArray(aligned.input) || aligned === body) {
135
+ return { ...body, input: direct.input };
136
+ }
137
+ const paired = projectOpenGatewayInput(aligned.input);
138
+ return { ...aligned, input: paired.changed ? paired.input : direct.input };
139
+ }
@@ -55,6 +55,7 @@ import { observeOutbound } from "../../usage/cache-diagnostic";
55
55
  import { normalizeMuseToolChoice } from "./muse-tool-choice";
56
56
  import { declaresHostedWebSearch, projectHostedSearchHistory, renderCompactionSearchHistoryItems } from "./compaction-search-history";
57
57
  import { stripKimiEmptyAssistantMessages } from "./empty-assistant";
58
+ import { normalizeOpenGatewayResponsesInput } from "./opengateway-input";
58
59
 
59
60
  /**
60
61
  * Identifies DeepSeek's strict Responses replay contract: tool-bearing continuations need
@@ -508,11 +509,13 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
508
509
  || !declaresHostedWebSearch(finalBody))) {
509
510
  finalBody = projectHostedSearchHistory(finalBody);
510
511
  }
512
+ finalBody = normalizeOpenGatewayResponsesInput(finalBody, url);
511
513
  observeOutbound(parsed._rawBody, finalBody, headers);
512
514
  if (!isCanonicalOpenAiForwardProvider(provider)) {
513
515
  validateFinalCustomToolCompatibility(finalBody, provider.supportsResponsesCustomTools);
514
516
  }
515
- const body = JSON.stringify(applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName));
517
+ const wireBody = applyGithubCopilotContextTier(finalBody, provider, parsed.modelId, incoming.providerName);
518
+ const body = JSON.stringify(wireBody);
516
519
  const releaseBodyObservation = translatorBudget.observeExternallyCapped(
517
520
  "passthrough_serialization",
518
521
  Buffer.byteLength(body, "utf8"),
@@ -522,6 +525,7 @@ export function createResponsesPassthroughAdapter(provider: OcxProviderConfig):
522
525
  method: "POST",
523
526
  headers,
524
527
  body,
528
+ ...(isPlainObject(wireBody) && typeof wireBody.model === "string" ? { outboundModelId: wireBody.model } : {}),
525
529
  releaseBodyObservation,
526
530
  ...(convertedRoutedCustomToolNames ? { convertedRoutedCustomToolNames } : {}),
527
531
  ...(routedCustomToolRepairNames ? { routedCustomToolRepairNames } : {}),
@@ -23,6 +23,18 @@ export function copyResponsesClientMetadata(target: Headers, incoming: Headers,
23
23
  }
24
24
  }
25
25
 
26
+ /**
27
+ * Strip trailing slashes with a linear scan rather than a regex.
28
+ *
29
+ * `/\/+$/` retries candidate end positions when a long slash run is not at the end of the
30
+ * string; the scan walks each character once and produces the same result on every input.
31
+ */
32
+ function stripTrailingSlashes(value: string): string {
33
+ let end = value.length;
34
+ while (end > 0 && value.charCodeAt(end - 1) === 47) end -= 1;
35
+ return end === value.length ? value : value.slice(0, end);
36
+ }
37
+
26
38
  /** Shared Responses URL/auth/header construction; capabilities never grant auth authority. */
27
39
  export function buildResponsesTransport(provider: OcxProviderConfig, incoming: Pick<IncomingMeta, "headers">): {
28
40
  url: string; headers: Record<string, string>;
@@ -31,7 +43,7 @@ export function buildResponsesTransport(provider: OcxProviderConfig, incoming: P
31
43
  let url: string;
32
44
  if (provider.authMode === "forward") {
33
45
  const mayForwardCallerCredentials = isCanonicalOpenAiForwardProvider(provider);
34
- const baseUrl = mayForwardCallerCredentials ? CODEX_FORWARD_BASE_URL : provider.baseUrl.replace(/\/+$/, "");
46
+ const baseUrl = mayForwardCallerCredentials ? CODEX_FORWARD_BASE_URL : stripTrailingSlashes(provider.baseUrl);
35
47
  url = `${baseUrl}/responses`;
36
48
  if (provider.headers) Object.assign(headers, provider.headers);
37
49
  const runtimeProvider = provider as {
@@ -1,4 +1,4 @@
1
- import { closeSync, constants, fstatSync, lstatSync, openSync, readFileSync, type BigIntStats } from "node:fs";
1
+ import { closeSync, constants, fstatSync, lstatSync, openSync, readSync, type BigIntStats } from "node:fs";
2
2
  import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { claudeDesktopConfigLibraryDir, resolveConfigLibraryDir } from "./desktop-3p-paths";
@@ -44,6 +44,20 @@ export interface Desktop3pMetadata {
44
44
 
45
45
  export const DESKTOP_PICKER_ENTRY_NAME = "opencodex-picker";
46
46
 
47
+ export const MAX_DESKTOP_PROFILE_FILE_BYTES = 4 * 1024 * 1024;
48
+
49
+ export function assertDesktopLibraryTextSize(text: string): void {
50
+ if (Buffer.byteLength(text, "utf8") > MAX_DESKTOP_PROFILE_FILE_BYTES) {
51
+ throw new Error("desktop_config_too_large");
52
+ }
53
+ }
54
+
55
+ export function serializeDesktopLibraryJson(value: unknown): string {
56
+ const text = JSON.stringify(value, null, 2) + "\n";
57
+ assertDesktopLibraryTextSize(text);
58
+ return text;
59
+ }
60
+
47
61
  /** A path that no longer names the observed file was swapped; a same-path failure keeps its own errno. */
48
62
  function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolean {
49
63
  try {
@@ -54,7 +68,13 @@ function pathStillNamesExpectedFile(path: string, expected: BigIntStats): boolea
54
68
  }
55
69
 
56
70
  /** Read regular config files only; a path swap must not turn an observation into a FIFO wait. */
57
- export function readDesktopLibraryBytes(path: string): Buffer {
71
+ export function readDesktopLibraryBytes(
72
+ path: string,
73
+ maxBytes: number = MAX_DESKTOP_PROFILE_FILE_BYTES,
74
+ ): Buffer {
75
+ if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_DESKTOP_PROFILE_FILE_BYTES) {
76
+ throw new RangeError("invalid Desktop library read limit");
77
+ }
58
78
  const expected = lstatSync(path, { bigint: true });
59
79
  if (!expected.isFile()) throw new Error("desktop_config_not_regular");
60
80
  // POSIX open of a FIFO can itself block, before fstat is possible. Windows has no POSIX
@@ -73,7 +93,27 @@ export function readDesktopLibraryBytes(path: string): Buffer {
73
93
  const opened = fstatSync(fd, { bigint: true });
74
94
  if (!opened.isFile() || opened.dev !== expected.dev || opened.ino !== expected.ino
75
95
  || opened.ctimeNs !== expected.ctimeNs) throw new Error("desktop_config_not_regular");
76
- return readFileSync(fd);
96
+ if (opened.size > BigInt(maxBytes)) throw new Error("desktop_config_too_large");
97
+ // Allocate for the observed size plus one sentinel byte, not the full cap: callers scan many
98
+ // small profiles, and the cap would zero-fill megabytes per read. A file that fills the
99
+ // buffer grew after validation — keep reading within the bound so it classifies as
100
+ // desktop_config_too_large rather than silently truncating.
101
+ let bytes = Buffer.alloc(Number(opened.size) + 1);
102
+ let length = 0;
103
+ for (;;) {
104
+ const count = readSync(fd, bytes, length, Math.min(64 * 1024, bytes.length - length), null);
105
+ if (count === 0) break;
106
+ length += count;
107
+ if (length > maxBytes) throw new Error("desktop_config_too_large");
108
+ if (length === bytes.length && bytes.length <= maxBytes) {
109
+ const grown = Buffer.alloc(Math.min(maxBytes + 1, bytes.length * 2));
110
+ bytes.copy(grown, 0, 0, length);
111
+ bytes = grown;
112
+ }
113
+ }
114
+ if (length > maxBytes) throw new Error("desktop_config_too_large");
115
+ if (!pathStillNamesExpectedFile(path, opened)) throw new Error("desktop_config_not_regular");
116
+ return bytes.subarray(0, length);
77
117
  } finally {
78
118
  closeSync(fd);
79
119
  }
@@ -6,7 +6,7 @@ import { canonicalDirectory } from "./desktop-remote-store-io";
6
6
  import {
7
7
  resolveDesktop3pConfigLibraryPath, parseMetadata, SAFE_DESKTOP_PROFILE_ID, isRecord,
8
8
  isOwnedDesktopEntry, isOwnedDesktopGatewayEntry, profilePath, readDesktopProfileSnapshot, readDesktopLibraryText, readDesktopLibraryBytes,
9
- readOptionalDesktopLibraryBytes,
9
+ readOptionalDesktopLibraryBytes, assertDesktopLibraryTextSize, serializeDesktopLibraryJson,
10
10
  type Desktop3pConfigLibraryOptions, type Desktop3pMetadata, type Desktop3pMetadataEntry,
11
11
  } from "./desktop-3p-library";
12
12
  export { resolveDesktop3pConfigLibraryPath, type Desktop3pConfigLibraryOptions } from "./desktop-3p-library";
@@ -642,13 +642,24 @@ function removeDesktop3pStandardPivotLocal(
642
642
  if (targetIds.length === 0) return { ok: true, changed: false, kind: "noop", libraryPath: inspected.libraryPath };
643
643
 
644
644
  let metadataAfterPivot = metadata;
645
+ let standardPath: string | undefined;
646
+ let pivotMetadataJson: string | undefined;
645
647
  if (selectedOwnedGatewayActive) {
646
648
  const standardId = randomUUID();
647
- const standardPath = profilePath(inspected.libraryPath, standardId);
648
- atomicWriteFile(standardPath, "{}\n");
649
+ standardPath = profilePath(inspected.libraryPath, standardId);
649
650
  const standardEntry: Desktop3pMetadataEntry = { id: standardId, name: "opencodex-standard" };
650
651
  metadataAfterPivot = { ...metadata, appliedId: standardId, entries: [...metadata.entries, standardEntry] };
651
- atomicWriteFile(metadataPath, JSON.stringify(metadataAfterPivot, null, 2) + "\n");
652
+ pivotMetadataJson = serializeDesktopLibraryJson(metadataAfterPivot);
653
+ }
654
+ // Bound the final metadata before any unlink so a size refusal cannot strand
655
+ // a row pointing at an already-deleted profile.
656
+ const removalMetadataJson = serializeDesktopLibraryJson({
657
+ ...metadataAfterPivot,
658
+ entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)),
659
+ });
660
+ if (standardPath !== undefined && pivotMetadataJson !== undefined) {
661
+ atomicWriteFile(standardPath, "{}\n");
662
+ atomicWriteFile(metadataPath, pivotMetadataJson);
652
663
  }
653
664
 
654
665
  const residualPaths: string[] = [];
@@ -674,10 +685,7 @@ function removeDesktop3pStandardPivotLocal(
674
685
  }
675
686
  // Do not leave a metadata row pointing at a deleted profile. For a foreign
676
687
  // selection this only removes proven opencodex residues; appliedId is kept.
677
- atomicWriteFile(
678
- metadataPath,
679
- JSON.stringify({ ...metadataAfterPivot, entries: metadataAfterPivot.entries.filter(entry => !targetIds.includes(entry.id)) }, null, 2) + "\n",
680
- );
688
+ atomicWriteFile(metadataPath, removalMetadataJson);
681
689
  return { ok: true, changed: true, kind: "removed", libraryPath: inspected.libraryPath };
682
690
  } catch (error) {
683
691
  return { ok: false, changed: false, kind: "write_failed", libraryPath: inspected.libraryPath, reason: desktopMutationFailureReason(error) };
@@ -831,7 +839,7 @@ function writeDesktop3pConfigWithGenerator(
831
839
 
832
840
  const generated = generate();
833
841
  const previous = readDesktopProfileSnapshot(configPath);
834
- const configJson = JSON.stringify({ ...previous.foreign, ...generated }, null, 2) + "\n";
842
+ const configJson = serializeDesktopLibraryJson({ ...previous.foreign, ...generated });
835
843
  const fingerprint = createHash("sha256").update(configJson).digest("hex").slice(0, 16);
836
844
  if (refreshOnly) {
837
845
  atomicWriteFileNoFollow(configPath, configJson, undefined, {
@@ -849,9 +857,10 @@ function writeDesktop3pConfigWithGenerator(
849
857
  });
850
858
  return { written: true, path: configPath, fingerprint };
851
859
  }
860
+ const metadataJson = serializeDesktopLibraryJson({ ...metadata, appliedId: id, entries });
852
861
  atomicReplaceDesktopConfig(configPath, configJson, previous.bytes);
853
862
  try {
854
- atomicWriteFile(metadataPath, JSON.stringify({ ...metadata, appliedId: id, entries }, null, 2) + "\n");
863
+ atomicWriteFile(metadataPath, metadataJson);
855
864
  } catch (metaError) {
856
865
  // Roll back from the verified bytes in memory: reopening the backup could meet a swapped
857
866
  // entry and leave the new profile beside the old metadata. A profile this call created is
@@ -895,6 +904,7 @@ export function atomicReplaceDesktopConfig(
895
904
  previous: Buffer | undefined,
896
905
  writer: typeof atomicWriteFileNoFollow = atomicWriteFileNoFollow,
897
906
  ): { backupPath?: string } {
907
+ assertDesktopLibraryTextSize(content);
898
908
  const validateBeforeRename = () => assertDesktopProfileUnchanged(path, previous);
899
909
  validateBeforeRename();
900
910
  const backupPath = `${path}.bak`;